| 2026-09-09 |

security: critical batch 1 — RCE/XSS/CORS/webhook hardening
...
Backend:
- auth/deps: fix refresh-lock clock race (cleanup now monotonic like the cache)
- core/registry: add missing structlog logger (NameError on fallback path)
- config: GNAUTH_WEBHOOK_SECRET, NAVI_ALLOWED_ORIGINS (+list property)
- webhooks: HMAC-SHA256 signature verification (503 unconfigured in auth mode,
unsigned+warning in no-auth mode, 403 bad/stale signature, 400 bad JSON)
- main: CORS from NAVI_ALLOWED_ORIGINS in auth mode (fail-fast on empty),
eval router behind require_admin, /debug only registered in no-auth mode
- auth routes: mobile-done sid validation (32 hex) + CSP header + safe JS
escaping (reflected XSS); Secure cookie flag via helper (https base URL)
- websocket: anonymous WS rejected in auth mode (closes legacy-session RCE);
socket registered only after access checks; stop_session auth gate
- messages: REST agent start now takes session lock + busy flag (409 on
active run), contextvars reset in finally
Webclient:
- useMarkdown: DOMPurify.sanitize on all rendered markdown (stored XSS),
image-URL scheme whitelist, delegated error listener (no inline onerror)
- html.html artifact viewer: sandbox without allow-same-origin (opaque origin)
- tests: DOMPurify runs under jsdom (happy-dom Node.prototype.nodeName getter
breaks DOMPurify); useWebSocket tests get localStorage stub + ui-kit alias
Tests: pytest 1049 passed, 1 skipped; vitest 61 passed
Eugene Sukhodolskiy
committed
1 day ago
|
| 2026-07-14 |
tui: dedicated code_exec renderer — highlighted code + structured result
...
code_exec went through the generic tool renderer: the whole script was dumped
as an escaped JSON string (unreadable), and the result was a dim wall of text
with stdout/stderr fused by a [stderr] marker. Give it a first-class card:
started: Python syntax highlighting via the shared highlight_code (follows
Theme.code_theme), long scripts folded at 60 lines, working_dir/timeout shown
as compact key/values instead of JSON bulk.
result: exit code anchored in the title (exit N), stdout and stderr split into
separate blocks with stderr in the warning colour, and a dedicated "timeout Ns"
status (detected from metadata or, for legacy sessions, the output text).
Registered before the generic tool renderers (first accepting wins).
Eugene Sukhodolskiy
committed
on 14 Jul
|
code_exec: agent-controlled timeout (30s default, 300s max) + language metadata
...
The 30s timeout was a hard constant; long computations or test suites hit it
with no escape hatch. Expose it as a `timeout` param (clamped 1-300s, default
30) like terminal's. Also stamp `language: "python"` into metadata so the
TUI renderer can highlight without hard-coding, and record `timeout` on the
timeout result for a dedicated timeout status card.
Eugene Sukhodolskiy
committed
on 14 Jul
|
tui: lower default visible chat window 200->60 (faster long-session resume)
...
The cold cost is dominated by the one-shot mount of the whole visible
window at load/switch time. 200 _ChatItemView widgets each parse Markdown,
build Content, compute height and run layout in one pass. 60 cuts that
peak ~3.3x with no loss for streaming (signature cache + throttle + render
cache keep per-token cost O(1) once mounted). Model cap (max_visible_items
* 3) follows automatically: 600 -> 180. Older items collapse into the
existing "... N earlier messages not shown" hint.
Eugene Sukhodolskiy
committed
on 14 Jul
|

