Skip to content

feat(tui v2): theme system + Markdown/streaming/continue fixes#403

Merged
lsdefine merged 16 commits into
lsdefine:mainfrom
nianyucatfish:feat/tui-themes
May 17, 2026
Merged

feat(tui v2): theme system + Markdown/streaming/continue fixes#403
lsdefine merged 16 commits into
lsdefine:mainfrom
nianyucatfish:feat/tui-themes

Conversation

@nianyucatfish

Copy link
Copy Markdown
Collaborator

Summary

Adds a theme system to TUI v2 and fixes a cluster of rendering issues that surfaced along the way.

Themes

  • Themable color system wired through Textual's theme registry; ga-default (github-dark) plus nord / gruvbox / dracula / tokyo-night / textual-light.
  • Ctrl+T opens a live-preview theme picker; choice persists to temp/tui_settings.json and is restored on startup.
  • Non-ga-default palettes use a minimal Markdown theme (fg/muted only) so body text stays readable until each theme is hand-tuned.
  • Theme switch is cache-only invalidation (no full remount), so switching is instant.

Markdown / rendering

  • Anchor _META_TAG_RE to line start so <summary> / <thinking> tags inside backticks survive.
  • CJK-aware wrap patched on every Rich + Textual binding (Rich Markdown, user/system messages, input box).
  • Clean copy: parallel wide render builds a wrap-free source so selection→copy never includes visual wrap newlines.

Streaming perf

  • Defer Markdown render until streaming completes — per-chunk path updates plain Text; the terminal done chunk renders Markdown once and swaps.
    Drops O(chunks × turn_len × 2) Markdown parses to O(turn_len × 2).

/continue restore

  • Rebuild restored transcripts to match live agent_loop output (tool_use headers + tool_result fences).
  • Classify auto-continuation prompts by tool_result presence so they don't show up as fake user messages.

Other small fixes

  • Sidebar Q/S preview switched to C_MUTED so it stays readable on the active row under tokyo-night.
  • Drop the theme: <name> system toast on every theme change.
  • Align ga-default chip_time with the sidebar's active-session green.
  • Hook topbar chip colors into the theme and move the fold indicator to the right column.

Adds Ctrl+T theme cycling across six palettes: a custom ga-default
matching the existing github-dark scheme plus Textual built-ins
(nord, gruvbox, dracula, tokyo-night, textual-light). Themes flow
through Textual's native register_theme + get_css_variables, so CSS
references $ga-bg / $ga-fg / etc. that resolve from the active theme;
ga-default uses our exact palette while built-ins derive the missing
slots (border/dim/muted/border_hi) from resolved bg/fg via a blend.

watch_theme keeps the C_* module globals (Rich Text colors) and the
role-color dict in sync with the active theme, invalidates message
caches, and remounts the current session. Rich Markdown rendering
also passes a per-theme RichTheme so code blocks, links, headings,
and quotes follow the active palette instead of Rich's frozen
defaults. text-muted / text-disabled are deliberately not inherited
from Textual: those resolve to 'auto NN%', a Textual-only syntax
Rich Text rejects.
The five topbar chips (session name / model / effort / tasks / clock)
were hardcoded github-dark hex values introduced in the upstream
topbar redesign, so they stayed cyan/amber/lavender on every theme.
Add five chip_* slots to _DEFAULT_PALETTE and derive them for
built-in themes from primary / secondary / warning / accent / success,
keeping five distinguishable hues across themes. watch_theme syncs
the C_CHIP_* globals on switch.

Also move the '▾ fold' indicator from the LEFT column to the RIGHT,
just before the clock. The left column packs identity + session +
running status pill into a ratio=1 ellipsis column, so a long status
('running 2m 15s') was eating the trailing fold glyph as '...'.
The clock chip was github-dark's brighter success green (#56d364),
which read as more vibrant than the rest of the muted palette and
clashed with C_GREEN. Use the same #7ec27e the sidebar uses for the
active session marker, so all 'positive accent' greens in the chrome
sit at one saturation level.
These are agent-internal metadata: <summary> already feeds the sidebar
preview and the fold title, <thinking> is meant to be hidden. They were
leaking into the chat body, and worse, CommonMark parses
'<summary>X</summary>\n<body>' (no blank line between) as a single HTML
block that swallows the following body line — so the model's actual
reply disappeared from view whenever it wrote the summary tag immediately
before its answer with only a single newline.

Strip both tags in _render_md before passing to the Markdown parser.
Rich's divide_line treats a Chinese run as one indivisible word and
bumps it whole to the next line when it doesn't fit the remaining
space, wasting cells like 'AI ↩ 助手...'. Patch both rich._wrap and
textual.content so Text.wrap (assistant Markdown) and Visual.to_strips
(user messages) pack leading CJK chars into the current line's
remainder, then fold the rest at full width.

Non-CJK words keep stock behavior; code-block content remains
untouched because the patch only adjusts wrap break positions.
_render_md now produces two renders: the existing narrow ANSI roundtrip
for display (mouse selection still anchors via segment.style.meta) and a
wide render at width=10000 that contains only structural \n. An alignment
walk maps each visual line back to a position in the wide source, so
SelectableStatic.get_selection extracts source text without wrap-induced
newlines.

