diff --git a/tai.py b/tai.py index b5ea369..4f7a90b 100755 --- a/tai.py +++ b/tai.py @@ -96,7 +96,7 @@ def _fromisoformat(text): return parsed.replace(tzinfo=tzinfo) raise ValueError("invalid datetime, use ISO like 2026-10-08T09:00") -VERSION = "1.26.1" +VERSION = "1.27.0" WORKER_STEPS = 12 CREATE_SKILL_STEPS = 40 FORK_MAX_DEPTH = 2 @@ -9712,7 +9712,8 @@ TUI_COLORS = {"black": 0, "red": 1, "green": 2, "yellow": 3, "blue": 4, "magenta TUI_THEME_ROLES = ("bg", "surface", "fg", "muted", "border", "accent", "on_accent", "selection", "success", "warning", "error", "info", "bubble_in", "bubble_out", "bubble_fg", "meta_in", "meta_out", "tick_read", - "service_bg", "service_fg", "unread", "unread_muted") + "service_bg", "service_fg", "unread", "unread_muted", + "panel", "accent2", "accent3", "code_bg", "tool_bg") TUI_THEME_DEFAULT = "dark" @@ -9743,40 +9744,45 @@ def tui_register_theme(theme): def _tui_builtin_themes(): tui_register_theme(TuiTheme("dark", True, { - "bg": "#101418", "surface": "#1b222c", "fg": "#e6edf3", "muted": "#8b949e", - "border": "#30363d", "accent": "#2f81f7", "on_accent": "#ffffff", "selection": "#1f3a5f", - "success": "#3fb950", "warning": "#d29922", "error": "#f85149", "info": "#39c5cf", - "bubble_in": "#182533", "bubble_out": "#2b5278", "bubble_fg": "#f5f5f5", - "meta_in": "#6d7f8f", "meta_out": "#7da8d3", "tick_read": "#4082bc", - "service_bg": "#213040", "service_fg": "#ffffff", "unread": "#4082bc", "unread_muted": "#3e546a"})) + "bg": "#0a0e1a", "surface": "#141b2e", "fg": "#e5e9f5", "muted": "#7f8aa8", + "border": "#2a3558", "accent": "#8b5cf6", "on_accent": "#ffffff", "selection": "#22305f", + "success": "#34d399", "warning": "#fbbf24", "error": "#fb7185", "info": "#38bdf8", + "bubble_in": "#18223d", "bubble_out": "#4338ca", "bubble_fg": "#f1f5ff", + "meta_in": "#6b7aa3", "meta_out": "#c7d2fe", "tick_read": "#67e8f9", + "service_bg": "#1d2750", "service_fg": "#a5b4fc", "unread": "#8b5cf6", "unread_muted": "#3a4668", + "panel": "#0e1424", "accent2": "#22d3ee", "accent3": "#f472b6", "code_bg": "#0b1226", "tool_bg": "#101a33"})) tui_register_theme(TuiTheme("light", False, { - "bg": "#ffffff", "surface": "#f6f8fa", "fg": "#1f2328", "muted": "#59636e", - "border": "#d1d9e0", "accent": "#0969da", "on_accent": "#ffffff", "selection": "#b6d8ff", - "success": "#1a7f37", "warning": "#9a6700", "error": "#cf222e", "info": "#1b7c83", - "bubble_in": "#ffffff", "bubble_out": "#def1fd", "bubble_fg": "#222222", - "meta_in": "#a0acb6", "meta_out": "#86a8c2", "tick_read": "#40a7e3", - "service_bg": "#a6c2d3", "service_fg": "#ffffff", "unread": "#40a7e3", "unread_muted": "#bbbbbb"})) + "bg": "#f5f7ff", "surface": "#ffffff", "fg": "#1b2036", "muted": "#6a7494", + "border": "#cdd5f0", "accent": "#6d4aff", "on_accent": "#ffffff", "selection": "#dcd6ff", + "success": "#0f9d6b", "warning": "#b7791f", "error": "#d6336c", "info": "#0b7fc2", + "bubble_in": "#ffffff", "bubble_out": "#dcd6ff", "bubble_fg": "#1b2036", + "meta_in": "#8a93b2", "meta_out": "#6b5bd6", "tick_read": "#0b7fc2", + "service_bg": "#dfe4fa", "service_fg": "#4a55a0", "unread": "#6d4aff", "unread_muted": "#b9c0dd", + "panel": "#eaeefc", "accent2": "#0891b2", "accent3": "#db2777", "code_bg": "#eef1fd", "tool_bg": "#e3e8fb"})) tui_register_theme(TuiTheme("nord", True, { "bg": "#2e3440", "surface": "#3b4252", "fg": "#eceff4", "muted": "#7c8698", "border": "#4c566a", "accent": "#88c0d0", "on_accent": "#2e3440", "selection": "#434c5e", "success": "#a3be8c", "warning": "#ebcb8b", "error": "#bf616a", "info": "#81a1c1", "bubble_in": "#3b4252", "bubble_out": "#4c6a8a", "bubble_fg": "#eceff4", "meta_in": "#7c8698", "meta_out": "#88c0d0", "tick_read": "#88c0d0", - "service_bg": "#4c566a", "service_fg": "#eceff4", "unread": "#88c0d0", "unread_muted": "#4c566a"})) + "service_bg": "#4c566a", "service_fg": "#eceff4", "unread": "#88c0d0", "unread_muted": "#4c566a", + "panel": "#292e39", "accent2": "#8fbcbb", "accent3": "#b48ead", "code_bg": "#272c36", "tool_bg": "#333a48"})) tui_register_theme(TuiTheme("gruvbox", True, { "bg": "#282828", "surface": "#3c3836", "fg": "#ebdbb2", "muted": "#928374", "border": "#504945", "accent": "#83a598", "on_accent": "#282828", "selection": "#458588", "success": "#b8bb26", "warning": "#fabd2f", "error": "#fb4934", "info": "#8ec07c", "bubble_in": "#3c3836", "bubble_out": "#458588", "bubble_fg": "#ebdbb2", "meta_in": "#928374", "meta_out": "#83a598", "tick_read": "#83a598", - "service_bg": "#504945", "service_fg": "#ebdbb2", "unread": "#458588", "unread_muted": "#665c54"})) + "service_bg": "#504945", "service_fg": "#ebdbb2", "unread": "#458588", "unread_muted": "#665c54", + "panel": "#1d2021", "accent2": "#8ec07c", "accent3": "#d3869b", "code_bg": "#1d2021", "tool_bg": "#32302f"})) tui_register_theme(TuiTheme("ansi", True, { "bg": "black", "surface": "black", "fg": "white", "muted": "bright_black", "border": "white", "accent": "cyan", "on_accent": "black", "selection": "blue", "success": "green", "warning": "yellow", "error": "red", "info": "cyan", "bubble_in": "black", "bubble_out": "blue", "bubble_fg": "white", "meta_in": "white", "meta_out": "cyan", "tick_read": "bright_blue", - "service_bg": "black", "service_fg": "white", "unread": "blue", "unread_muted": "white"})) + "service_bg": "black", "service_fg": "white", "unread": "blue", "unread_muted": "white", + "panel": "black", "accent2": "cyan", "accent3": "magenta", "code_bg": "black", "tool_bg": "black"})) _tui_builtin_themes() @@ -10055,6 +10061,7 @@ class TuiPanel: flow="rows", dock=None, layer=None, z=0, offset=None, align="left", valign="top", padding=0, gap=0, border=False, border_style=None, style=None, title_style=None, + rounded=False, focus_border_style=None, visible=True, focusable=False, enabled=True, modal=False, scrollable=False): TuiPanel._seq_counter[0] += 1 @@ -10076,6 +10083,8 @@ class TuiPanel: self.gap = max(0, tui_int(gap)) self.border = bool(border) self.border_style = border_style or TuiStyle(fg="white") + self.rounded = bool(rounded) + self.focus_border_style = focus_border_style self.style = style or TuiStyle() self.title_style = title_style or TuiStyle(bold=True) self.visible = visible @@ -10245,7 +10254,8 @@ class TuiPanel: heights = [] flow_w = 0 flow_h = 0 - for child in self.children: + shown = [child for child in self.children if child.visible] + for child in shown: cw, ch = child.measure() outer_w = child._fixed_len(child.w, 0, cw) + child.chrome_w() outer_h = child._fixed_len(child.h, 0, ch) + child.chrome_h() @@ -10258,7 +10268,7 @@ class TuiPanel: else: flow_h += outer_h widths.append(outer_w) - gaps = self.gap * max(0, len([child for child in self.children if not child.dock and child.x is None and child.y is None]) - 1) + gaps = self.gap * max(0, len([child for child in shown if not child.dock and child.x is None and child.y is None]) - 1) if self.flow == "cols": return (max(widths + [flow_w + gaps]), max(heights + [0])) return (max(widths + [0]), max(heights + [flow_h + gaps])) @@ -10275,9 +10285,10 @@ class TuiPanel: box = [self.ax + edge + pad_left, self.ay + edge + pad_top, max(0, self.aw - edge * 2 - pad_left - pad_right), max(0, self.ah - edge * 2 - pad_top - pad_bottom)] - docked = [child for child in self.children if child.dock] - floating = [child for child in self.children if not child.dock and (child.x is not None or child.y is not None)] - flowing = [child for child in self.children if not child.dock and child.x is None and child.y is None] + shown = [child for child in self.children if child.visible] + docked = [child for child in shown if child.dock] + floating = [child for child in shown if not child.dock and (child.x is not None or child.y is not None)] + flowing = [child for child in shown if not child.dock and child.x is None and child.y is None] for child in docked: self._place_dock(child, box) if self.flow is None: @@ -10492,17 +10503,21 @@ class TuiPanel: return [tui_pad_spans(spans, self.aw, self.style) for spans in rows[:self.ah]] def _add_border(self, rows, inner_w): - top = [TuiSpan("┌─", self.border_style)] + tl, tr, bl, br = "╭╮╰╯" if self.rounded else "┌┐└┘" + line = self.border_style + if self.focus_border_style is not None and self.app is not None and self.app.focused is self: + line = self.focus_border_style + top = [TuiSpan(tl + "─", line)] title = " %s " % self.title.strip() if self.title.strip() else "" room = max(0, inner_w - 2) if len(title) > room: title = title[:max(0, room - 1)] + ("…" if room else "") top.append(TuiSpan(title, self.title_style)) - top.append(TuiSpan("─" * max(0, room - len(title) + 1) + "┐", self.border_style)) - bottom = [TuiSpan("└" + "─" * inner_w + "┘", self.border_style)] + top.append(TuiSpan("─" * max(0, room - len(title) + 1) + tr, line)) + bottom = [TuiSpan(bl + "─" * inner_w + br, line)] framed = [top] for spans in rows: - framed.append([TuiSpan("│", self.border_style)] + list(spans) + [TuiSpan("│", self.border_style)]) + framed.append([TuiSpan("│", line)] + list(spans) + [TuiSpan("│", line)]) framed.append(bottom) return framed @@ -11613,16 +11628,53 @@ class TuiText(TuiPanel): return rows +class TuiRich(TuiPanel): + """Static multi-style rows: set() takes one span list or a list of span + lists. Rows pad to the panel width and clip, so callers never measure. + .text mirrors the plain content for tests and status reads.""" + + def __init__(self, rows=(), **kwargs): + super().__init__(**kwargs) + self.rows = [] + self.text = "" + self.set(rows) + + def set(self, rows): + rows = list(rows) + if rows and isinstance(rows[0], TuiSpan): + rows = [rows] + self.rows = [list(row) for row in rows] + self.text = "\n".join("".join(span.text for span in row) for row in self.rows) + if self.app is not None: + self.app.dirty_paint() + + def measure(self): + width = 0 + for row in self.rows: + width = max(width, sum(len(span.text) for span in row)) + return (width, max(1, len(self.rows))) + + def content_rows(self): + width = self._content_w() + height = self._content_h() + out = [] + for pos in range(height): + row = self.rows[pos] if pos < len(self.rows) else [] + out.append(tui_pad_spans(tui_slice_spans(row, 0, width, self.style), width, self.style)) + return out + + class TuiButton(TuiPanel): """Clickable button: Enter/Space/click press it. on_press is the Press-event handler attribute and receives the event.""" - def __init__(self, label="", on_press=None, **kwargs): + def __init__(self, label="", on_press=None, brackets=True, **kwargs): kwargs.setdefault("focusable", True) kwargs.setdefault("border", True) super().__init__(**kwargs) self.label = label self.on_press = on_press + self.brackets = brackets def measure(self): return (len(self.label) + 4, 1) @@ -11630,7 +11682,7 @@ class TuiButton(TuiPanel): def content_rows(self): width = self._content_w() height = self._content_h() - label = ("[ %s ]" % self.label).center(width)[:width] + label = (("[ %s ]" if self.brackets else " %s ") % self.label).center(width)[:width] style = self.style if not self.enabled: style = style.merged(TuiStyle(dim=True)) @@ -11796,10 +11848,19 @@ class TuiOptions(TuiPanel): COMPLETE_WORD_RE = re.compile(r"(?:^|\s)([/@])([A-Za-z0-9_./-]*)$") +TUI_AVATAR_ROLES = ("accent", "accent2", "accent3", "success", "warning", "info") + + +def tui_avatar_role(name): + """Stable accent role per name so a bot keeps its color everywhere.""" + return TUI_AVATAR_ROLES[sum(ord(char) for char in str(name)) % len(TUI_AVATAR_ROLES)] + + class TuiChatList(TuiOptions): - """Two-line chat rows: name / snippet. Options carry (label, value) - as usual; row_meta maps value -> {"snippet": str, "draft": str}. - Selection moves per chat while each chat paints two terminal rows.""" + """Two-line chat rows: avatar chip + name / snippet. Options carry + (label, value) as usual; row_meta maps value -> {"snippet": str, + "draft": str, "busy": bool}. Selection moves per chat while each chat + paints two terminal rows; the selected chat gets an accent bar.""" def __init__(self, options=(), **kwargs): super().__init__(options, **kwargs) @@ -11828,21 +11889,26 @@ class TuiChatList(TuiOptions): if 0 <= chat < len(self.options): label, value = self.options[chat] meta = self.row_meta.get(value) or {} - if chat == self.selected and self.enabled: - style = self.style.merged(TuiStyle(reverse=True, bold=focused)) - else: - style = self.style + active = chat == self.selected and self.enabled + base = self.style.merged(TuiStyle(bg="role:selection")) if active else self.style + tint = "role:" + tui_avatar_role(label) + bar = TuiSpan("▌" if active else " ", base.merged(TuiStyle(fg="role:accent"))) if sub == 0: - rows.append([TuiSpan((" " + label)[:width].ljust(width), style)]) + chip = TuiSpan(" %s " % (label[:1].upper() or "?"), TuiStyle(fg="role:on_accent", bg=tint, bold=True)) + name = TuiSpan(" " + label[:max(0, width - 6)], base.merged(TuiStyle(bold=active or focused))) + badge = [TuiSpan(" ●", base.merged(TuiStyle(fg="role:success")))] if meta.get("busy") else [] + rows.append(tui_pad_spans([bar, chip, name] + badge, width, base)) else: snippet = str(meta.get("snippet") or "") draft = str(meta.get("draft") or "") + lead = TuiSpan(" ", base) if draft: - head = TuiSpan(" Draft: ", style.merged(TuiStyle(fg="role:error"))) - tail = TuiSpan(draft[:max(0, width - 9)], style) - rows.append(tui_pad_spans([head, tail], width, style)) + head = TuiSpan("Draft: ", base.merged(TuiStyle(fg="role:error"))) + tail = TuiSpan(draft[:max(0, width - 12)], base.merged(TuiStyle(fg="role:muted"))) + rows.append(tui_pad_spans([bar, lead, head, tail], width, base)) else: - rows.append([TuiSpan((" " + snippet)[:width].ljust(width), style)]) + tail = TuiSpan(snippet[:max(0, width - 6)], base.merged(TuiStyle(fg="role:muted"))) + rows.append(tui_pad_spans([bar, lead, tail], width, base)) else: rows.append([TuiSpan(" " * width, self.style)]) return rows @@ -12398,17 +12464,9 @@ class TuiLog(TuiPanel): self._push("".join(span.text for span in spans), spans) def content_rows(self): - width = self._content_w() - height = self._content_h() - rows = [] - for pos in range(height): - index = self.scroll_y + pos - if 0 <= index < len(self.lines): - _text, spans = self.lines[index] - rows.append(tui_pad_spans(tui_slice_spans(spans, self.scroll_x, width, self.style), width, self.style)) - else: - rows.append([TuiSpan(" " * width, self.style)]) - return rows + """Every line, unscrolled: TuiPanel.paint applies scroll_x/scroll_y + itself, so scrolling here too would shift the view twice.""" + return [spans for _text, spans in self.lines] def on_scroll(self, event): event.stop() @@ -13345,62 +13403,270 @@ TUI_CHAT_TICK_SENT = "✓" TUI_CHAT_TICK_READ = "✓✓" -def tui_chat_bubble_rows(text, width, side, styles, stamp="", ticks="", ticks_read=False): - """One aligned bg-fill bubble as span-rows. side out (right) or in - (left); stamp+ticks tuck bottom-right inside the bubble. styles needs - base, bubble, meta, tick entries. Below usable width bubbles degrade - to full-width left rows. Returns a list of span lists.""" +TUI_MD_INLINE_RE = re.compile(r"(\*\*[^*\n]+\*\*|__[^_\n]+__|`[^`\n]+`|(? pos: + segs.append((text[pos:match.start()], "")) + token = match.group(0) + if token.startswith(("**", "__")): + segs.append((token[2:-2], "b")) + elif token.startswith("`"): + segs.append((token[1:-1], "c")) + else: + segs.append((token[1:-1], "i")) + pos = match.end() + if pos < len(text): + segs.append((text[pos:], "")) + return segs + + +def tui_md_blocks(text): + """Line-level markdown: returns (kind, segs, lead) rows. kinds: p, h, + li (lead is the marker), quote, code, rule, blank. Fences, headings, + bullets, numbered lists, quotes and rules; everything else is text.""" + out = [] + fence = False + for raw in str(text or "").replace("\t", " ").split("\n"): + line = raw.rstrip() + if line.strip().startswith("```"): + fence = not fence + continue + if fence: + out.append(("code", [(line, "code")], "")) + continue + if not line.strip(): + if out and out[-1][0] != "blank": + out.append(("blank", [], "")) + continue + head = re.match(r"^#{1,6}\s+(.*)$", line) + item = TUI_MD_LIST_RE.match(line) + quote = re.match(r"^>\s?(.*)$", line) + if head: + out.append(("h", [(seg, "h") for seg, _tag in tui_md_inline(head.group(1))], "")) + elif re.match(r"^\s*(-{3,}|\*{3,}|_{3,})\s*$", line): + out.append(("rule", [], "")) + elif item: + marker = "•" if item.group(2) in "-*+" else item.group(2) + out.append(("li", tui_md_inline(item.group(3)), " " * (len(item.group(1)) // 2) + marker)) + elif quote: + out.append(("quote", tui_md_inline(quote.group(1)), "")) + else: + out.append(("p", tui_md_inline(line), "")) + while out and out[-1][0] == "blank": + out.pop() + return out + + +def tui_wrap_segs(segs, width): + """Greedy word wrap over (text, tag) segments; words longer than the + width are chopped. Returns a list of segment lists, never empty.""" + width = max(1, width) + lines = [] + cur = [] + used = 0 + + def flush(): + nonlocal cur, used + while cur and cur[-1][0].isspace(): + cur.pop() + lines.append(cur) + cur, used = [], 0 + + for text, tag in segs: + for token in re.findall(r"\s+|\S+", text): + if token.isspace(): + if used and used + len(token) <= width: + cur.append((token, tag)) + used += len(token) + continue + while len(token) > width: + if used: + flush() + cur.append((token[:width], tag)) + used = width + flush() + token = token[width:] + if used + len(token) > width: + flush() + cur.append((token, tag)) + used += len(token) + flush() + return lines + + +def tui_chat_styles(side): + """Style table for one bubble kind. side: out, in, note, error.""" + if side == "out": + bg, fg, meta = "role:bubble_out", "role:bubble_fg", "role:meta_out" + elif side == "error": + bg, fg, meta = "role:tool_bg", "role:error", "role:meta_in" + elif side == "note": + bg, fg, meta = "role:tool_bg", "role:muted", "role:meta_in" + else: + bg, fg, meta = "role:bubble_in", "role:bubble_fg", "role:meta_in" + base = TuiStyle(bg="role:bg", fg="role:fg") + return { + "base": base, + "bubble": TuiStyle(bg=bg, fg=fg), + "meta": TuiStyle(bg=bg, fg=meta), + "tick": TuiStyle(bg=bg, fg="role:tick_read" if side == "out" else meta, bold=True), + "name": TuiStyle(bg=bg, fg="role:accent2" if side != "out" else "role:meta_out", bold=True), + "b": TuiStyle(bg=bg, fg=fg, bold=True), + "i": TuiStyle(bg=bg, fg="role:info"), + "c": TuiStyle(bg="role:code_bg", fg="role:accent3"), + "code": TuiStyle(bg="role:code_bg", fg="role:accent2"), + "h": TuiStyle(bg=bg, fg="role:accent3" if side == "out" else "role:accent", bold=True), + "bullet": TuiStyle(bg=bg, fg="role:accent2" if side != "out" else "role:meta_out", bold=True), + "quote": TuiStyle(bg=bg, fg="role:muted"), + "bar": TuiStyle(bg=bg, fg=("role:error" if side == "error" else "role:accent")), + } + + +TUI_CHAT_PAD = 2 + + +def tui_chat_body_rows(text, inner, styles, markdown=True): + """Bubble body as (segs, fill_tag) rows wrapped to inner columns. + fill_tag code pads the row with the code-block background.""" + rows = [] + if not markdown: + for para in str(text or "").split("\n"): + for part in textwrap.wrap(para, inner, expand_tabs=True, replace_whitespace=False, + drop_whitespace=False, break_long_words=True, break_on_hyphens=False) or [""]: + rows.append(([(part.rstrip(), "")], "")) + return rows + for kind, segs, lead in tui_md_blocks(text): + if kind == "blank": + rows.append(([], "")) + elif kind == "rule": + rows.append(([("─" * inner, "quote")], "")) + elif kind == "code": + line = segs[0][0] + for start in range(0, max(1, len(line)), inner): + rows.append(([(" " + line[start:start + inner - 1], "code")], "code")) + elif kind == "li": + indent = len(lead) + 1 + wrapped = tui_wrap_segs(segs, max(4, inner - indent)) + for pos, parts in enumerate(wrapped): + head = [(lead[:-1], ""), (lead[-1:], "bullet"), (" ", "")] if pos == 0 else [(" " * indent, "")] + rows.append((head + parts, "")) + elif kind == "quote": + for parts in tui_wrap_segs([(seg, "quote") for seg, _tag in segs], max(4, inner - 2)): + rows.append(([("▎ ", "bar")] + parts, "")) + else: + for parts in tui_wrap_segs(segs, inner): + rows.append((parts, "")) + return rows + + +def tui_chat_bubble_rows(text, width, side, styles, stamp="", ticks="", ticks_read=False, sender="", markdown=True, boxed=True): + """One aligned bubble as span-rows. side out (right) or in (left); + boxed bubbles carry a sender row on top and the stamp+ticks row at the + bottom, both inside the fill. Markdown renders to styled runs. Below + usable width bubbles degrade to full-width left rows. styles needs the + base, bubble, meta, tick entries; the rest fall back to bubble.""" width = max(10, int(width)) base = styles["base"] + bubble = styles["bubble"] + + def style_of(tag): + return styles.get(tag) or bubble + + meta = " ".join(part for part in (stamp, ticks) if part).strip() if width < 34: rows = [] - for para in str(text or "").split("\n"): - rows.extend(TuiText(para, wrap=True).lines(width) or [""]) - meta = " ".join(part for part in (stamp, ticks) if part).strip() + if sender: + rows.append([TuiSpan(sender[:width].ljust(width), style_of("name"))]) + for segs, _fill in tui_chat_body_rows(text, width, styles, markdown): + rows.append(tui_pad_spans([TuiSpan(seg, style_of(tag)) for seg, tag in segs], width, base)) if meta: - rows.append(meta.rjust(width)) - return [[TuiSpan(line[:width].ljust(width), base)] for line in rows] - meta = " ".join(part for part in (stamp, ticks) if part).strip() - cap = max(12, int(width * TUI_CHAT_BUBBLE_MAX)) - wrapped = [] - for para in str(text or "").split("\n"): - parts = textwrap.wrap(para, cap - 2, expand_tabs=True, replace_whitespace=False, - drop_whitespace=False, break_long_words=True, break_on_hyphens=False) - wrapped.extend(part.rstrip() for part in parts or [""]) - want = max([len(line) for line in wrapped] + [0]) + 2 - if meta and wrapped: - want = max(want, len(wrapped[-1]) + 1 + len(meta) + 2, len(meta) + 2) - bubble_w = min(cap, want) - bubble = styles["bubble"] - time_span = TuiSpan((stamp + " ") if stamp and ticks else stamp, styles["meta"]) if stamp else None - tick_span = TuiSpan(ticks, styles["tick"] if ticks_read else styles["meta"]) if ticks else None - meta_spans = [span for span in (time_span, tick_span) if span is not None] + rows.append([TuiSpan(meta.rjust(width), styles["meta"])]) + return rows + cap = max(14, int(width * TUI_CHAT_BUBBLE_MAX)) + inner_cap = cap - 2 * TUI_CHAT_PAD + body = tui_chat_body_rows(text, inner_cap, styles, markdown) + need = max([sum(len(seg) for seg, _tag in segs) for segs, _fill in body] + [len(sender) + 1, len(meta), 1]) + bubble_w = min(cap, need + 2 * TUI_CHAT_PAD) + inner = bubble_w - 2 * TUI_CHAT_PAD + pad = TuiSpan(" " * TUI_CHAT_PAD, bubble) rows = [] - body = list(wrapped) - if meta and body and len(body[-1]) + 1 + len(meta) <= bubble_w - 2: - tail = body.pop() - for line in body: - rows.append([TuiSpan(" " + line.ljust(bubble_w - 2) + " ", bubble)]) - gap = bubble_w - 2 - len(tail) - len(meta) - rows.append([TuiSpan(" ", bubble), TuiSpan(tail, bubble), TuiSpan(" " * gap, bubble)] + meta_spans + [TuiSpan(" ", bubble)]) - else: - for line in body: - rows.append([TuiSpan(" " + line.ljust(bubble_w - 2) + " ", bubble)]) + if boxed: + rows.append([TuiSpan(" " * bubble_w, bubble)] if not sender else + [pad, TuiSpan(sender[:inner].ljust(inner), style_of("name")), pad]) + for segs, fill in body: + spans = [TuiSpan(seg, style_of(tag)) for seg, tag in segs] + used = sum(len(seg) for seg, _tag in segs) + fill_style = style_of(fill) if fill else bubble + rows.append([pad] + spans + [TuiSpan(" " * (inner - used), fill_style), pad]) + if boxed: if meta: - gap = bubble_w - 2 - len(meta) - rows.append([TuiSpan(" ", bubble), TuiSpan(" " * gap, bubble)] + meta_spans + [TuiSpan(" ", bubble)]) - if not rows: - rows.append([TuiSpan(" " * bubble_w, bubble)]) + time_span = TuiSpan(stamp, styles["meta"]) if stamp else None + gap = " " if stamp and ticks else "" + tick_span = TuiSpan(ticks, styles["tick"] if ticks_read else styles["meta"]) if ticks else None + tail = [span for span in (time_span, TuiSpan(gap, styles["meta"]) if gap else None, tick_span) if span is not None] + rows.append([pad, TuiSpan(" " * (inner - len(meta)), bubble)] + tail + [pad]) + else: + rows.append([TuiSpan(" " * bubble_w, bubble)]) aligned = [] for spans in rows: - cells = bubble_w if side == "out": - aligned.append([TuiSpan(" " * max(0, width - cells), base)] + spans) + aligned.append([TuiSpan(" " * max(0, width - bubble_w), base)] + spans) else: - aligned.append(spans + [TuiSpan(" " * max(0, width - cells), base)]) + aligned.append(spans + [TuiSpan(" " * max(0, width - bubble_w), base)]) return aligned +def tui_chat_tool_summary(args): + """Compact `k=v k=v` view of a JSON tool-call argument string.""" + try: + data = json.loads(args) + except (TypeError, ValueError): + return str(args or "").strip() + if not isinstance(data, dict): + return str(data) + return " ".join("%s=%s" % (key, value if isinstance(value, (int, float)) else json.dumps(value, ensure_ascii=False).strip('"')) + for key, value in data.items()) + + +def tui_chat_tool_row(name, args, width, base): + """One-line chip for a tool call: gear, bold name, muted arguments.""" + width = max(10, int(width)) + chip = TuiStyle(bg="role:accent2", fg="role:on_accent", bold=True) + label = TuiStyle(bg="role:tool_bg", fg="role:fg", bold=True) + detail = TuiStyle(bg="role:tool_bg", fg="role:muted") + room = max(0, width - len(name) - 8) + text = tui_chat_tool_summary(args).replace("\n", " ") + if len(text) > room: + text = text[:max(0, room - 1)] + "…" + spans = [TuiSpan(" ", base), TuiSpan(" ⚙ ", chip), TuiSpan(" %s " % name, label)] + if text: + spans.append(TuiSpan("%s " % text, detail)) + return tui_pad_spans(spans, width, base) + + +def tui_chat_note_rows(text, width, side, base): + """Command output and errors: accent bar plus plain wrapped text on the + tool background, no markdown so aligned columns survive.""" + styles = tui_chat_styles(side) + cap = max(12, int(width * 0.9)) + lines = tui_chat_body_rows(text, cap - 4, styles, markdown=False) + longest = max([len(segs[0][0]) if segs else 0 for segs, _fill in lines] + [1]) + block_w = min(cap, longest + 4) + rows = [] + for segs, _fill in lines: + raw = segs[0][0] if segs else "" + rows.append(tui_pad_spans([TuiSpan("▎", styles["bar"]), TuiSpan(" " + raw.ljust(block_w - 3) + " ", styles["bubble"])], + width, base)) + return rows + + def tui_chat_pill_row(text, width, base_style, pill_style): label = (" %s " % (text or "").strip())[:max(0, width)] left = max(0, (width - len(label)) // 2) @@ -13408,87 +13674,154 @@ def tui_chat_pill_row(text, width, base_style, pill_style): return [TuiSpan(" " * left, base_style), TuiSpan(label, pill_style), TuiSpan(" " * right, base_style)] +def tui_chat_welcome_rows(width, name): + """Empty-state card shown before the first message.""" + width = max(10, int(width)) + base = TuiStyle(bg="role:bg", fg="role:fg") + logo = TuiStyle(bg="role:bg", fg="role:accent", bold=True) + sub = TuiStyle(bg="role:bg", fg="role:muted") + key = TuiStyle(bg="role:service_bg", fg="role:service_fg", bold=True) + lines = [ + [TuiSpan("✦ tai", logo)], + [TuiSpan("chatting with %s" % name, sub)], + [], + [TuiSpan(" Enter ", key), TuiSpan(" send ", sub), TuiSpan(" /help ", key), TuiSpan(" commands ", sub), + TuiSpan(" @ ", key), TuiSpan(" mention a bot", sub)], + [TuiSpan(" F2 ", key), TuiSpan(" bots ", sub), TuiSpan(" F4 ", key), TuiSpan(" theme", sub)], + ] + rows = [[TuiSpan(" " * width, base)]] + for spans in lines: + used = sum(len(span.text) for span in spans) + left = max(0, (width - used) // 2) + rows.append(tui_pad_spans([TuiSpan(" " * left, base)] + spans, width, base)) + return rows + + +class TuiChatLog(TuiLog): + """Transcript view with a slim scrollbar in the last column, drawn only + when there is something to scroll.""" + + def paint(self): + rows = super().paint() + width = self._content_w() + height = self._content_h() + total = len(self.lines) + if total <= height or height < 3 or width < 2: + return rows + edge = 1 if self.border else 0 + thumb = max(1, height * height // total) + span = max(1, total - height) + top = int((height - thumb) * min(1.0, self.scroll_y / float(span))) + track = TuiStyle(bg=self.style.bg, fg="role:border") + grip = TuiStyle(bg=self.style.bg, fg="role:accent", bold=True) + out = [] + for pos, spans in enumerate(rows): + local = pos - edge - self.padding[0] + if 0 <= local < height: + on = top <= local < top + thumb + spans = tui_slice_spans(spans, 0, self.aw - edge - self.padding[1] - 1, self.style) + [ + TuiSpan("┃" if on else "│", grip if on else track)] + spans = tui_pad_spans(spans, self.aw - edge, self.style) + out.append(spans) + return out + + def tui_build_chat(app, agent): - """Telegram-style chat scene: bots sidebar, transcript, input, status. - Turns run inline via a deferred timer (see run_pending): the worker - thread the research sketch assumed would share one sqlite connection - across threads, which sqlite forbids, so the echo paints first and - the turn blocks the loop like the telegram bot already does.""" + """Chat scene: bots sidebar, header, markdown transcript, rounded + composer, status bar. Turns run inline via a deferred timer (see + run_pending): the worker thread the research sketch assumed would share + one sqlite connection across threads, which sqlite forbids, so the echo + paints first and the turn blocks the loop like the telegram bot does.""" refs = {"drafts": {}, "pending": None, "note": "", "sidebar_auto": False, - "sidebar_hide": False, "last_side": None, "bots": [], "pending_tick": None, + "sidebar_hide": False, "last_key": None, "bots": [], "pending_tick": None, "blocks": [], "render_w": 0} panel_style = TuiStyle(bg="role:bg", fg="role:fg") dim_style = TuiStyle(bg="role:bg", fg="role:muted") err_style = TuiStyle(bg="role:bg", fg="role:error") - bar_style = TuiStyle(bg="role:surface", fg="role:fg") - name_style = TuiStyle(bg="role:bg", fg="role:fg", bold=True) - in_style = TuiStyle(bg="role:bubble_in", fg="role:bubble_fg") - out_style = TuiStyle(bg="role:bubble_out", fg="role:bubble_fg") - dim_in_style = TuiStyle(bg="role:bubble_in", fg="role:muted") - err_in_style = TuiStyle(bg="role:bubble_in", fg="role:error") - meta_in_style = TuiStyle(bg="role:bubble_in", fg="role:meta_in") - meta_out_style = TuiStyle(bg="role:bubble_out", fg="role:meta_out") - tick_style = TuiStyle(bg="role:bubble_out", fg="role:tick_read") - pill_style = TuiStyle(bg="role:service_bg", fg="role:service_fg") - in_styles = {"base": panel_style, "bubble": in_style, "meta": meta_in_style, "tick": meta_in_style} - out_styles = {"base": panel_style, "bubble": out_style, "meta": meta_out_style, "tick": tick_style} - dim_styles = {"base": panel_style, "bubble": dim_in_style, "meta": meta_in_style, "tick": meta_in_style} - err_styles = {"base": panel_style, "bubble": err_in_style, "meta": meta_in_style, "tick": meta_in_style} + bar_style = TuiStyle(bg="role:panel", fg="role:fg") + name_style = TuiStyle(bg="role:panel", fg="role:fg", bold=True) + side_style = TuiStyle(bg="role:panel", fg="role:fg") + pill_style = TuiStyle(bg="role:service_bg", fg="role:service_fg", bold=True) + bubble_styles = {side: tui_chat_styles(side) for side in ("in", "out", "note", "error")} root = TuiPanel(flow="rows", style=TuiStyle(bg="role:bg"), id="chat") body = TuiCols(h="1fr", style=TuiStyle(bg="role:bg"), id="body") - sidebar_wrap = TuiPanel(w=tui_chat_sidebar_w(app.cols), h="100%", border=True, title="Bots", - style=panel_style, id="sidebar") - sidebar = TuiChatList([], h="1fr", style=panel_style, id="bots") - sidebar_wrap.add(sidebar) + sidebar_wrap = TuiRows(w=tui_chat_sidebar_w(app.cols), h="100%", style=side_style, id="sidebar") + brand = TuiRich(h=3, style=side_style, id="brand") + sidebar = TuiChatList([], h="1fr", style=side_style, id="bots") + sidebar_wrap.add(brand, sidebar) right = TuiRows(w="1fr", h="100%", style=TuiStyle(bg="role:bg"), id="right") - header = TuiRows(h=3, style=TuiStyle(bg="role:bg"), id="header") + header = TuiCols(h=3, style=bar_style, padding=(0, 1), id="header") + avatar = TuiRich(w=7, h=3, style=bar_style, id="avatar") + info = TuiRows(w="1fr", h=3, valign="middle", style=bar_style, padding=(0, 1), id="info") head_name = TuiText("", h=1, wrap=False, style=name_style, id="head_name") - head_status = TuiText("", h=1, wrap=False, style=dim_style, id="head_status") - head_hair = TuiText("", h=1, wrap=False, style=dim_style, id="head_hair") - header.add(head_name, head_status, head_hair) - transcript = TuiLog(border=True, focusable=True, h="1fr", style=panel_style, id="transcript") - composer = TuiCols(h=3, style=TuiStyle(bg="role:bg"), id="composer") - box = TuiTextBox(w="1fr", h="100%", border=True, style=panel_style, id="input", + status_row = TuiCols(h=1, style=bar_style, id="status_row") + head_dot = TuiText("● ", w=2, h=1, wrap=False, style=TuiStyle(bg="role:panel", fg="role:success"), id="head_dot") + head_status = TuiText("", w="1fr", h=1, wrap=False, style=TuiStyle(bg="role:panel", fg="role:muted"), id="head_status") + status_row.add(head_dot, head_status) + info.add(head_name, status_row) + head_meta = TuiRich(w=24, h=3, style=bar_style, id="head_meta") + header.add(avatar, info, head_meta) + transcript = TuiChatLog(focusable=True, h="1fr", style=panel_style, id="transcript") + composer = TuiCols(h=3, gap=1, padding=(0, 1), style=TuiStyle(bg="role:bg"), id="composer") + box = TuiTextBox(w="1fr", h=1, border=True, rounded=True, padding=(0, 1), id="input", + style=TuiStyle(bg="role:surface", fg="role:fg"), + border_style=TuiStyle(bg="role:bg", fg="role:border"), + focus_border_style=TuiStyle(bg="role:bg", fg="role:accent"), complete=tui_complete_command, mentions=lambda prefix: tui_complete_mention(prefix, bots=tuple(refs["bots"])), send_on_enter=True, on_send=lambda value: do_send(value), max_chars=TUI_CHAT_INPUT_CAP, placeholder="Write a message...") - send_btn = TuiButton("Send", w=10, h="100%", style=TuiStyle(bg="role:accent", fg="role:on_accent", bold=True), + send_btn = TuiButton("Send ➤", brackets=False, w=10, h=1, border=True, rounded=True, + border_style=TuiStyle(bg="role:bg", fg="role:accent"), + focus_border_style=TuiStyle(bg="role:bg", fg="role:accent2"), + style=TuiStyle(bg="role:accent", fg="role:on_accent", bold=True), on_press=lambda event: do_send(box.value), id="send") composer.add(box, send_btn) right.add(header, transcript, composer) body.add(sidebar_wrap, right) - status = TuiText("", h=1, wrap=False, style=bar_style, id="status") + status = TuiRich(h=1, style=bar_style, id="status") root.add(body, status) refs.update({"root": root, "sidebar_wrap": sidebar_wrap, "sidebar": sidebar, "transcript": transcript, "box": box, "status": status, "composer": composer, - "head_name": head_name, "head_status": head_status, "head_hair": head_hair}) + "head_name": head_name, "head_status": head_status, "head_dot": head_dot, + "avatar": avatar, "head_meta": head_meta, "send_btn": send_btn}) def render_block(block): - width = max(10, transcript._content_w()) - if block[0] == "pill": - transcript.append_spans(tui_chat_pill_row(block[1], width, panel_style, pill_style)) - return - _kind, side, text, styles, stamp, ticks, ticks_read = block - if transcript.lines and refs["last_side"] not in (None, side): + width = max(10, transcript._content_w() - 3) + base = panel_style + kind = block[0] + key = (kind, block[1]) if kind in ("msg", "note") else (kind,) + if transcript.lines and refs["last_key"] is not None and (key != refs["last_key"] or kind == "msg"): transcript.append("") - for spans in tui_chat_bubble_rows(text, width, side, styles, stamp, ticks, ticks_read): - transcript.append_spans(spans) - refs["last_side"] = side + if kind == "pill": + rows = [tui_chat_pill_row(block[1], width, base, pill_style)] + elif kind == "welcome": + rows = tui_chat_welcome_rows(width, block[1]) + elif kind == "tool": + rows = [tui_chat_tool_row(block[1], block[2], width, base)] + elif kind == "note": + rows = tui_chat_note_rows(block[2], width, block[1], base) + else: + _kind, side, text, stamp, ticks, ticks_read, sender = block + rows = tui_chat_bubble_rows(text, width, side, bubble_styles[side], stamp, ticks, ticks_read, sender) + margin = TuiSpan(" ", base) + for spans in rows: + transcript.append_spans([margin] + list(spans)) + refs["last_key"] = key def replay(): if not transcript.aw: return pinned = transcript.scroll_y >= max(0, len(transcript.lines) - transcript._content_h()) transcript.lines = [] - refs["last_side"] = None + refs["last_key"] = None for block in refs["blocks"]: render_block(block) while len(transcript.lines) > TUI_CHAT_LINE_CAP and len(refs["blocks"]) > 1: refs["blocks"].pop(0) transcript.lines = [] - refs["last_side"] = None + refs["last_key"] = None for block in refs["blocks"]: render_block(block) if pinned: @@ -13502,11 +13835,9 @@ def tui_build_chat(app, agent): if transcript.aw and transcript._content_w() != refs["render_w"]: replay() - def bubble(side, text, styles, stamp="", ticks="", ticks_read=False): - """Journal one aligned bubble and render it when laid out. Same-side - neighbors join with no gap; a side switch gets one blank line. - Returns the journal block (stable across replays).""" - block = ["bubble", side, text, styles, stamp, ticks, ticks_read] + def journal(block): + """Journal one block and render it when laid out. Returns the block + (stable across replays) so callers can mutate it and replay.""" refs["blocks"].append(block) if transcript.aw: render_block(block) @@ -13515,54 +13846,98 @@ def tui_build_chat(app, agent): replay() return block + def bubble(side, text, stamp="", ticks="", ticks_read=False, sender=""): + return journal(["msg", side, text, stamp, ticks, ticks_read, sender]) + + def note(text, side="note"): + return journal(["note", side, text]) + def pill(text): - block = ["pill", text] - refs["blocks"].append(block) - if transcript.aw: - render_block(block) - return block + return journal(["pill", text]) def show(sender, text, style): - side = "out" if sender == "you" else "in" - styles = dim_styles if style in (dim_style, err_style) else (out_styles if side == "out" else in_styles) + if sender == "you": + return bubble("out", text) if style is err_style: - styles = err_styles - return bubble(side, text, styles) + return note(text, "error") + if style is dim_style: + return note(text) + return bubble("in", text, sender=agent.bot) def render_history(): refs["blocks"] = [] refs["pending_tick"] = None transcript.lines = [] - refs["last_side"] = None + refs["last_key"] = None past = [msg for msg in agent.messages if msg.get("role") != "system"][-TUI_CHAT_HISTORY:] if not past: now = datetime.now() pill("%s %d" % (now.strftime("%B"), now.day)) + journal(["welcome", agent.bot]) for msg in past: if msg.get("role") == "user": - bubble("out", tui_strip_ansi(msg.get("content") or ""), out_styles) + bubble("out", tui_strip_ansi(msg.get("content") or "")) elif msg.get("role") == "assistant": if msg.get("content"): - bubble("in", tui_strip_ansi(msg["content"]), in_styles) + bubble("in", tui_strip_ansi(msg["content"]), sender=agent.bot) for call in msg.get("tool_calls") or []: fn = (call.get("function") or {}) - bubble("in", "- %s %s" % (fn.get("name"), (fn.get("arguments") or "")[:100]), dim_styles) + journal(["tool", fn.get("name") or "tool", fn.get("arguments") or ""]) + + def chip(text, style): + return TuiSpan(" %s " % text, style) def paint_status(): width = max(20, app.cols) - left = refs["note"] or "F2 sidebar - F4 theme - Tab switch - i input - /help" + key_style = TuiStyle(bg="role:service_bg", fg="role:service_fg", bold=True) + label_style = TuiStyle(bg="role:panel", fg="role:muted") + gap = TuiSpan(" ", bar_style) + left = [] + if refs["note"]: + warn = refs["note"].startswith("thinking") + left.append([TuiSpan(" %s " % ("◌" if warn else "●"), TuiStyle(bg="role:panel", fg="role:warning" if warn else "role:success", bold=True)), + TuiSpan(refs["note"], TuiStyle(bg="role:panel", fg="role:fg", bold=True))]) + for keycap, label in (("F2", "Bots"), ("F4", "Theme"), ("Tab", "Focus"), ("i", "Input"), ("/help", "Commands")): + left.append([chip(keycap, key_style), TuiSpan(" %s " % label, label_style)]) pins = " ".join(part for part in (agent.chat.provider_pin, agent.chat.model_pin) if part) - right = "%s/%s - %s" % (agent.profile, agent.bot, pins or "auto") + right = [[TuiSpan(" %s/%s " % (agent.profile, agent.bot), TuiStyle(bg="role:accent", fg="role:on_accent", bold=True))], + [TuiSpan(" %s " % (pins or "auto"), TuiStyle(bg="role:service_bg", fg="role:service_fg"))]] busy = running_agents() if busy: - right += " +%d" % busy - if len(left) + 1 + len(right) > width: - status.text = left[:width] - else: - status.text = left + right.rjust(width - len(left)) - head_name.text = "[%s] %s" % (agent.bot[:1].upper(), agent.bot) - head_status.text = "typing..." if refs["pending"] is not None else "online" - head_hair.text = "─" * max(1, head_hair._content_w() or 20) + right.append([TuiSpan(" +%d " % busy, TuiStyle(bg="role:warning", fg="role:bg", bold=True))]) + + def size(groups): + return sum(sum(len(span.text) for span in group) + 1 for group in groups) + + while len(left) > 1 and size(left) + size(right) > width: + left.pop() + while len(right) > 1 and size(left) + size(right) > width: + right.pop() + spans = [] + for group in left: + spans.extend(group) + spans.append(gap) + fill = max(0, width - size(left) - size(right)) + spans.append(TuiSpan(" " * fill, bar_style)) + for group in right: + spans.extend(group) + spans.append(gap) + status.set(spans) + tint = "role:" + tui_avatar_role(agent.bot) + av = TuiStyle(bg=tint, fg="role:on_accent", bold=True) + avatar.set([[TuiSpan(" " * 7, av)], [TuiSpan((" %s " % (agent.bot[:1].upper() or "?")).center(7), av)], [TuiSpan(" " * 7, av)]]) + head_name.text = agent.bot + typing = refs["pending"] is not None + head_status.text = "typing..." if typing else "online" + head_dot.style = TuiStyle(bg="role:panel", fg="role:warning" if typing else "role:success") + brand.set([[], [TuiSpan(" ✦ ", TuiStyle(bg="role:panel", fg="role:accent", bold=True)), + TuiSpan("tai", TuiStyle(bg="role:panel", fg="role:fg", bold=True)), + TuiSpan(" %s" % VERSION, TuiStyle(bg="role:panel", fg="role:muted"))], + [TuiSpan(" Bots", TuiStyle(bg="role:panel", fg="role:accent2", bold=True))]]) + meta_style = TuiStyle(bg="role:panel", fg="role:muted") + label = "%s" % (pins or "auto") + head_meta.set([[], [TuiSpan(("◆ " + label + " ")[-22:].rjust(24), meta_style)], []]) + head_meta.visible = app.cols >= 70 app.dirty_paint() def clear_box(): @@ -13590,7 +13965,8 @@ def tui_build_chat(app, agent): sidebar.options = [TuiOptions.norm_option(name) for name in names] sidebar.selected = names.index(agent.bot) sidebar.row_meta = {name: {"snippet": bot_snippet(name), - "draft": refs["drafts"].get(name, "").split("\n")[0][:40]} + "draft": refs["drafts"].get(name, "").split("\n")[0][:40], + "busy": refs["pending"] is not None and name == agent.bot} for name in names} app.dirty_paint() @@ -13614,11 +13990,11 @@ def tui_build_chat(app, agent): with contextlib.redirect_stdout(buf): stay = handle_command(agent, text) except Exception as exc: - bubble("in", "error: %s" % short_error(exc), err_styles) + note("error: %s" % short_error(exc), "error") return out = tui_strip_ansi(buf.getvalue()).strip() if out: - bubble("in", "\n".join(out.split("\n")[:100]), dim_styles) + note("\n".join(out.split("\n")[:100])) if not stay: app.quit() return @@ -13630,8 +14006,8 @@ def tui_build_chat(app, agent): refs["pending_tick"] = None if block is None or not any(entry is block for entry in refs["blocks"]): return - block[5] = TUI_CHAT_TICK_READ - block[6] = True + block[4] = TUI_CHAT_TICK_READ + block[5] = True replay() def do_send(value): @@ -13644,7 +14020,7 @@ def tui_build_chat(app, agent): run_command(text) return stamp = datetime.now().strftime("%H:%M") - refs["pending_tick"] = bubble("out", text, out_styles, stamp, TUI_CHAT_TICK_SEND) + refs["pending_tick"] = bubble("out", text, stamp, TUI_CHAT_TICK_SEND) refs["pending"] = text refs["note"] = "thinking..." refresh_bots() @@ -13671,7 +14047,7 @@ def tui_build_chat(app, agent): sys.stdin = old_stdin settle_pending_tick() stamp = datetime.now().strftime("%H:%M") - bubble("in", tui_strip_ansi(answer or "[no reply]"), in_styles, stamp) + bubble("in", tui_strip_ansi(answer or "[no reply]"), stamp, sender=agent.bot) refs["note"] = "" refresh_bots() paint_status() @@ -13687,6 +14063,9 @@ def tui_build_chat(app, agent): def on_resize(event): sidebar_wrap.w = tui_chat_sidebar_w(app.cols) + compact = app.cols < 50 + send_btn.label = "➤" if compact else "Send ➤" + send_btn.w = 5 if compact else 10 if app.cols < TUI_CHAT_NARROW_COLS and sidebar_wrap.visible and not refs["sidebar_hide"]: sidebar_wrap.visible = False refs["sidebar_auto"] = True @@ -13716,9 +14095,11 @@ def tui_build_chat(app, agent): box.focus() def tick_input(): - want = max(3, min(7, len(box.lines) + 2)) - if composer.h != want: - composer.h = want + rows = max(1, min(5, len(box.lines))) + if box.h != rows: + box.h = rows + send_btn.h = rows + composer.h = rows + 2 app.dirty_layout() sidebar.on_select = lambda event: select_bot(event.value) @@ -13726,11 +14107,12 @@ def tui_build_chat(app, agent): root.on_resize = on_resize app.every(0.2, tick_input) app.every(1.0, paint_status) + on_resize(None) refresh_bots() render_history() paint_status() refs.update({"do_send": do_send, "run_pending": run_pending, "toggle_sidebar": toggle_sidebar, - "paint_status": paint_status, "show": show, "replay": replay, + "paint_status": paint_status, "show": show, "replay": replay, "add_note": note, "render_history": render_history}) return root, refs diff --git a/test_tai.py b/test_tai.py index d13156e..33e0c68 100644 --- a/test_tai.py +++ b/test_tai.py @@ -5758,10 +5758,10 @@ class TuiThemeTests(unittest.TestCase): def test_role_resolves_at_draw(self): tai.tui_set_theme("dark") - self.assertEqual(tai.TuiStyle(fg="role:accent").sgr(), "\x1b[0;38;2;47;129;247m") - self.assertEqual(tai.TuiStyle(bg="role:bg").sgr(), "\x1b[0;48;2;16;20;24m") + self.assertEqual(tai.TuiStyle(fg="role:accent").sgr(), "\x1b[0;38;2;139;92;246m") + self.assertEqual(tai.TuiStyle(bg="role:bg").sgr(), "\x1b[0;48;2;10;14;26m") tai.tui_set_theme("light") - self.assertEqual(tai.TuiStyle(fg="role:accent").sgr(), "\x1b[0;38;2;9;105;218m") + self.assertEqual(tai.TuiStyle(fg="role:accent").sgr(), "\x1b[0;38;2;109;74;255m") def test_unknown_role_is_empty(self): self.assertEqual(tai.TuiStyle(fg="role:nope").sgr(), "\x1b[0m") @@ -5897,9 +5897,36 @@ class TuiChatTests(unittest.TestCase): self.assertIn("coder", text) self.assertIn("hello", text) self.assertIn("Write a message...", text) - self.assertIn("[M] main", text) + self.assertIn("main", text) + self.assertIn("✦ tai", text) self.assertIn("online", text) + def test_long_transcript_shows_tail_not_blank(self): + app, _agent, _root, refs = self.make_chat(rows=24) + refs["box"].focus() + for n in range(60): + refs["show"]("bot", "line number %d" % n, None) + transcript = refs["transcript"] + self.assertGreater(len(transcript.lines), transcript._content_h()) + text = app.render_text() + self.assertIn("line number 59", text) + self.assertNotIn("line number 0 ", text) + + def test_many_messages_stay_pinned_and_scroll(self): + app, _agent, _root, refs = self.make_chat(rows=24) + refs["box"].focus() + transcript = refs["transcript"] + for n in range(tai.TUI_CHAT_LINE_CAP): + self.type(app, "m%d" % n) + self.key(app, "enter") + refs["run_pending"]() + self.assertLessEqual(len(transcript.lines), tai.TUI_CHAT_LINE_CAP + 10) + self.assertIn("echo: m%d" % (tai.TUI_CHAT_LINE_CAP - 1), app.render_text()) + transcript.scroll_by(0, -10) + self.assertNotIn("echo: m%d" % (tai.TUI_CHAT_LINE_CAP - 1), app.render_text()) + transcript.scroll_by(0, 10) + self.assertIn("echo: m%d" % (tai.TUI_CHAT_LINE_CAP - 1), app.render_text()) + def test_narrow_hides_and_restores_sidebar(self): app, _agent, root, refs = self.make_chat(cols=100) self.assertTrue(refs["sidebar_wrap"].visible) @@ -5910,6 +5937,21 @@ class TuiChatTests(unittest.TestCase): app.dispatch(tai.TuiResize(panel=root, rows=24, cols=100)) self.assertTrue(refs["sidebar_wrap"].visible) + def test_hidden_sidebar_frees_its_columns(self): + app, _agent, _root, refs = self.make_chat(cols=100) + right = refs["transcript"].parent + self.assertEqual(right.ax, 30) + refs["box"].focus() + self.key(app, "f2") + app.layout() + self.assertEqual(right.ax, 0) + self.assertEqual(right.aw, 100) + + def test_narrow_start_hides_sidebar_and_compacts_send(self): + app, _agent, _root, refs = self.make_chat(cols=44) + self.assertFalse(refs["sidebar_wrap"].visible) + self.assertEqual(refs["send_btn"].w, 5) + def test_f2_manual_toggle_overrides_auto(self): app, _agent, root, refs = self.make_chat(cols=100) refs["box"].focus() @@ -6005,7 +6047,7 @@ class TuiChatTests(unittest.TestCase): refs["sidebar"].choose(1) self.assertEqual(agent.bot, "coder") self.assertEqual(refs["box"].value, "") - self.assertEqual(refs["head_name"].text, "[C] coder") + self.assertEqual(refs["head_name"].text, "coder") refs["sidebar"].choose(0) self.assertEqual(agent.bot, "main") self.assertEqual(refs["box"].value, "draft-1") @@ -6018,37 +6060,82 @@ class TuiChatTests(unittest.TestCase): plain = tai.TuiStyle() return {"base": plain, "bubble": plain, "meta": plain, "tick": plain} + def row_text(self, row): + return "".join(span.text for span in row) + def test_bubble_rows_align(self): rows = tai.tui_chat_bubble_rows("hi", 60, "out", self.bubble_styles(), "12:00", tai.TUI_CHAT_TICK_READ, True) - text = "".join(span.text for span in rows[0]) - self.assertEqual(len(text), 60) - self.assertTrue(text.startswith(" ")) - self.assertTrue(text.rstrip().endswith("12:00 " + tai.TUI_CHAT_TICK_READ)) - rows = tai.tui_chat_bubble_rows("hi", 60, "in", self.bubble_styles()) - text = "".join(span.text for span in rows[0]) - self.assertTrue(text.startswith(" hi ")) - self.assertTrue(text.endswith(" ")) + texts = [self.row_text(row) for row in rows] + self.assertTrue(all(len(text) == 60 for text in texts)) + self.assertEqual(len(texts), 3) + self.assertTrue(texts[0].startswith(" ")) + self.assertIn("hi", texts[1]) + self.assertTrue(texts[2].rstrip().endswith("12:00 " + tai.TUI_CHAT_TICK_READ)) + rows = tai.tui_chat_bubble_rows("hi", 60, "in", self.bubble_styles(), sender="main") + texts = [self.row_text(row) for row in rows] + self.assertTrue(texts[0].startswith(" main")) + self.assertTrue(texts[1].startswith(" hi")) + self.assertTrue(texts[1].endswith(" ")) def test_bubble_rows_cap_width(self): rows = tai.tui_chat_bubble_rows("x" * 100, 60, "in", self.bubble_styles()) - self.assertTrue(len(rows) > 2) - first = "".join(span.text for span in rows[0]) - self.assertEqual(len(first), 60) - self.assertEqual(first.strip(), "x" * 40) + self.assertTrue(len(rows) > 3) + body = [self.row_text(row) for row in rows][1:-1] + self.assertEqual(len(body[0]), 60) + self.assertEqual(body[0].strip(), "x" * 38) + self.assertEqual("".join(line.strip() for line in body), "x" * 100) - def test_bubble_rows_tucked_meta_keeps_body(self): + def test_bubble_rows_meta_row_keeps_body(self): rows = tai.tui_chat_bubble_rows("first line here with padding words second line tail end", 60, "out", - self.bubble_styles(), "12:00", "x") - texts = ["".join(span.text for span in row) for row in rows] - self.assertTrue(len(texts) >= 2) + self.bubble_styles(), "12:00", "x") + texts = [self.row_text(row) for row in rows] self.assertTrue(any("first line here" in line for line in texts)) - self.assertTrue(any("tail end" in line and "12:00" in line for line in texts)) + self.assertTrue(any("tail end" in line for line in texts)) + self.assertIn("12:00", texts[-1]) def test_bubble_rows_narrow_degrade(self): rows = tai.tui_chat_bubble_rows("hi there", 20, "out", self.bubble_styles(), "12:00", "x") - text = "".join(span.text for span in rows[0]) + text = self.row_text(rows[0]) self.assertTrue(text.startswith("hi there")) - self.assertIn("12:00", "".join(span.text for span in rows[-1])) + self.assertIn("12:00", self.row_text(rows[-1])) + + def test_markdown_blocks(self): + blocks = tai.tui_md_blocks("# Title\n\nplain **bold** and `code`\n- one\n2. two\n> quote\n```py\nx = 1\n```\n---") + kinds = [kind for kind, _segs, _lead in blocks] + self.assertEqual(kinds, ["h", "blank", "p", "li", "li", "quote", "code", "rule"]) + self.assertEqual(blocks[2][1], [("plain ", ""), ("bold", "b"), (" and ", ""), ("code", "c")]) + self.assertEqual(blocks[3][2], "•") + self.assertEqual(blocks[4][2], "2.") + + def test_wrap_segs_chops_long_words_and_keeps_tags(self): + lines = tai.tui_wrap_segs([("aaaa bbbb ", ""), ("cccccccccc", "b")], 6) + self.assertEqual(["".join(text for text, _tag in line) for line in lines], ["aaaa", "bbbb", "cccccc", "cccc"]) + self.assertEqual(lines[2][0][1], "b") + + def test_markdown_markers_not_shown_in_bubble(self): + rows = tai.tui_chat_bubble_rows("use **bold** and `code`", 60, "in", self.bubble_styles()) + text = " ".join(self.row_text(row) for row in rows) + self.assertIn("use bold and code", text) + self.assertNotIn("**", text) + self.assertNotIn("`", text) + + def test_tool_chip_summarizes_json_and_truncates(self): + row = tai.tui_chat_tool_row("fork", '{"task": "do it", "n": 2}', 60, tai.TuiStyle()) + text = self.row_text(row) + self.assertEqual(len(text), 60) + self.assertIn("fork", text) + self.assertIn("task=do it", text) + self.assertIn("n=2", text) + short = self.row_text(tai.tui_chat_tool_row("fork", '{"task": "' + "z" * 200 + '"}', 40, tai.TuiStyle())) + self.assertEqual(len(short), 40) + self.assertIn("…", short) + + def test_composer_and_sidebar_fit_screen(self): + app, _agent, _root, refs = self.make_chat(rows=24) + lines = app.render_text().split("\n") + self.assertIn("╰", lines[-2]) + self.assertIn("Send", lines[-3]) + self.assertIn("F2", lines[-1]) def test_pill_row_centered(self): spans = tai.tui_chat_pill_row("Today", 40, tai.TuiStyle(), tai.TuiStyle()) @@ -6117,12 +6204,12 @@ class TuiChatTests(unittest.TestCase): self.assertEqual(refs["pending"], "via button") def test_telegram_roles_registered(self): - self.assertEqual(len(tai.TUI_THEME_ROLES), 22) + self.assertEqual(len(tai.TUI_THEME_ROLES), 27) for role in ("bubble_in", "bubble_out", "bubble_fg", "meta_in", "meta_out", "tick_read", "service_bg", "service_fg", "unread", "unread_muted"): self.assertIn(role, tai.TUI_THEME_ROLES) tai.tui_set_theme("dark") - self.assertEqual(tai.TuiStyle(bg="role:bubble_out").sgr(), "\x1b[0;48;2;43;82;120m") + self.assertEqual(tai.TuiStyle(bg="role:bubble_out").sgr(), "\x1b[0;48;2;67;56;202m") def test_parser_flags(self): args = tai.build_parser().parse_args(["--tui", "--theme", "nord"]) diff --git a/tui.md b/tui.md index 2f7570f..d9dacab 100644 --- a/tui.md +++ b/tui.md @@ -214,12 +214,14 @@ callbacks; exceptions in callbacks never break the loop. ## Themes Colors go through named roles, not literals. `TuiTheme(name, dark, -roles)` holds 22 roles: the 12 base roles (`bg`, `surface`, `fg`, +roles)` holds 27 roles: the 12 base roles (`bg`, `surface`, `fg`, `muted`, `border`, `accent`, `on_accent`, `selection`, `success`, `warning`, `error`, `info`) plus 10 Telegram roles (`bubble_in`, `bubble_out`, `bubble_fg`, `meta_in`, `meta_out`, `tick_read`, `service_bg`, `service_fg`, `unread`, `unread_muted`, with day/night -values from the tdesktop theme files); `TUI_THEMES` is the registry +values from the tdesktop theme files) plus 5 chat-scene roles (`panel` for +sidebar, header and status bar, `accent2` cyan, `accent3` pink, `code_bg`, +`tool_bg`); `TUI_THEMES` is the registry with five builtins (`dark`, `light`, `nord`, `gruvbox`, `ansi`); `tui_register_theme()` adds more (all 12 roles required). Widgets use `fg="role:accent"` @@ -236,17 +238,27 @@ one value in `_tui_builtin_themes()` or register a custom theme. ## Chat scene (`--tui`) -`./tai.py --tui` runs the fullscreen chat UI with REPL parity, -styled after the Telegram client: a bots sidebar with two-line rows -(name / snippet or red draft), a conversation header (avatar -initial, name, `online` / `typing...` plus hairline), a message -transcript of aligned bg-fill bubbles (yours right, theirs left, -70% max width, time plus ticks tucked bottom-right inside), a -1-line status bar, and a composer (input plus Send button). Ticks -follow Telegram: clock while sending, single check on the cloud, -blue double check once read; service pills mark the date. Sidebar -selection switches bots with per-bot input drafts; Tab cycles -sidebar, transcript, input, Send. +`./tai.py --tui` runs the fullscreen chat UI with REPL parity: a +borderless, colored layout. A `panel`-tinted bots sidebar (colored +avatar chip per bot, accent bar on the selected chat, green dot while a +turn runs, red `Draft:` hint), a header with avatar, name and an +`online` / `typing...` dot, a transcript of aligned bg-fill bubbles +(yours right in the accent bubble color, theirs left with the bot name +on top, stamp plus ticks on the bottom row inside the bubble), a slim +scrollbar in the last transcript column, a rounded composer (accent +border while focused) with a Send button, and a status bar of key caps +plus profile/bot and model pills. Ticks follow Telegram: clock while +sending, single check on the cloud, blue double check once read; +service pills mark the date. Sidebar selection switches bots with +per-bot input drafts; Tab cycles sidebar, transcript, input, Send. + +Assistant text renders through a small line-level markdown renderer +(`tui_md_blocks`, `tui_md_inline`, `tui_wrap_segs`): headings, bold, +italic (shown in the info color), inline code, fenced code blocks, +bullets, numbered lists, quotes, rules. Tool calls show as one-line +chips (gear, bold name, `k=v` arguments, truncated). Command output +and errors show as plain bars on the tool background so aligned +columns survive. An empty chat shows a welcome card with the key hints. Slash commands run inline with output captured into a dim bubble (`/quit` quits); anything else runs a full agent turn whose reply @@ -289,11 +301,12 @@ the widget shows a plain error line instead of crashing. the key-event subset of the kitty protocol (no release tracking, no super/hyper/meta, no alternate-key shortcut matching), no true opacity blending. -- The chat scene has no markdown-to-styled-run renderer yet - (assistant markdown shows unstyled), no bubble tails, no unread +- The chat scene markdown renderer is line-level only: no tables, no + links, no nested emphasis, no syntax highlighting inside code + blocks; there are no bubble tails, no unread pills or per-chat times (no honest data source), no attach/voice - or reply-preview (no function behind them), tool calls show as dim - bubbles (no collapsible blocks), no per-token streaming, no + or reply-preview (no function behind them), tool-call chips are not + collapsible and show no results, no per-token streaming, no send-history recall. - No per-widget async tasks; blocking work belongs in threads that post back via timers.