| 2026-07-13 |

tui: copy chat to system clipboard via reading mode + selection fixes
...
Two ways to copy chat content out of the TUI now work:
1. In-app selection (Ctrl+C, OSC 52): _ChatItemView.render() now builds a
selection-aware Content from the rich renderable instead of letting Static
wrap a Panel into a non-selection-aware RichVisual — so the magenta
drag-select highlight actually renders and get_selection returns clean,
chrome-free text. rich Segment styles are converted to Textual Style
(Style.from_rich_style) so the selection_style merge doesn't crash, and
None segment styles are guarded. get_content_height is overridden to
compute auto-height straight from the rich renderable; without it the
width-keyed Content churned new Visual objects per width change and spread
the layout-settling cascade across refreshes, leaving a deferred scroll_end
at an intermediate max_scroll_y (chat no longer stuck to bottom). Theme
defines screen-selection-background/foreground so the highlight is visible.
2. Reading mode (Ctrl+R) for terminals without OSC 52 (e.g. GNOME Console,
TERM_PROGRAM=kgx, which silently ignores OSC 52 so Ctrl+C never reaches the
OS clipboard). Toggling the app's reading-mode class hides the chrome around
the chat (right status/todo column, input prompt, status bar, chat outer
border) and re-renders every message without its Panel borders via the
registry's new render_plain — so Shift+drag + Ctrl+Shift+C copies just the
conversation text to the system clipboard. ContentRenderer.render_plain
defaults to unwrapping Panel/Padding(Panel) to its body; assistant messages
and plan_ready override it to raw Text(content) so rich Markdown doesn't
re-wrap fenced code blocks in their own bordered Panels. ChatPanel tracks
reading mode and re-renders visible widgets in place (scroll preserved);
new streamed items pick up the mode on mount.
Help (/help) now lists keys and documents both copy paths.
Tests: render_plain parametrized across all item types, message renderer
plain tests, chat panel reading-mode toggle (borders off/on, scroll kept,
new items borderless), tui_app reading-mode chrome hide, plus the existing
selection suite. Full suite green (922 passed, 1 skipped).
Eugene Sukhodolskiy
committed
16 hours ago
|
tui: make chat content selectable/copyable without bubble chrome
...
Chat items render rich Panels (rounded borders + Navi/You titles), but the
base Widget.get_selection only extracts text from widgets whose render is
Text/Content -- Panels return None, so a drag-select over a message or tool
output copied nothing (the highlight showed the borders, but Ctrl+C yielded
no message content).
Override _ChatItemView.get_selection: re-render the item's renderable via
the app console at the widget width (line layout identical to the screen),
strip the outer panel border lines and the │ / │ gutters, and remap the
mouse offsets onto the clean content so a partial-line selection lands on
the right characters. Plain renderables (status, turn_meta, filesystem tool
output) have no chrome and map 1:1. Ctrl+C (Screen.copy_text) then copies
the clean text via OSC 52.
Eugene Sukhodolskiy
committed
17 hours ago
|
tui: restore tool args/metadata, assistant-then-tool order, turn_meta on resume
...
ChatModel.load_history rebuilds the resumed session so the chat renders the
same way as a live turn:
- Recover tool-call args via tool_call_id (the persisted tool message stores
only the result, not the call args) and forward the stored metadata. Without
this the filesystem renderer saw args={} -> action=None and fell back to a
plain, un-highlighted result -- the resume "no syntax highlighting" symptom.
- Emit assistant_message BEFORE tool_started when an assistant message has both
text and tool_calls, matching the live stream where stream_delta precedes
tool_started; previously the answer was stranded under its own tool cards.
- Append a turn_meta item when the persisted assistant message carries
elapsed_seconds, reproducing the single duration line stream_end appends.
is_subagent for resumed tool calls is intentionally deferred (needs storage).
Eugene Sukhodolskiy
committed
18 hours ago
|
tui: let the user scroll up while the agent streams + keyboard scroll bindings
...
ChatPanel._sync called scroll_end on every WS event (every stream_delta),
yanking the view back to the bottom on each token — impossible to scroll up
and read earlier messages during a response. Stick-to-bottom: capture
is_vertical_scroll_end before any DOM change and only scroll_end when the
user was already at the bottom; auto-follow resumes when they scroll back.
Add app-level keyboard bindings so the chat scrolls without leaving the
input box: Ctrl+Shift+Up/Down (one line), Ctrl+End (jump to bottom). These
keys are not claimed by TextArea (it uses shift+up/down for select and
ctrl+e/end for line end), so they bubble from the input to the app.
Eugene Sukhodolskiy
committed
18 hours ago
|
| 2026-07-12 |