The aligner handles three layouts:
- 1:1 (no wrap): narrow line maps directly to wide line
- N:1 wrap: hanging indent on continuation lines is stripped, and the
  whitespace eaten at wrap points (e.g. spaces between English words) is
  recovered from the wide line
- centered single-line (Markdown headers): wide and narrow have different
  leading-pad widths; source uses the lstripped content, and narrow's pad
  becomes per-line indent that selection clamps over
The two CJK-aware drop-ins (divide_line for Rich/textual.content, and
compute_wrap_offsets for textual._wrap/_wrapped_document) shared
identical greedy-pack-then-fold core logic, written twice. Extract into
_fold_chunk_cells so each call site only handles interface shape (Rich
words vs Textual chunk regex, tab handling).

Also extend the patch to cover textual._wrap.compute_wrap_offsets and
textual.document._wrapped_document — the TextArea / Static-via-Content
paths use compute_wrap_offsets, not divide_line, so the prior patch
missed input-box and user-bubble wrap. Drop try/except defensiveness
around the Textual patch (we target latest Textual; let real breakage
surface), split _CJK_WRAP_RE into commented sub-ranges, prune WHAT
comments.

Verified: non-CJK output remains byte-equivalent to upstream across
both divide_line and compute_wrap_offsets fixture sets; live render at
width 60 packs the first line to ~98% utilization on input box, USER
bubble, and AGENT bubble.
…utput

extract_ui_messages now renders tool_use headers and tool_result fences
into the assistant content using the same string format that agent_loop
yields live, so fold_turns folds restored sessions identically to live
chat. Tool-only response rounds no longer drop the user message, and
Turn 1 also carries a marker so the first turn folds like the rest.
_user_text was misclassifying any prompt whose text block didn't start
with '### [WORKING MEMORY]' as a real user message — but auto-continue
prompts can interleave [SYSTEM TIPS], [System] regenerate triggers,
[DANGER] guard injections, and similar. Treat any prompt carrying a
tool_result block as auto-continue (the natural signal that this is
the next round of an in-flight LLM call), and extend the injection
marker list as a secondary guard for the rare tool_result-less
synthetic prompts.
Per-chunk _render_md on the growing tail segment is O(chunks x turn_len)
with two Markdown parses each call. During streaming, update the last
text widget with plain Text; on the terminal done chunk, render Markdown
once and swap, restoring code blocks, lists, and clean-copy source.
- Replace cycle-theme with ThemePicker modal: OptionList live-previews on
  highlight, Enter commits + saves, Esc reverts to the prior theme.
- Persist UI settings in temp/tui_settings.json; restored on startup.
- Theme Markdown list bullets and ordered-list numbers via markdown.list /
  markdown.item.bullet / markdown.item.number — previously hard-cyan.
- Sidebar Q/S preview used C_DIM (0.35 bg/fg blend); under the active row's
  SEL_BG the contrast collapses on tokyo-night. Switch to C_MUTED (0.55).
- Drop the 'theme: X' system message — the picker provides the visual
  feedback already, the extra row is noise on every preview tick.
…non-default themes

- watch_theme no longer remounts the whole transcript: just clears render
  caches and repaints chrome. Long-history switches go from multi-second
  Markdown reparse to ~150ms. Already-mounted message text stays in the
  old palette until the next natural reflow (fold toggle, resize, new
  turn, session switch).
- _markdown_rich_theme gains a colored flag. Non-ga-default themes render
  Markdown in plain fg with only structural emphasis (bold/italic/code
  backdrop) until per-theme accents are tuned — avoids text blending
  into the background on themes whose accent slots collide with sel_bg.
Non-ga-default themes fight Rich's frozen accent expectations — code spans
on sel_bg, link colors, heading hues end up either washed out or invisible
on themes like tokyo-night / textual-light. Until each is hand-tuned,
collapse heading / list / code styles to fg + muted so body text stays
readable; inline code also drops its sel_bg background under ga-default
(was hiding text in some terminals).
_META_TAG_RE blindly stripped the tags so inline-code samples like
'`<summary>x</summary>`' became bare empty backticks. Split the
text on fenced/inline code regions and only strip in the gaps.
The earlier split-around-code-spans helper was incomplete (no ~~~ fences,
no indented blocks, no multi-line inline code) and over-engineered. The
underlying CommonMark hazard is only the start-of-line HTML block that
swallows the next line — mid-line tags are inline HTML Rich already
renders as text. Anchoring the regex to line start (^ + MULTILINE,
optional <=3 leading spaces) handles the original swallow case while
leaving every code-region variant intact. One known gap: a <summary>
tag on the first column inside a fenced block still matches.
@lsdefine
lsdefine merged commit 730d506 into lsdefine:main May 17, 2026
@lsdefine

Copy link
Copy Markdown
Owner

LGTM. 主题系统和streaming优化都很实用,merge了。

RocketLi pushed a commit to RocketLi/GenericAgent that referenced this pull request May 18, 2026
…ine#403)

feat(tui v2): theme system + Markdown/streaming/continue fixes
lsdefine added a commit that referenced this pull request Jul 23, 2026
feat(tui v2): theme system + Markdown/streaming/continue fixes
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants