Redesign --tui chat scene: colorful borderless layout, markdown bubbles, tool chips, rounded composer; fix clipped composer and hidden-panel gap
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+113
-26
@@ -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"])
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user