Navi Code TUI: show context-compression summary in the chat
...
The server already sent the summary text in ContextCompressed.summary, but the
TUI never showed it — forced /compact printed a bare "N → M messages" line and
mid-turn auto-compress produced no chat feedback at all. Now both render a
distinct bordered block headed "Context compressed: N → M messages" with the
summary text (markdown) inside, so the user can see what the compressor kept.
- chat_model: new context_summary kind; context_compressed creates a chat item
carrying the summary + before/after counts (was explicitly ignored).
- renderers/summary: ContextSummaryRenderer — dim bordered panel, markdown body.
- chat_panel: _item_msg mapping for context_summary.
- tui_app: emit the summary item on context_compressed for both forced and
mid-turn cases; drop the now-redundant one-line status (the block header
carries the counts). Spinner start/stop/label logic unchanged.
- Tests: chat_model mapping + renderer (accepts/counts/text/missing-counts).
Summaries are live-only — not rebuilt on session reload (the compressor stores
them is_display=False, same as before for compression events).
Co-Authored-By: Claude <noreply@anthropic.com>
Eugene Sukhodolskiy
committed
1 day ago
|
| 2026-07-11 |
tui: per-message chat widgets — render only the streaming bubble
...
ChatPanel used to hold a single Static rebuilt via update(Group(...)) on every
event, so a per-token stream_delta re-rendered all visible items (up to 200).
Now each message is its own _ChatItemView(Static); _sync() mounts new widgets at
the end, removes dropped ones, and signature-gated maybe_update re-renders only
the changed widget. On a stream only the streaming bubble repaints — the rest
stay mounted untouched. Public API stays synchronous (mount/remove are sync in
this Textual version), so tui_app is unchanged. Truncation hint is now a
dedicated top widget toggled via styles.display.
Co-Authored-By: Claude <noreply@anthropic.com>
Eugene Sukhodolskiy
committed
2 days ago
|

Navi Code TUI: show request duration (live timer + chat metadata)
...
Live elapsed timer next to the activity indicator (ticks once per second
during a turn, format 2m 16s), driven by the same stream_start/stream_end
hooks as the activity indicator — so it spans the whole turn (all LLM calls,
tool calls, planning, sub-agent steps). On stream_end it syncs to the
backend's authoritative elapsed_seconds before freezing; on stream_stopped/
error/disconnect it freezes at the locally-measured value.
On stream_end the model also appends a turn_meta ChatItem rendered as a dim
"⏱ 2m 16s" line below the answer — the total time the agent spent on the
user's request. The value comes from stream_end.elapsed_seconds, already
measured by the backend over the whole turn, so the TUI needs no timer for
accuracy. stream_stopped/error do not get a metadata line (only completed
requests).
Shared format_duration(): None -> "—", <60s -> "16s", <3600 -> "2m 16s",
>=3600 -> "1h 2m".
Co-Authored-By: Claude <noreply@anthropic.com>
Eugene Sukhodolskiy
committed
2 days ago
|
tui: load session message history into the chat panel on resume
...
Resuming or switching a session showed a blank chat — the past conversation
was never replayed. GET /sessions/{id} already returns the messages; the TUI
just ignored them.
- ChatModel.load_history maps persisted Message dicts back to the same
ChatItems the live stream produces (user/assistant text, thinking, tool
started/result, plan), skipping is_display=False (context-only user,
summaries, compression events) so only what the user would have seen
live is rebuilt.
- ChatPanel.load_history wraps it (clears the render cache + refresh).
- attach_session loads the messages from get_session, so resume/switch
replays the history before the cwd/Connected banners.
Co-Authored-By: Claude <noreply@anthropic.com>
Eugene Sukhodolskiy
committed
3 days ago
|
| 2026-07-10 |
tui: bound chat history render cost (cache + visible-item cap)
...
The chat panel rebuilt every renderable on each WS event, including
per-token stream_delta — so markdown was re-parsed for the entire history
on every token and the TUI lagged as conversations grew.
- Cache renderables keyed by id(item)+signature so only the changed item
(the streaming assistant bubble) is rebuilt per token; the rest are
reused. Prune orphaned entries so cache stays bounded by live items.
- Add max_visible_items TUI setting (default 200): render only the last
N items with a "… N earlier messages not shown" hint when truncated.
Items stay in the in-memory model (non-destructive) but are not laid
out, so per-refresh cost is constant regardless of history length.
- Rename the cache attr to _chat_render_cache to avoid collision with
Textual Widget._render_cache, which it resets on mount.
Co-Authored-By: Claude <noreply@anthropic.com>
Eugene Sukhodolskiy
committed
3 days ago
|