compress: raise budget 6000 + richer prompt detail + input headroom
...
Increase the compression summary budget and push the prompt toward more
detail and completeness, so the agent keeps more durable facts when old turns
are folded into a summary.
Budget (output):
- context_summary_max_tokens 4000 → 6000 (global default).
- navi_code compression_max_tokens 4000 → 6000 (profile override).
Prompt (base, all profiles):
- New 'Intermediate Findings' section: durable facts from read/grep/log/terminal
that informed decisions (was forbidden by 'do not preserve intermediate
reasoning'). Existing sections get richer instructions (Active Files: key
symbol affected; Decisions: rationale + trigger; Completed: step + file +
verification outcome; Errors: snippet + what was tried + outcome).
- Loosen 'tight bullet points' → 'specific and complete; prefer listing over
generalizing — losing a fact costs more than a longer summary'.
- 'Do not write implementation code' → 'preserve short critical code verbatim
(final signatures, one-line fixes, key config lines); drop long blocks'.
Input headroom (so the summarizer can SEE what it must preserve):
- Non-critical tool result preview 300 → 800 chars.
- _MAX_SUMMARY_INPUT_CHARS 24000 → 32000 (fits 65k–128k windows with 6k output).
Profile prompt (navi_code/compression_prompt.txt): preserve short diagnostic
snippets verbatim (exact error line, failing assertion, one-line fix) instead
of blanket 'do not preserve long output'; keep code signatures + short key
snippets, not just names.
Tests: Intermediate Findings section in base prompt; 'short critical code'
phrasing; non-critical preview keeps 800 chars; meta-summary threshold raised
(2×6000 chars). Fixed flaky test_attach_session_seeds_context_fill (relied on
_startup worker timing after Etap 4 lengthened attach; now explicit attach
like its sibling + mock get_todos to skip a 30s×2 real-network timeout).
Full suite: 1008 passed, 1 skipped.
Eugene Sukhodolskiy
committed
on 14 Jul
|

todo: add op — append steps discovered mid-task (preserve statuses)
...
The todo tool only had set/view/update/clear. Adding a step that surfaced
mid-task meant either 'set' (which replaces the plan and resets every status
to pending) or 'replan' (1–3 LLM calls) — both heavy for a single new step, so
the agent usually skipped recording it. Add a cheap 'add' op:
- navi/tools/todo.py: op 'add' (tasks: list[str]) appends new _Task entries to
the end of the existing plan in pending status; existing steps and their
statuses are untouched. Requires an existing plan (points to 'set'
otherwise). Schema enum + op description + tool description updated.
- system_prompt.txt: tell the agent to record new subtasks with 'todo add'
right away (not held in head, not a replan); clarify the 'small todo edit'
line — add via 'todo add', drop/merge/reorder via 'set' (re-apply statuses).
- renderers/todo.py: TodoStartedRenderer renders the add card
('→ todo · add (N)') listing the new pending steps, not a JSON dump.
Tests: add appends + preserves statuses / requires existing plan / requires
tasks; renderer add card + empty-tasks. Full suite: 1005 passed, 1 skipped.
Eugene Sukhodolskiy
committed
on 14 Jul
|
tui: terminals picker — clearer two-line rows + Delete to close
...
- Each terminal row is now two lines: the name (bold, with status icon) on
top, the description + pid + uptime as a dim meta line beneath, separated
by '·' — easier to scan than a single run-on line.
- Close is bound to Delete/Backspace instead of Enter: Enter commonly means
"open/select", Delete means "remove/close", which matches closing a
terminal. Title updated: "Open terminals — Delete to close, Esc to cancel".
No-op on an empty list.
Tests: close-on-delete (was close-on-enter), empty-list delete is a no-op.
Full suite: 1000 passed, 1 skipped.
Eugene Sukhodolskiy
committed
on 14 Jul
|
tui: terminal tool_started renderer (action-aware card)
...
terminal tool_started cards used to fall through to the generic
ToolStartedRenderer (a raw JSON dump of the args — a wall of quoted strings for
a command or input). Add an action-aware started renderer, mirroring the result
renderer and the filesystem started renderer:
- Title: → terminal <action>.
- run: the command as a shell prompt in accent (the headline) + optional cwd.
- open: terminal name (accent) + description + background + command preview.
- close/status: terminal name. send_input: name + input preview.
- list: empty body (no per-call headline; the result card carries the table).
Registered before ToolStartedRenderer (first accepting wins).
Tests: started accepts gating + run/open/close/send_input/list cards;
render_plain covers the borderless reading-mode path. Full suite: 1000 passed,
1 skipped.
Eugene Sukhodolskiy
committed
on 14 Jul
|
| 2026-07-13 |

tui: /terminals command + modal to view and force-close terminals (Etap 5)
...
The final piece of the terminals plan: a modal to list the session's open
persistent terminals and force-close one.
- screens/terminals_picker.py: a modal (mirror of sessions_picker). Seeds the
list from api.list_terminals on open, kept live by refresh_from_model — the
app calls it as terminal_opened/closed events arrive while the modal is open
(closed ones drop out, newly opened append). Each row shows status emoji +
name (bold) + description + pid + uptime. Up/Down navigate, Enter force-closes
the highlighted terminal via api.close_terminal (in a worker), Escape cancels.
Empty list is a no-op for Enter (no crash, no close call).
- commands/builtin.py: TerminalsCommand (/terminals) opens the picker for the
active session. Registered in the command registry.
- tui_app._open_terminals_picker pushes the screen; on_ws_event's terminal
branch now also refreshes the picker live if it is the active screen.
Tests: list seed, Enter closes the highlighted terminal (api.close_terminal
called, drops from list), Escape cancels (dismiss None), live refresh filters
closed + adds opened, empty list Enter is a no-op. Input-box hint tests
adjusted for the new /t match order (terminals, thinking, themes). Full suite:
993 passed, 1 skipped.
Plan docs/terminal_tool_plan.md — Etap 5 of 5 (complete).
Eugene Sukhodolskiy
committed
on 13 Jul
|

tui: handle terminal lifecycle events + status-bar count (Etap 4)
...
terminal_opened/output/closed WS events used to fall through to ChatModel's
unknown-event path (raw dict dumped into chat). Now they feed a dedicated
terminals state and a status-bar count, with no chat bubble:
- ChatModel: terminals dict (name → {description, pid, background, status,
output_tail, closed}) updated by terminal_opened/output/closed in
handle_ws_event (return None — no chat item). open_terminal_count property;
seed_terminals(summaries) populates from a REST list (on attach). output_tail
capped at 200 lines.
- tui_app.on_ws_event: a terminal-events branch forwards to the model (no _sync
walk — background output can be frequent) and updates the StatusPanel count.
attach_session seeds via api.list_terminals and sets the count (graceful 0 on
failure).
- StatusPanel: the "Ctrl+P palette | /help commands" hint line is replaced with
a live "Terminals: N" count (set_terminals_count). Key combos still live in
the bottom StatusBar.
- Tests: mock_tui_api fixtures across 5 client test files gain an async
fake_list_terminals so attach_session never hits a real network in tests.
Tests: ChatModel opened tracks state (no bubble) / output appends tail / unknown
ignored / closed decrements count / seed from REST; tui_app terminal events
update the status count with no chat bubble. Full suite: 988 passed, 1 skipped.
Plan: docs/terminal_tool_plan.md — Etap 4 of 5.
Eugene Sukhodolskiy
committed
on 13 Jul
|

tui: terminal tool-call renderer (action-aware) (Etap 3)
...
terminal tool_call results used to fall through to the generic
ToolResultRenderer (flat Text, no awareness of the action). Add an
action-aware renderer registered before the generic one, mirroring filesystem:
- run: command echoed as a shell prompt + dim output + an exit-code anchor
(green on 0, red otherwise). A failed run still shows what ran and the exit
code, not just the raw error.
- open: terminal name (accent) + description + background flag + pid.
- list / status: dim, truncated (200-line cap like the generic tool renderer).
- send_input / close: dim echo. open failure surfaces the reason (already
exists / max reached). Other action failures show the message in red.
Registered before ToolResultRenderer in default_registry (first accepting
renderer wins). ChatPanel._item_msg already spreads the WS payload into meta,
so the renderer reads args/result/success/metadata directly.
Tests: accepts gating, run success/failure with exit code, open name/desc/pid,
list/status/send_input/close, run truncation; render_plain covers the
borderless reading-mode path. Full suite: 982 passed, 1 skipped.
Plan: docs/terminal_tool_plan.md — Etap 3 of 5.
Eugene Sukhodolskiy
committed
on 13 Jul
|

terminal: REST list/close endpoints + async client helpers (Etap 2)
...
The /terminals modal and the status-bar count need a server source for the
session's persistent terminals (background dev servers, etc.) and a way to
force-close one. Add REST endpoints over the existing TerminalManager:
- GET /sessions/{id}/terminals → terminal_manager.list(session_id) →
{session_id, terminals: [summary,...]}. Access-checked (navi.sessions.read_all).
- POST /sessions/{id}/terminals/{name}/close → terminal_manager.close(...)
→ {session_id, terminal_name, closed: bool}. Name is URL-encoded by the
client (terminal names may contain spaces).
- clients/terminal/api.py (async): list_terminals(session_id),
close_terminal(session_id, name) — for the TUI modal + status-bar seed.
Tests: list empty / list returns active / close routes to manager / 404 on
unknown session (fake TerminalManager injected into the container, mirroring the
kv_store injection pattern). Full suite: 972 passed, 1 skipped.
Plan: docs/terminal_tool_plan.md — Etap 2 of 5.
Eugene Sukhodolskiy
committed
on 13 Jul
|

terminal: session-broadcast sink + lifecycle events (Etap 1)
...
Persistent terminals emitted their stream only into a per-tool-call sink, which
closed once the open action returned — so a background dev server's output
stopped reaching the client immediately after open. TerminalClosed was never
emitted at all (the handler existed, the emitter did not).
Replace the per-tool event_sink with a single callback the orchestrator wires to
the session's WebSocket(s):
- TerminalManager: set_event_callback(cb) + _emit(session_id, event). Reader
tasks and open/close deliver TerminalOutputDelta / TerminalOpened /
TerminalClosed through the callback, which lives for the terminal's (and
session's) lifetime — not the tool call's.
- New TerminalOpened event (terminal_opened: name/description/pid/background).
TerminalClosed now actually emitted from _close_one (explicit/idle/shutdown/
session_ended). Background output keeps streaming after open returns.
- terminal.py: drop the per-tool event_sink from _tm.open (no longer needed).
- orchestrator._on_terminal_event → _notify_session (per-session WS fan-out,
same path recall/mcp updates use). container wires the callback after building
the orchestrator.
- Events imported lazily inside terminal_manager methods to avoid a circular
import (navi.core.events → navi.core.__init__ → agent → registry → navi.tools).
Tests: open emits TerminalOpened; background output streams via the callback
after open returns (gap-3 fix); close emits TerminalClosed; no callback does not
break readers. Existing terminal tests unchanged. Full suite: 968 passed, 1 skipped.
Plan: docs/terminal_tool_plan.md (5 etaps, this is Etap 1).
Eugene Sukhodolskiy
committed
on 13 Jul
|

tui: bash-style message history (Up/Down recall, per-session, in-memory)
...
On an empty prompt with the hints list closed, Up recalls the previous
submitted message and Down moves toward the most recent; Down past the newest
restores the empty draft. Like a shell history:
- InputBox owns the per-session history (capped at the 10 most recent, with
consecutive-duplicate suppression) plus the browsing index and saved draft.
- _PromptInput._on_key enters history mode on Up only when the field is empty
and the hints list is not open; while hints are open Up/Down still navigate
command matches, and a non-empty field uses TextArea's multiline cursor.
- Only plain user messages are recorded — slash commands and !shell are not
remembered (they are not messages to the agent). The raw typed text is stored.
- on_user_submitted records a plain submit; attach_session resets the history
on session switch/resume (the history is in-memory per session, not synced
to the server, by design).
Tests: append cap/dedup/skip-commands, up/down navigation, reset, Up-recall /
Down-draft / Up-non-empty-no-recall over pilot, submit records plain (not
/commands). Full suite: 964 passed, 1 skipped.
Eugene Sukhodolskiy
committed
on 13 Jul
|

tui: minor correctness/perf fixes (Etap 5)
...
The last batch from the code review — real bugs and edge cases, not cosmetics:
- tui_app._resolve_session: read session_id with .get and return None when a
malformed server response omits it, instead of KeyError slipping past the
api-call except (5.B3).
- cli /switch: surface the original error cause (404 vs network vs server) on
a non-unique prefix match instead of swallowing it into a generic "not found"
(6.B2).
- input_box _complete_command: a bare "/" with no hint list available no longer
auto-picks the first command in the registry (3.B3).
- filesystem _render_grep: when the grep ran in regex mode, highlight with the
actual regex so marked spans match what the server found — a literal highlight
of a regex pattern would mark nothing (2.B2).
- file_refs _collect_files: filter sensitive entries out in the generator before
sorted() so a huge directory is not fully materialized only to be mostly
discarded (5.P1).
- renderers/tool ToolResultRenderer: cap a generic tool result at 200 lines
(tail + marker) so a non-filesystem tool with huge output does not flood the
chat bubble — filesystem has its own renderer (2.P3).
Tests: malformed-response /switch error surfacing, bare-slash no-auto-pick,
grep regex highlight, sensitive-subdir skip, tool-result truncation. Full
suite: 953 passed, 1 skipped.
Eugene Sukhodolskiy
committed
on 13 Jul
|

tui: theme repaцвет chat + bounded history + tail purge (Etap 4)
...
- apply_theme now re-renders already-drawn chat bubbles in the new palette:
ChatPanel.refresh_content() rebuilds each visible item's rich renderable (the
item renderers read get_active_theme(), already switched) and drops the
per-widget Content/height caches so the next paint rebuilds instead of
returning the old-theme cached Content. Theme switch / live preview no longer
leaves the conversation in the previous palette (5.B1).
- theme_picker live preview is debounced (~100 ms coalesce via set_timer):
apply_theme now re-renders every bubble, so applying it on every highlight
while scrolling the theme list would stutter — rapid moves collapse into one
apply, with the freshest theme winning. Escape/cancel dismiss the pending
timer so it cannot fire after a restore and re-apply the highlighted theme
over the restored one (4.P2).
- ChatModel.items is now bounded: a cap (visible window * 3) trims the oldest
items off the front via a single _append/_trim path, so a long autonomous
session no longer grows items without limit. The visible window is much
smaller than the cap, so trimming only drops already-off-screen items and the
truncation hint still reads correctly (3.P3).
- stream_end purges empty assistant/thinking bubbles off the tail (while-pop)
instead of a full-history list-comprehension copy — empty bubbles are created
at the end of the turn, so O(tail) suffices (1.P1).
Tests: apply_theme re-renders bubbles; theme picker debounces + escape cancels;
chat_model cap trims front / cap=None keeps everything; stream_end keeps
non-empty assistant + purges trailing empty thinking. Full suite: 946 passed, 1 skipped.
Eugene Sukhodolskiy
committed
on 13 Jul
|

tui: async REST api client — stop blocking the event loop (Etap 3)
...
The terminal client's REST helpers were synchronous (httpx.Client); every
caller inside the Textual event loop (attach/switch/resume/profile/stop) blocked
the whole TUI for the duration of each network round-trip — the spinner froze,
input stopped responding, the screen did not repaint. Move the network layer to
async and a shared pooled client:
- api.py: all 7 helpers are now `async def` over a single lazily-created
httpx.AsyncClient (connection pooling) — awaits yield the loop while a request
is in flight, so the TUI never blocks on the network.
- tui_app.py + commands/builtin.py: every `api.*` call site is now `await`-ed
(all already ran in async workers / execute). _stop_stream_worker drops the
dead `iscoroutine` check (api.stop_session is async, the await is real).
- cli.py: _resolve_session_id and _handle_command await api.*; _run_raw wraps
the resolve + run in asyncio.run(_run_raw_async) so the sync click entrypoint
stays unchanged. Removed the now-unused _run_one_shot wrapper.
- sessions_picker.on_mount: list_sessions runs in a worker (run_worker) instead
of blocking on_mount — a slow/unreachable backend no longer hangs the modal;
the input is focusable immediately and the list populates when the request
returns.
Tests: every api mock across 6 client test files is now an `async def` fake
(test_tui_app, test_chat_panel, test_sessions_picker, test_terminal_client,
test_tui_export, test_input_box); the sessions picker tests use an async
_raise_not_found helper for the 404 path. Full suite: 939 passed, 1 skipped.
Eugene Sukhodolskiy
committed
on 13 Jul
|

tui: throttle streaming markdown rebuild + cache height (Etap 2)
...
The assistant bubble re-parsed rich Markdown on every stream_delta and rich-rendered it
twice per delta (once to paint, once to measure height) — O(N^2) over the answer length.
Fix the cost while keeping live markdown formatting:
- _ChatItemView.maybe_update now throttles assistant_message rebuilds to ~one per 150 ms
(coalesced via set_timer). The ChatItem content still updates per delta; only the
Markdown parse + rich render is deferred. Plain renderables (thinking/planning are
Text) bypass the throttle — they are cheap. The first mutating delta rebuilds
immediately for a snappy start; rapid bursts coalesce.
- get_content_height is cached by (renderable identity, width) so the layout pass no
longer re-renders the rich renderable to measure it between throttled rebuilds.
- stream_end flushes any pending throttled rebuild so the final chunk of streamed text
appears immediately instead of after the throttle window.
- A deferred rebuild re-anchors scroll to the bottom (via ChatPanel._stick_to_bottom)
so the freshly rendered streaming text stays in view after its height grows.
- ThemedMarkdownRenderable.__rich_console__ now streams segments straight through
instead of materializing them into a list first (no per-paint O(N) allocation +
double walk); the link rewrite is inline in the loop.
Tests: adapted test_per_token_refresh_renders_only_the_streaming_item to the throttled
semantics (a burst yields a couple of rebuilds, not one per token, all for the assistant
item); added throttle-coalesces and stream_end-flushes tests. Full suite: 939 passed, 1 skipped.
Eugene Sukhodolskiy
committed
on 13 Jul
|

tui: code-review Etap 1 — crash/injection/reconnect fixes + drop artifact renderer
...
Point fixes from the full TUI/terminal-client code review (docs/code_review_tui.md),
each with a covering test:
- command_palette: guard on_list_view_selected against IndexError when the
filter is empty and the placeholder row is selected.
- cli /profile: read session_id (not the absent "id") from the server — the
handler no longer KeyErrors.
- todo_list: escape task text/validation/index/milestone before interpolating
into rich markup so a task like "fix [bug]" no longer breaks the line styling.
- builtin /help: drop bold markup tags from status content — StatusRenderer
builds a literal Text() that would show the tags as literal square brackets.
- tui_app on_user_submitted: always enqueue when a bridge exists (the input loop
buffers across reconnect) instead of dropping the message on connected=False.
- chat_model stream_start: also reset _current_thinking so a dropped turn (no
thinking_end) does not glue the next turn's reasoning onto the old block.
- todo renderer update: coerce validation to str before .strip() so a non-string
JSON value does not AttributeError.
- renderers: remove the unused ArtifactRenderer (no production path emits
type=artifact — TUI shows files via the OS); keep the diff renderer test.
Full suite: 937 passed, 1 skipped.
Eugene Sukhodolskiy
committed
on 13 Jul
|

tui: unify syntax highlighting under Theme.code_theme; highlight filesystem read output
...
All syntax-highlighted code in the TUI now resolves one Pygments style per
theme from a single point: Theme.code_theme (gnexus-dark=dracula,
gnexus-light=paraiso-light). Previously the dracula/github-light name was
duplicated in artifact.py and markdown_content.py, and the light theme's
"github-light" is not a real Pygments style (only github-dark exists), so
rich silently fell back to "default" and light-mode code was never
highlighted — fixed by switching to paraiso-light.
New renderers/syntax.py::highlight_code() is the shared Syntax factory
(always theme.code_theme + background_color=theme.surface.hex); artifact.py
and the new filesystem read path build through it, and
_theme_aware_code_theme now reads Theme.code_theme via ThemeRegistry.
filesystem FilesystemToolResultRenderer._render_read: the file body is now
syntax-highlighted via highlight_code with the language guessed from the
read path (guess_language). The server's "{num:>width}: {line}" prefix is
stripped (_strip_number_prefix) so Syntax renders its own line-number column
and multi-line constructs (triple-quoted strings, block comments) highlight
correctly across line boundaries; numbered=False renders without numbers.
The header plaque (accent path) and the large-file warning stay as Text;
body is Group(header, [warning,] Text, Syntax). Unknown extensions fall back
to the "text" lexer (plain).
Diff content keeps marker-only coloring (combining Pygments token styles
with the +/- tint is non-trivial); grep/list/info/find unchanged.
Tests: tests/clients/test_code_theme.py pins the single-point contract
(markdown/artifact/read all resolve theme.code_theme, dark != light);
test_filesystem_renderer read tests rewritten for the Group+Syntax body,
plus language-guess and numbered=False coverage. Full suite: 932 passed.
Eugene Sukhodolskiy
committed
on 13 Jul
|

mcp: dedicated runner task per client (fix cross-task cancel-scope on shutdown)
...
MCP client SDK transports (stdio/sse/streamable_http) + ClientSession are
anyio task groups whose cancel scopes require __aenter__/__aexit__ in the
SAME asyncio task. McpClient entered the transport in one task (lifespan
connect / health-check reconnect / request retry) and exited it in another
(lifespan teardown) -> RuntimeError: Attempted to exit cancel scope in a
different task than it was entered in.
Refactor McpClient to own a single long-running runner task that holds the
AsyncExitStack and performs ALL transport enter/exit + list_tools/call_tool.
The public async API (connect/disconnect/list_tools/call_tool/mark_disconnected)
just enqueues a _Cmd and awaits a Future, so callers from any task no longer
cross cancel-scope boundaries. connected/instructions mirror from the runner
onto the instance to stay sync-readable. disconnect() enqueues a stop command
and awaits shield(runner) so teardown isn't interrupted by lifespan cancel.
Also call mcp_manager.stop_health_check() BEFORE disconnect_all() in
AppContainer.shutdown() so the health-check task cannot enqueue onto a
client whose runner is being torn down. mark_disconnected() is now async
(queued) and its manager caller updated.
Regression test: connect in one task, list_tools in a second, disconnect in
a third — the exact scenario that raised the RuntimeError before.
Eugene Sukhodolskiy
committed
on 13 Jul
|

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
on 13 Jul
|
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
on 13 Jul
|
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
on 13 Jul
|
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
on 13 Jul
|
compressor: target hysteresis — shrink to 65% after compression, not just below the trigger
...
Compression triggered at 90% but left the context just under the trigger, so a
fixed keep_recent (navi_code: 12 turns ≈ 104 messages) re-triggered a couple
of messages later — the context yo-yoed at the trigger line instead of gaining
headroom. Add context_compression_target (0.65): in turn-based (preturn) mode
compress_context shrinks keep_recent until the verbatim kept region fits 65% of
the window, folding the extra turns into the same single summary LLM call; a
safety net in compress_session token-budget-truncates if the kept region alone
still exceeds the target (midturn kept a huge in-flight turn, or the
keep_recent floor can't fit). Trigger 90% → target 65% leaves real headroom.
Eugene Sukhodolskiy
committed
on 13 Jul
|
filesystem: strip trailing newline in _number_diff so diff lines aren't blank-separated
...
_unified_diff (used by edit_lines / smart_edit) feeds difflib with
"line + \n" so difflib does not emit a "\ No newline at end of file" marker.
With lineterm="" difflib keeps that trailing "\n" on content lines, so the
final "\n".join produced "\n\n" between every diff line — a blank line
between each, bloating the model-facing output and rendering with double
spacing in both clients. Strip the trailing "\n" in _number_diff before
processing; no-op for the _diff action, whose lines come from splitlines()
without trailing newlines.
Eugene Sukhodolskiy
committed
on 13 Jul
|
renderers: colour diff line numbers + read line numbers, keep content neutral
...
Standard for diff and read output: the line number (and the +/- marker for
diffs) is the coloured anchor; the content itself reads plainly.
diff: "{num} {marker} {content}" with num+marker in the marker colour
(green/red), content neutral. Fixed the number-column regex to "^( +)(\d+)|"
so files with more than 9 lines (width > 1) still match — the old
single-space pattern silently fell back to whole-line colouring.
read: "{num}: {content}" with the number in the accent colour, content
neutral (was dim number).
Applied to both clients: the TUI diff/filesystem renderers and the webclient
ToolCard (renderDiff / renderRead). The model-facing text from the server is
unchanged — this is display-only.
Eugene Sukhodolskiy
committed
on 13 Jul
|
tui: sort milestone groups by status rank so finished ones sink
...
Todo widget already sorted steps within a milestone by status, but the
milestone groups themselves stayed in plan order — a done milestone sat
mid-list while its steps were pushed down. Group milestones by their
worst step status (in_progress < pending < failed/skipped/done) with a
stable sort that preserves plan order within equal rank; the unnamed
(legacy, no-milestone) group participates in the same ranking.
Eugene Sukhodolskiy
committed
on 13 Jul
|

Автономность мелких моделей: OUTPUT DISCIPLINE, milestone-todo, перехват финала хода
...
Два последовательных захода над одной проблемой — мелкие модели (12–30B) в navi_code
теряют автономность на трудных шагах: «остановился поболтать» вместо действия и
слишком большое расстояние между пунктами плана.
Заход 1 (З1–З3):
- З1: OUTPUT DISCIPLINE в системном промпте navi_code — «act, don't announce»,
без few-shot антипримеров. Контракт хода: ответ без tool_calls = конец хода, поэтому
объявление намерения текстом убивает автономность; правила заставляют вызывать
инструмент в том же ходе.
- З2: плоский todo + метка группы milestone + декомпозиция. _parse_plan_steps
возвращает list[tuple[milestone, text]]; milestone — метка группировки (не сущность,
без статуса), «done» вычисляется при рендеринге; подшаги = больше плоских шагов
(без вложенности). TUI side-panel группирует по milestone (плоский фолбэк при пустом
milestone). Plan depth: max 15→20 + правило декомпозиции.
- З3: adaptive re-plan «длинный шаг» — nudge «разбей шаг» при in_progress ≥ порога
итераций без смены todo (порог 4, раньше общего anti-stall warning на 8).
Заход 2 (шаги 1–3, после cloud-теста 31b vs 12b):
Корневая структурная причина: весь спасательный механизм (anti-stall warning с явным
предложением reflect, adaptive re-plan) живёт только внутри tool-цикла — nudge
инжектируется в pre_turn *следующей* итерации, которой при «остановился поболтать»
нет (ход закрылся по return до post_turn). 31b застревала через «продолжаю
tool-итерации» → дожала до warning → спаслась; 12b — через «замолчала текстом» → мимо
всех nudge.
- Шаг 1: перехват финала хода. Если модель выдала bare-text, но в todo есть открытые
шаги (pending/in_progress) и лимит не исчерпан — НЕ эмитить StreamEnd, а сохранить
ассистентский текст в session.context, поставить системный nudge и continue
(без StreamEnd, без workers — консистентно с multi-iteration tool-турами). Счётчик
final_interceptions на AgentTurnContext, лимит final_intercept_limit (default 2),
эскалация жёсткости (мягкий → «second stop»). has_open_steps в todo.py: пустой
todo → False (защита casual-сообщений), failed/skipped терминальны. Профильные
флаги final_intercept_enabled/limit в base.py + loader + admin.
- Шаг 2: жёсткий reflect-триггер в промпте — «~3 tool attempts on the same step
without progress → call reflect IN THIS TURN (tool call, not reasoning aloud)».
- Шаг 3: открыть replan для застревания — «call replan when reflect showed the whole
approach is dead (not one failed step, but the approach itself)».
Тесты: 874 passed, 1 skipped. Новые — has_open_steps (5), final intercept (5),
milestone-группировка, adaptive long-step nudge, парсер шагов с milestone-маркером.
cloud: navi_code model → gemma4:31b-cloud для тестирования догадки (31b признала
застревание, 12b — нет); .env cloud-host уже gitignored.
Co-Authored-By: Claude <noreply@anthropic.com>
Eugene Sukhodolskiy
committed
on 13 Jul
|