diff --git a/clients/terminal/cli.py b/clients/terminal/cli.py index df8e975..91bf257 100644 --- a/clients/terminal/cli.py +++ b/clients/terminal/cli.py @@ -297,7 +297,7 @@ try: current = api.get_session(state.get_session_id() or "") click.echo(f"Profile: {current.get('profile_id', 'unknown')}") - click.echo(f"Session: {current['id']}") + click.echo(f"Session: {current.get('session_id', 'unknown')}") except Exception as exc: click.secho(f"Failed to get session: {exc}", fg="red", err=True) return True diff --git a/clients/terminal/tui/chat_model.py b/clients/terminal/tui/chat_model.py index 0d5f48a..b3d66b4 100644 --- a/clients/terminal/tui/chat_model.py +++ b/clients/terminal/tui/chat_model.py @@ -125,7 +125,13 @@ # tools/thinking that follow, where scroll_end hides it. Instead reset # the current-assistant pointer and let the first stream_delta create # the bubble lazily, at the position where text actually arrives. + # Reset the thinking pointer too: if the previous turn broke without a + # ``thinking_end`` (dropped socket, stop without the event), a dangling + # ``_current_thinking`` would make the new turn's first ``thinking_delta`` + # append to the previous turn's reasoning block, gluing two turns' + # reasoning together. self._current_assistant = None + self._current_thinking = None return None if msg_type == "thinking_delta": diff --git a/clients/terminal/tui/commands/builtin.py b/clients/terminal/tui/commands/builtin.py index d2ff530..388cc9d 100644 --- a/clients/terminal/tui/commands/builtin.py +++ b/clients/terminal/tui/commands/builtin.py @@ -26,7 +26,11 @@ from clients.terminal.tui.commands.registry import get_registry registry = get_registry() - lines = ["[b]Slash commands[/b]"] + # Plain section headers (no rich markup): this content is delivered as a + # ``status`` event, and ``StatusRenderer`` builds a literal ``Text(...)`` + # which does not parse markup — ``[b]…[/b]`` would show as literal square + # brackets instead of bold. + lines = ["Slash commands"] for cmd in registry.all(): aliases = f" ({', '.join(cmd.meta.aliases)})" if cmd.meta.aliases else "" key = f" [{cmd.meta.keybind}]" if cmd.meta.keybind else "" @@ -34,7 +38,7 @@ # Keys (not slash commands) so they're discoverable from /help too. lines.append("") - lines.append("[b]Keys[/b]") + lines.append("Keys") lines.append(" Ctrl+P — command palette") lines.append(" Ctrl+R — reading mode (toggle)") lines.append(" Ctrl+X Q — quit") @@ -45,7 +49,7 @@ # Copying text out of the TUI — the non-obvious bit worth surfacing. lines.append("") - lines.append("[b]Copying text[/b]") + lines.append("Copying text") lines.append( " Drag (no Shift) + Ctrl+C — copy a message/output (clean, no borders)." ) diff --git a/clients/terminal/tui/renderers/__init__.py b/clients/terminal/tui/renderers/__init__.py index 871d812..be43074 100644 --- a/clients/terminal/tui/renderers/__init__.py +++ b/clients/terminal/tui/renderers/__init__.py @@ -4,7 +4,7 @@ from .base import ContentRenderer from .registry import RendererRegistry -from . import message, tool, thinking, error, markdown_content, plain, diff, artifact, status, planning, subagent, todo, turn_meta, summary, filesystem, recall +from . import message, tool, thinking, error, markdown_content, plain, diff, status, planning, subagent, todo, turn_meta, summary, filesystem, recall def default_registry() -> RendererRegistry: @@ -34,7 +34,6 @@ reg.register(recall.RecallRenderer()) reg.register(markdown_content.MarkdownRenderer()) reg.register(diff.DiffRenderer()) - reg.register(artifact.ArtifactRenderer()) reg.register(plain.PlainRenderer()) return reg diff --git a/clients/terminal/tui/renderers/artifact.py b/clients/terminal/tui/renderers/artifact.py deleted file mode 100644 index eaaee07..0000000 --- a/clients/terminal/tui/renderers/artifact.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Renderer for code artifact messages.""" - -from __future__ import annotations - -from rich.console import RenderableType -from rich.panel import Panel -from rich.text import Text - -from clients.terminal.tui.themes import get_active_theme - -from .base import ContentRenderer -from .language import guess_language -from .syntax import highlight_code - - -class ArtifactRenderer(ContentRenderer): - """Render a file-like artifact with syntax highlighting.""" - - def accepts(self, msg: dict) -> bool: - return msg.get("type") == "artifact" - - def render(self, msg: dict) -> RenderableType: - theme = get_active_theme() - path = msg.get("path", "artifact") - language = msg.get("language") or guess_language(path) - content = msg.get("content", "") - - if not content.strip(): - return Panel( - Text("(empty artifact)", style=theme.text_dim.hex), - title=path, - title_align="left", - border_style=theme.border.hex, - ) - - syntax = highlight_code( - content, - language, - theme=theme, - line_numbers=True, - word_wrap=True, - ) - return Panel( - syntax, - title=f"{path} [{language}]", - title_align="left", - border_style=theme.tool_border.hex, - ) diff --git a/clients/terminal/tui/renderers/todo.py b/clients/terminal/tui/renderers/todo.py index fdd3105..65ff257 100644 --- a/clients/terminal/tui/renderers/todo.py +++ b/clients/terminal/tui/renderers/todo.py @@ -108,7 +108,10 @@ if op == "update": status = args.get("status", "") - validation = (args.get("validation") or "").strip() + # ``validation`` arrives from the LLM as arbitrary JSON; coerce to + # string before ``.strip()`` so a non-string value (dict/list) does + # not raise ``AttributeError``. + validation = str(args.get("validation") or "").strip() # The step text comes from ToolStarted.metadata (enriched on the # backend by loading the plan before the tool runs — the LLM's # update args carry only the index). Absent on history replay. diff --git a/clients/terminal/tui/screens/command_palette.py b/clients/terminal/tui/screens/command_palette.py index 455b004..f815835 100644 --- a/clients/terminal/tui/screens/command_palette.py +++ b/clients/terminal/tui/screens/command_palette.py @@ -175,7 +175,11 @@ def on_list_view_selected(self, event: ListView.Selected) -> None: index = self._list_items.index(event.item) if event.item in self._list_items else None - if index is not None: + # Guard the empty-filter case: when nothing matches, the list holds a + # single "No matching commands" placeholder that is not in ``_filtered``. + # Selecting it would index ``_filtered[index]`` → IndexError. Treat any + # out-of-range selection (placeholder, stale highlight) as a no-op cancel. + if index is not None and 0 <= index < len(self._filtered): self.dismiss(self._filtered[index]) def on_key(self, event: events.Key) -> None: diff --git a/clients/terminal/tui/tui_app.py b/clients/terminal/tui/tui_app.py index c9cbc27..1f8860c 100644 --- a/clients/terminal/tui/tui_app.py +++ b/clients/terminal/tui/tui_app.py @@ -273,7 +273,12 @@ for err in resolved.errors: self._chat_panel.handle_ws_event({"type": "error", "message": err}) - if self._bridge and self._bridge.connected: + # Always enqueue when a bridge exists: the WsBridge input loop buffers + # the message in its queue and waits on ``_connected_event`` before + # sending, so a submit during a brief reconnect (``connected`` is False) + # is delivered after the socket comes back — not dropped. Only show the + # "not connected" error when there is no bridge at all. + if self._bridge: self._bridge.client.enqueue(resolved.to_message()) else: self._chat_panel.handle_ws_event( diff --git a/clients/terminal/tui/widgets/todo_list.py b/clients/terminal/tui/widgets/todo_list.py index 36394c3..b66f79e 100644 --- a/clients/terminal/tui/widgets/todo_list.py +++ b/clients/terminal/tui/widgets/todo_list.py @@ -17,6 +17,8 @@ from __future__ import annotations +from rich.markup import escape + from textual.app import ComposeResult from textual.containers import ScrollableContainer from textual.widgets import Static @@ -145,7 +147,11 @@ status = t.get("status", "") or "pending" icon = _STATUS_ICON.get(status, "?") icon_color = getattr(theme, _STATUS_COLOR_ATTR.get(status, "text_dim"), theme.text_dim).hex - text = t.get("text", "") + # Escape user/LLM-supplied text before interpolating into a rich markup + # string — a task text like "fix [bug]" would otherwise be parsed as a + # markup tag and silently break the line's styling (or drop text). + text = escape(str(t.get("text", ""))) + index_str = escape(str(t.get("index", ""))) # Line emphasis: the active step is bold + bright; completed/skipped # are dimmed; failed pops in the warning colour; pending is muted. if status == "in_progress": @@ -157,8 +163,9 @@ else: text_style = theme.text_muted.hex suffix = f" [{dim}]({status})[/]" if status not in ("pending", "done") else "" - note = f" [{dim}]✓ {t['validation']}[/]" if t.get("validation") else "" - return f" [{icon_color}]{icon}[/] [{text_style}]{t.get('index', '')}. {text}[/]{suffix}{note}" + validation = t.get("validation") + note = f" [{dim}]✓ {escape(str(validation))}[/]" if validation else "" + return f" [{icon_color}]{icon}[/] [{text_style}]{index_str}. {text}[/]{suffix}{note}" def _build_content(self) -> str: theme = get_active_theme() @@ -187,7 +194,7 @@ t.get("status") in ("done", "skipped") for t in group_tasks ) check = f" [{theme.success.hex}]✓[/]" if group_done else "" - lines.append(f"[{dim}]Milestone {ms}{check}[/]") + lines.append(f"[{dim}]Milestone {escape(str(ms))}{check}[/]") for t in self._ordered_within(group_tasks): lines.append(self._render_task_line(t, theme, dim)) return "\n".join(lines) diff --git a/docs/code_review_tui.md b/docs/code_review_tui.md new file mode 100644 index 0000000..9cc2b07 --- /dev/null +++ b/docs/code_review_tui.md @@ -0,0 +1,852 @@ +# Code review: Navi Code TUI (и terminal-клиент) + +Дата: 2026-07-13. Scope: весь terminal-клиент — `clients/terminal/tui/` (48 файлов, ~6144 LOC) + +классический CLI `clients/terminal/*.py` + тесты `tests/clients/`. + +Процесс: поэтапный ревью по слоям, от фундамента к UI. После всех этапов — приоритизация и список +задач в работу (этап 7). + +Категории находок (по приоритету пользователя): **B** — баги/корректность, **P** — перформанс, +**M** — maintainability/архитектура. Безопасность отмечается по факту, но не приоритет. + +Каждая находка имеет стабильный id (`<этап>.<буква><номер>`) для ссылок в итоговом списке задач. + +--- + +## Этап 1 — Транспорт и модель данных (TUI) + +Файлы: `ws_bridge.py` (206), `chat_model.py` (280), `events.py` (39), `context.py` (37) + контракт +`ws_client.py` (125). + +### Баги / корректность + +**1.B1 — `chat_model.stream_start` не сбрасывает `_current_thinking`.** +`chat_model.py:128` сбрасывает только `_current_assistant`. Если предыдущий ход оборвался без +`thinking_end` (дроп сокета, ручной stop без события), `_current_thinking` остаётся «висящим». +Следующий `stream_start` → первый `thinking_delta` нового хода **допишет в блок мышления прошлого +хода**, склеивая reasoning двух разных ходов в один блок. `stream_end` purge удалит только пустые, а +этот уже непустой — выживет. Исправление: в `stream_start` сбрасывать оба указателя. + +**1.B2 — `load_history` хардкодит `success: True` для всех `role=="tool"`.** +`chat_model.py:113`. В persisted `Message` (`navi/llm/base.py`) поля `success` нет — только `content`, +`metadata`, `tool_call_id`, `name`. Live-путь получает `success` из WS-события `tool_call`; при resume +данные утеряны. Следствие: сессия с упавшим инструментом при возобновлении показывает его успешным +(нет красной окраски/иконки у tool-карточки). Root cause — в схеме persistence (backend), проявление +в TUI. Детально разбирается в Этапе 2 (renderer `tool.py` читает `success`). + +**1.B3 — `WsBridge` переупорядочивает очередь ввода при дропе сокета.** +`ws_bridge.py:179-188`. При ошибке `send` сообщение возвращается `queue.put_nowait(content)` (в +конец), а новые сообщения пользователя, отправленные во время reconnect, лежат в очереди **впереди** +него. Итог: «послал A → дроп → послал B» может превратиться в «B, A» после переподключения. Не +теряется, но порядок нарушается. Минор для чата. + +**1.B4 — `WsBridge.start()` не защищён от повторного вызова.** +`ws_bridge.py:65-72`. Повторный `start()` без `stop()` перезаписывает `_supervise_task`/`_input_task` +новыми тасками, а старые продолжают работать — **утечка тасок и два параллельных receive-loop-а**, +оба постят `WsEvent` в app. Зависит от того, гарантирует ли app «start ровно один раз». Проверяется в +Этапе 5 (`tui_app.py`). + +### Перформанс + +**1.P1 — `stream_end` purge делает full-copy `self.items`.** +`chat_model.py:235-241`. List comprehension по всем items хода. На длинной сессии (тысячи items) +каждый `stream_end` — O(n) копия; ходы частые → кумулятивно O(n²) за сессию. Дешёвое исправление: +purgeать только хвост (пока последние элементы — пустые assistant/thinking), или собирать индекс и +`del`-slice. Значимо для длинных автономных сессий. + +**1.P2 — неограниченный рост очереди ввода при долгом offline.** +`ws_client._input_queue` безразмерная; `ws_bridge.py:183` requeue + пользовательский ввод копятся без +предела во время длительного disconnect. Минор. + +### Maintainability / архитектура + +**1.M1 — `WsBridge` лезет в приватные атрибуты клиента.** +`ws_bridge.py:147` (`self._client._ws`), `:167` (`self._client._input_queue`). `ws_client.py` имеет +public `enqueue`/`send`/`close`/`stop_input`, но не даёт «receive-итератор» или «wait_connected» — +bridge вынужден обходить инкапсуляцию. Корень: `NaviWebSocketClient` смешивает транспорт и +CLI-рендеринг (`receive_loop` рендерит в `Renderer`). TUI нужен транспорт без рендерера. Стоит вынести +чистый транспорт или добавить public-хуки (`recv()`-итератор, `wait_connected()`). + +**1.M2 — `WsBridge.stop()` глотает все исключения без логирования.** +`ws_bridge.py:91, 97, 132, 142, 187`. `except (asyncio.CancelledError, Exception): pass` и +`except Exception: pass` ×4. При shutdown уместно (idempotent), но `CancelledError` в tuple избыточен +(не наследник `Exception`), и тишина прячет реальные баги close-логики. Хотя бы `logger.debug(…, +exc_info=True)`. + +**1.M3 — unknown WS-event → `status` с дампом всего `msg`.** +`chat_model.py:278`: `f"{msg_type}: {msg}"`. Новое событие с тяжёлым payload → весь дамп в чат. Лучше +`f"unhandled event: {msg_type}"` (без payload). + +**1.M4 — `TuiContext.app()` бросает `LookupError` вне app-loop.** +`context.py:32-37`. `textual_app.active_app.get()` падает без вразумительной ошибки, если команда +выполнится без активного app (edge, тесты). Стоит `try/except LookupError: return None` или дефолт. + +**1.M5 — `_backoff` не сбрасывает `_attempt` между `stop`/`start`.** +`ws_bridge.py:190-193`. Сбрасывается только при успешном connect (`:126`). Если bridge остановлен и +запущен заново после долгого offline, первая задержка будет большой. Минор, но surprising. + +### Не-находки (проверено, ок) +- `_input_loop` requeue не теряет сообщения (1.B3 — про порядок, не потерю). +- `async for raw in ws` + `json.loads` fallback на `JSONDecodeError` — корректен. +- `events.py` — чистые Message-классы, замечаний нет. +- Экспоненциальный backoff с потолком 30с — корректен. + +**Сводка этапа 1:** 4 бага корректности (1.B1 — реальный, 1.B2 — архитектурный root cause, 1.B3/B4 — +минор/зависит от app), 2 перф-замечания (1.P1 значимо для длинных сессий), 5 maintainability. +Критичных блокеров нет — транспорт в целом здравый. + +--- + +## Этап 2 — Renderers (ядро визуализации) + +Файлы: `renderers/*` (16 файлов, ~1100 LOC) — registry, base, syntax, language, filesystem, diff, +artifact, markdown_content, todo, tool, message, planning, subagent, recall, summary, thinking, +turn_meta, status, plain, error. + +Подсветка (`syntax.py` `highlight_code`, `language.py` `guess_language`) — уже ревьювалась при +внедрении (см. `reference-tui-code-highlighting`), замечаний нет. + +### Баги / корректность + +**2.B1 — `ArtifactRenderer` — мёртвый код.** +`renderers/artifact.py` зарегистрирован (`__init__.py:37`), `accepts` проверяет `type == "artifact"`. +Но `ChatModel` никогда не создаёт `kind="artifact"` (его нет в `ChatItem.kind` literal, +`chat_model.py:16-29`), и ни `handle_ws_event`, ни `load_history` не эмитят такой type — WS-событие +`artifact` отсутствует в коде (`grep` по всему репо: только `artifact.py` его упоминает). Любой +msg с `type=artifact` в принципе не доходит до registry. Renderer никогда не вызывается. +Подтверждено поиском. Удалить или подключить (если артефакты задуманы как отдельное событие — +оформить WS-событие + ChatItem kind). + +**2.B2 — `grep` подсветка расходится с реальным поиском, когда `regex=True`.** +`renderers/filesystem.py:357-394` (`_render_grep`) всегда строит `highlight = re.compile(re.escape(pattern), +IGNORECASE)` — literal case-insensitive. Но если `args.regex=True`, реальный `grep` на бэкенде искал по +**регулярке**, а TUI подсветит **literal** pattern — совпадения подсветки не совпадут с тем, что нашёл +сервер (regex `\d+` подсветит литерал «\d+», которого в тексте нет → не подсветит ничего, хотя строки +вышли по regex). Визуальное несоответствие. Исправление: если `args.regex` — компилировать pattern как +regex (без escape) для подсветки; остерегаясь патологических regex, в try/except. + +**2.B3 — `todo` update падает, если `validation` — не строка.** +`renderers/todo.py:111` `(args.get("validation") or "").strip()`. `args` приходит из LLM как +произвольный JSON; если модель вернёт `validation` как dict/list (правда,unlikely, но контракт не +гарантирован) — `dict.strip()` → `AttributeError`. Минор robustness; исправление — +`str(args.get("validation") or "").strip()` или isinstance-проверка. + +### Перформанс + +**2.P1 — `AssistantMessageRenderer` парсит Markdown на каждый stream-delta → O(n²) по ответу.** +`renderers/message.py:52-66` создаёт `Markdown(text)` + `ThemedMarkdownRenderable` на каждый `render`. +Из docstring: «Re-rendered on every stream delta so the formatting appears live as Navi streams». +Каждый delta (десятки–сотни в ответе) → полный markdown-парс растущего текста + создание `Console` +внутри `ThemedMarkdownRenderable.__rich_console__` + `list(console.render(md))` (materialize всех +segments). Для длинного ответа ассистента (N delta) — суммарно O(N²) работы и O(N) памяти на delta. +Ключевая перфоманс-находка TUI. Варианты: троттлить ре-рендер (не чаще чем раз в X ms / по coalescing), +или рендерить сырой `Text` во время стрима и `Markdown` — только на `stream_end`. Зависит от того, как +`chat_panel` вызывает render (Этап 3) — если ре-рендерит весь список на каждый delta, проблема +усугубляется. + +**2.P2 — `ThemedMarkdownRenderable` материализует все segments в список.** +`renderers/markdown_content.py:50` `segments = list(themed_console.render(self._markdown))` — полный +materialize вместо streaming yield, плюс второй проход для link-rewrite (52-58). На каждом ре-рендере +(scroll, resize, reading-mode toggle) — O(n) память + двойной обход. Применяется ко всем markdown-путям +(assistant, plan_ready, context_summary). Можно yield-ить напрямую, переиспользуя console, без +промежуточного list (link-rewrite делать inline в цикле). + +**2.P3 — generic `ToolResultRenderer`/`PlainRenderer` не трункают большой result.** +`renderers/tool.py:70` `Text(str(result))`, `renderers/plain.py:22` `Text(text or str(msg))`. +`filesystem` перехватывает свои большие выводы раньше, но любой **non-filesystem** tool с громоздким +output (`bash` с длинным stdout, и т.п.) попадает в generic и рендерится целиком в один `Text` без +ограничения. Минор (зависит от того, какие инструменты есть), но потенциально тяжёлый bubble. + +### Maintainability / архитектура + +**2.M1 — жёсткая привязка `TodoResultRenderer` к строке backend "Plan — X/N done:".** +`renderers/todo.py:199` `first.startswith("Plan —") and "done" in first` (em-dash). Цветовая разбивка +`2/5 done` (success/dim) работает только для этого точного формата `todo._render`. Если backend +изменит формулировку — тихо свалится в fallback `_truncate(first, 80)` без цветовой разбивки. +Контракт UI↔backend не формализован (строка как контракт). Стоит либо документировать, либо +передавать структурированный `{done, total}` в meta. + +**2.M2 — fallback-рендер сыплет raw dict в чат.** +`renderers/registry.py:26` `return str(msg)` (никто не accepts) и `status`/`plain`: +`msg.get("content","") or str(msg)`. Непокрытый type → Python dict repr в чат (шум + потенциальная +утечка данных payload). Параллель 1.M3. Стоит统一 в «unknown event: » без payload. + +**2.M3 — `_DIFF_NUM_RE` избыточный `re.DOTALL`.** +`renderers/diff.py:23` `re.compile(r"^( +)(\d+)│ ?(.*)$", re.DOTALL)`. `text.splitlines()` уже разбил +строки, `.` внутри одной строки newline не встретит — `DOTALL` не влияет. Вводит в заблуждение +(будтоmultiline-матч задуман). Убрать. + +**2.M4 — `recall._format_trigger` наивный ISO-парсинг строкой.** +`renderers/recall.py:34-38` `trigger_at.replace("T"," ").split(".")[0]`. Работает для каноничного ISO +`2026-07-13T14:30:00.123`, но для не-ISO или offset-timezone (`...+03:00`) даёт мусор в UI. Минор; +надёжнее `datetime.fromisoformat` + `strftime`, с fallback на исходную строку. + +**2.M5 — `ArtifactRenderer` мёртвый (см. 2.B1).** Дублирует указание: либо удалить, либо подключить. + +### Не-находки (проверено, ок) +- `filesystem._strip_number_prefix` — корректен на edge cases (пустые строки, content с `: `, строка + без prefix). +- `diff.highlight_unified_diff` — переупорядочивание `{num} {marker}` корректно, `+++`/`---`/`@@` + разбирательство правильно. +- `is_subagent` indent (`Padding(...,(0,0,0,2))`) — консистентен по всем tool-рендерерам. +- `thinking`/`status`/`error`/`turn_meta`/`plain` — простые, чистые. +- `recall._ACTIONS` — аккуратная таблица действия→иконка/цвет. + +**Сводка этапа 2:** 3 бага (2.B1 — мёртвый renderer, 2.B2 — regex-расхождение grep, 2.B3 — minor +robustness), 3 перф-замечания (2.P1 — **ключевая**, markdown-per-delta O(n²); 2.P2 — segment +materialize; 2.P3 — нет truncation), 5 maintainability. Renderers в целом хорошо структурированы; +главная проблема — перформанс живого markdown-рендера (2.P1), решение зависит от `chat_panel` +(Этап 3). + +--- + +## Этап 3 — Widgets (Textual UI) + +Файлы: `widgets/*` (8 файлов, ~1330 LOC) — chat_panel (479), input_box (160), todo_list (232), +status_panel (89), status_bar (131), command_hints (104), elapsed_timer (68), activity_indicator (68). + +`chat_panel` — ключевой для понимания 2.P1: ре-рендер **локализован** в один виджет (`maybe_update` +по signature), но на каждом delta виджет всё равно парсит Markdown и rich-рендерится дважды. + +### Баги / корректность + +**3.B1 — `todo_list` rich-markup injection через данные задачи.** +`widgets/todo_list.py:144-161` (`_render_task_line`) строит rich **markup-строку** через f-string: +`f"[{text_style}]{t.get('index', '')}. {text}[/]"`, плюс `t['validation']`, milestone label. Если +текст задачи содержит квадратные скобки (напр. `"fix [bug] in module"`), rich-парсер интерпретирует +`[bug]` как тег → разорванная/искажённая разметка, возможен тихий сбой строки. Сравнение: +`command_hints.py:91-105` делает **правильно** — `Text.assemble(Text(...), ...)` вставляет данные как +literal (Text не парсит markup). Исправление: либо `from rich.markup import escape` для `text`/ +`validation`/`index`/`milestone`, либо переделать `_build_content` на `Text.assemble` как в +command_hints. + +**3.B2 — `chat_panel._denoise` хрупкий при `│` в контенте.** +`widgets/chat_panel.py:223-259` (`_denoise`) снимает panel-бордеры по `line.find("│")` / +`line.rfind("│")`. Если контент пузыря содержит box-drawing `│` (код с ASCII-таблицами, box-drawing +диаграммы), `rfind("│")` укажет не на правый gutter, а на `│` внутри контента → +`line[content_col:end]` отрежет часть текста или включит gutter в выборку → некорректный copy. Из +памяти — selection в целом протестирован, но этот edge (двойной `│`) не покрыт. Минор-medium; известное +ограничение. + +**3.B3 — `input_box._complete_command` complete-ит к первой команде на пустом `/`.** +`widgets/input_box.py:78-96`: при `text == "/"` (пустое имя) `get_registry().match("")` может вернуть +все команды, `matches[0]` — первая в регистре, и Tab дополнит до неё. Минор edge; поведение +неинтуитивно (пользователь не выбирал). + +### Перформанс + +**3.P1 — `chat_panel`: двойной rich-рендер растущего Markdown на каждый delta (усиливает 2.P1).** +На каждый `stream_delta`: `ChatModel` мутирует `_current_assistant.content +=` (in-place) → +`ChatPanel.handle_ws_event` → `_sync` → `maybe_update` (signature изменилась) → новый +`_rich_renderable` (`registry.render` → `Markdown(text)` парс растущего текста + внутри +`ThemedMarkdownRenderable` свой `Console`) → `refresh(layout=True)`. Затем Textual: +(1) измеряет высоту `get_content_height` → `list(console.render(renderable, options))` (**полный +render #1**), (2) рисует `render()`→`_build_content` → `list(console.render(renderable, options))` +(**полный render #2**) + build Content со Style-конвертацией каждого segment. Итого **два полных +rich-рендера** растущего Markdown на каждый token → кумулятивно **O(N²)** по длине ответа (N delta, +каждый парсит текст длины O(N)). Ключевая перфоманс-находка TUI, корень в связке 2.P1 + 3.P1. Варианты: +троттлить (Markdown раз в X мс / только на `stream_end`, во время стрима — простой `Text`), либо +кэшировать высоту (get_content_height — по signature+width, не пересчитывать если renderable тот же). + +**3.P2 — `chat_panel._sync` — O(visible) обход на каждое WS-событие.** +`widgets/chat_panel.py:423-480`: `handle_ws_event` → `_sync` → `visible_ids = {id(it)...}` (set +comprehension) + `for item in visible` + dict lookups на **каждое** событие, включая каждый delta. +Для `max_visible_items=200` → ~200 итераций на токен. Dict O(1), но константный overhead на каждый +токен стрима. Минор; можно отслеживать только последний изменённый item (model может возвращать +«что изменилось») вместо full walk. + +**3.P3 — `ChatModel.items` не ограничен (memory growth длинных сессий).** +Visible window в TUI ограничен (`max_visible_items`), но `ChatModel.items` растёт без предела — +`stream_end` purge удаляет только пустые assistant/thinking, append- only иначе. Для длинной +автономной сессии (тысячи ходов) `items` копится в памяти целиком, хотя видны последние N. Связано с +1.P1 (purge full-copy по всему списку). Среднее; стоит bounded-хранение (с truncation marker, как в +visible, но на уровне модели) или прокси/оконная модель. + +### Maintainability / архитектура + +**3.M1 — `ChatPanel.clear` лезет в приватные `ChatModel._current_assistant`/`_current_thinking`.** +`widgets/chat_panel.py:398-399`: `self._model._current_assistant = None; +self._model._current_thinking = None`. Внутри same package, но хрупко — смена инкапсуляции ChatModel +ломает clear. Лучше `ChatModel.reset()` (или `clear()` уже есть логика в `load_history` — +переиспользовать). + +**3.M2 — `_item_msg` дублирует знание структуры meta.** +`widgets/chat_panel.py:26-71` — ручная маппинг-функция `ChatItem → dict`, повторяющая для каждого +`kind`, какие поля `meta` несёт. Если renderer начнёт ждать новое поле — править надо в трёх местах +(`ChatModel`, `_item_msg`, renderer). Цена разделения ChatItem/renderers, но точка знания +утяжелена. Минор. + +**3.M3 — `input_box` дублирует `self.text = f"/{cmd.meta.name} "`.** +`widgets/input_box.py:75` (`_submit`) и `:94` (`_complete_command`) — одинаковая конкатенация. Минор; +вынести в хелпер. + +**3.M4 — `ElapsedTimer` вызывает `format_duration(None)` если `stop` без `final_seconds` и без `start`.** +`widgets/elapsed_timer.py:50-59`: `elapsed = final_seconds`; если None и `start_ts is None` → +`format_duration(None)`. Безопасность зависит от `format_duration` (Этап 5). Отметить как зависимость; +стоит guard. + +### Не-находки (проверено, ок) +- `command_hints` — `Text.assemble` (без markup-парсинга) — **референс правильного подхода** для + фикса 3.B1; навигация/`_MAX_HINTS` cap корректны. +- `status_panel`, `status_bar` — простые `Static.update`, чистые; `StatusBar._ctx_fill` обходит + `_context`-pitfall (комментарий-предупреждение на месте). +- `activity_indicator` (10 fps), `elapsed_timer` (1 s) — `set_interval` + `timer.stop()` корректны, + `time.monotonic` (не wall clock) — правильно. +- `_ChatItemView` content-cache по `(id(renderable), width)` — разумный; `get_content_height` отдельной + функцией обоснован (scroll_end fixpoint, подробно в комментарии `:193-211`). +- Selection-aware `Content` + `get_selection` remap — сложный, но из памяти протестирован; основной + остаточный edge — 3.B2. + +**Сводка этапа 3:** 3 бага (3.B1 — **markup injection в todo_list**, средний; 3.B2 — denoise edge, +минор; 3.B3 — input edge, минор), 3 перф-замечания (3.P1 — **ключевая**, двойной rich-рендер на +delta, корень с 2.P1; 3.P2 — O(visible) на delta; 3.P3 — unbounded items), 4 maintainability. +Виджеты в целом хорошо спроектированы (per-item widget, signature-кэш, selection-aware Content); +главная боль — перформанс стриминга markdown (3.P1). + +--- + +## Этап 4 — Screens + Commands + +Файлы: `screens/command_palette.py` (205), `screens/sessions_picker.py` (217), +`screens/theme_picker.py` (184), `commands/base.py` (32), `commands/registry.py` (86), +`commands/builtin.py` (373). + +### Баги / корректность + +**4.B1 — `command_palette.on_list_view_selected` падает при пустом фильтре.** +`screens/command_palette.py:176-179`: +```python +def on_list_view_selected(self, event: ListView.Selected) -> None: + index = self._list_items.index(event.item) if event.item in self._list_items else None + if index is not None: + self.dismiss(self._filtered[index]) # IndexError when _filtered == [] +``` +Когда фильтр пуст, в списке один элемент «No matching commands» (`_render_list:124`). Клик/выбор +его мышью → `index == 0` → `self._filtered[0]` → `IndexError` (app crash). `on_key` enter +(`:191-196`) имеет guard `0 <= highlighted < len(self._filtered)`, а `on_list_view_selected` — нет. +Исправление: `if index is not None and 0 <= index < len(self._filtered)`. + +**4.B2 — `/help` markup не рендерится (видны квадратные скобки).** +`commands/builtin.py:29-33` (`HelpCommand.execute`) формирует content с rich-markup: +`lines = ["[b]Slash commands[/b]", ...]`, и шлёт `{"type": "status", "content": "\n".join(lines)}` +в `ctx.chat_panel.handle_ws_event`. Но `StatusRenderer.render` (`renderers/status.py:22`) делает +`Text(f"• {text}", ...)` — `Text(str)` **не парсит markup** (нужен `Text.from_markup`). Итог: `/help` +показывает `[b]Slash commands[/b]` как literal текст с квадратными скобками вместо жирного +заголовка. Либо `StatusRenderer` должен `Text.from_markup` (с escape-политикой), либо `HelpCommand` +не должен использовать markup для status. Замечание: остальные команды шлют plain text без markup — +баг локализован в `/help`. + +### Перформанс + +**4.P1 — `sessions_picker.on_mount` делает блокирующий HTTP-вызов в event loop.** +`screens/sessions_picker.py:105-113`: `raw = api.list_sessions()` — синхронный `requests`-вызов прямо +в `on_mount` модального экрана. Текстуальный event loop блокируется на время запроса; при +медленном/недоступном бэкенде TUI висит (спиннер не крутится, ввод не отвечает). Стоит вынести в +`run_worker` / async-клиент. Минор-medium (вызов обычно быстрый, но при сетевой проблеме — заметный +freeze). + +**4.P2 — `theme_picker` live-preview дёргает `apply_theme` на каждое перемещение курсора.** +`screens/theme_picker.py:157-160` (`on_list_view_highlighted`) → `_preview_theme` → `app.apply_theme()` +(полный re-render всех виджетов + перерегистрация Textual-темы) на **каждое** движение подсветки +в списке тем. Для 2 тем некритично, но `apply_theme` тяжёлый; при удержании стрелки — серия полных +re-layout. Минор; можно debounce или применять только на enter. + +### Maintainability / архитектура + +**4.M1 — builtin-команды делают блокирующий sync I/O в `async execute`.** +`commands/builtin.py`: `SwitchCommand._resolve_session` (`api.get_session`/`api.list_sessions`, +`:131-150`), `ProfileCommand.execute` (`api.get_session`, `:167`), `ExportCommand.execute` +(`api.get_session` + `file_path.write_text`, `:302/317`). Все `api.*` — синхронные `requests`; вызовы +идут из `async def execute`, но **без `await` в потоке** → блокируют Textual event loop на время +каждого сетевого/дискового I/O. `SessionsCommand` делегирует экрану (тот же issue — 4.P1). Стоит +либо async-клиент, либо `await app.run_in_thread(api.get_session, ...)`. Минор-medium. + +**4.M2 — `QuitCommand.execute` без guard на `ctx.app()` returning None.** +`commands/builtin.py:189-191`: `app = ctx.app(); app.exit()`. `TuiContext.app()` сегодня бросает +`LookupError` вне app-loop (см. 1.M4); если 1.M4 пофиксить на `return None`, `QuitCommand` сломается +здесь. Связанная находка — править согласованно. + +**4.M3 — `registry.match("")` возвращает все команды.** +`commands/registry.py:45-46`: `if not prefix: return self.all()`. Используется `input_box` на голом +`/` → автодополнение к первой команде (см. 3.B3). Documented, но surprising; стоит решение: либо +не автодополнять на пустом имени, либо отдельный метод «suggest». + +### Не-находки (проверено, ок) +- `command_palette._render_rich_line` и `command_hints._build_content` — `Text.assemble` (literal, + безопасно от markup-injection) — референс правильного подхода. +- `registry.match` — корректный rank-сорт (exact → prefix), stable; singleton lazy. +- `theme_picker._restore_original` на escape — восстанавливает тему; `sessions_picker` sentinel + `__new__` — чистая схема. +- `ExportCommand` — detached `subprocess.Popen` (не блокирует TUI), fallback `EDITOR`→`vi`/`notepad`. +- `base.CommandMeta`/`BaseCommand` — чистый ABC. + +**Сводка этапа 4:** 2 бага (4.B1 — crash edge в palette; 4.B2 — `/help` markup виден literally), +2 перф-замечания (4.P1 — блокирующий I/O в on_mount; 4.P2 — apply_theme на highlight), 3 +maintainability. Screens/commands — самый лёгкий слой, основная тема — блокирующий sync I/O в +async-контексте (4.P1/4.M1). + +--- + +## Этап 5 — App-оркестрация + подсистемы + +Файлы: `tui_app.py` (522), `settings.py` (146), `file_refs.py` (317), `shell_runner.py` (84), +`duration.py` (22). (`themes.py` ревьювался в Этапе 2 — единая точка `code_theme` ok.) + +### Баги / корректность + +**5.B1 — chat-пузыри не пере-красятся при смене темы.** +`tui_app.py:140-148` (`apply_theme`) обновляет `set_active_theme` + status_panel + todo_panel, но **не +инвалидирует `ChatPanel` items**. `_ChatItemView._content_cache` кэширует `Content` по +`(id(renderable), width)` — renderable при theme switch **не пересоздаётся** (signature/content +те же) → cache hit → старые segments со старыми цветами. Итог: live-preview тем (и `/themes`, +`Ctrl+X T`) меняет панель/боковую колонку, но уже отрисованные сообщения остаются в прежней палитре +до тех пор, пока их не перерендерит другое событие. Сравнение: `set_reading_mode` +(`tui_app.py:506-513`) явно ре-рендерит items (`chat_panel.set_reading_mode` → сбрасывает +`_rich_renderable` + `_content_cache`). `apply_theme` нужно то же самое: метод +`ChatPanel.refresh_content()` + вызов из `apply_theme`. Medium; заметный визуальный баг. + +**5.B2 — `on_user_submitted` теряет сообщение во время reconnect.** +`tui_app.py:276-281`: `if self._bridge and self._bridge.connected: enqueue(...)` — иначе error +«Not connected to a session». Но `WsBridge._input_loop` переживает disconnect: кладёт в +`_input_queue`, ждёт `_connected_event`, а при ошибке send — requeue (`ws_bridge.py:179-188`). Т.е. +сообщение, поставленное в очередь во время reconnecting, было бы доставлено после переподключения. +Guard по `connected` вместо всегда-enqueue **отбрасывает** ввод при кратковременном дропе (sync +status panel «reconnecting…», пользователь шлёт сообщение → ошибка, сообщение потеряно). Исправление: +`if self._bridge: enqueue(...)` (queue — правильный буфер); error только если `self._bridge is None`. +Связано с 1.B3 (переупорядочивание очереди). Medium. + +**5.B3 — `tui_app`/`sessions_picker` игнорируют `session["session_id"]` KeyError.** +`tui_app.py:172, 184, 196` — `session["session_id"]` без guard: если `api.get_session`/`create_session` +вернёт некорректный dict (некорректный backend, proxy, частичный ответ), `KeyError` проскочит мимо +`except Exception` (тот ловит только ошибку `api.*`, а не доступ к ключу). Минор robustness; `dict.get` ++ проверка. + +### Перформанс + +**5.P1 — `file_refs._collect_files` материализует всю директорию через `sorted`.** +`file_refs.py:274-279`: `_collect_files` делает `sorted(iterator)` (полный список всех entries) **до** +фильтра sensitive-dirs. Для большой директории (`@node_modules/`-стиль, или просто глубокий проект) +— тысячи entries сортируются в памяти целиком, хотя большинство отфильтруется +`_is_inside_sensitive_dir` после. Лучше фильтровать в итераторе до sort (или sort лениво по +генератору). Минор-medium; проявляется на `@big-dir/`. + +### Maintainability / архитектура + +**5.M1 — системный: блокирующий sync `api.*` по всему app в Textual worker (event loop).** +`tui_app.py`: `_resolve_session` (`api.get_session`/`create_session`, `:170-197`), +`attach_session` (`api.get_session`/`get_profile_model`/`get_todos`, `:204-230`), +`_create_new_session_worker` (`api.create_session`, `:310`), `_stop_stream_worker` +(`api.stop_session`, `:475`). Все `api.*` — синхронные `requests`; вызываются из `run_worker(...)` +(Textual worker живёт в том же event loop, **не** в отдельном потоке) → каждый сетевой вызов +**блокирует** TUI: спиннер не крутится, ввод не реагирует, freeze на время запроса. Обобщение 4.P1/4.M1 +на весь app. Решение: async-клиент (`httpx.AsyncClient`) или `await +app.run_in_thread(api.get_session, ...)`. Medium — самая «системная» находка ревью. + +**5.M2 — `action_stop_stream`mixes sync/async `api.stop_session`.** +`tui_app.py:473-481`: `result = api.stop_session(session_id); if asyncio.iscoroutine(result): await +result`. API-клиент с непоследовательным контрактом (иногда sync, иногда coroutine) — code smell; +контракт должен быть однозначным. Минор. + +**5.M3 — `tui_app.on_unmount` + `action_quit` double-stop `WsBridge`.** +`tui_app.py:515-522`: `action_quit` → `bridge.stop()` → `self.exit()` → `on_unmount` → `bridge.stop()` +снова. `WsBridge.stop` достаточно idempotent (см. 1.M2), но второй `stop_input()` кладёт `None`-sentinel +в очередь повторно. Безопасно, но избыточно; стоило `if self._bridge is not None: …; self._bridge = +None`. Минор. + +### Подтверждения/корректировки предыдущих этапов + +- **1.B4 (повторный `WsBridge.start`) — mitigated на уровне app.** `attach_session` + (`tui_app.py:246-249`) всегда `await self._bridge.stop()` перед новым `WsBridge(...).start()`. Прямой + двойной `start()` невозможен через app. Оставить как внутренний robustness-комментарий в `WsBridge` + (guard в `start`), но снизить приоритет с «баг» до «defensive». +- **3.M4 (ElapsedTimer `format_duration(None)`) — снято.** `duration.py:16-17` `format_duration(None)` + → `"—"` безопасно. Non-issue. +- **5.B1 подтверждает**, что `apply_theme` — точка, где theme-switch должен инвалидировать chat + items (связка с 3.P1: кэш `_content_cache`). + +### Не-находки (проверено, ок) +- `settings.py` — robust coercion (`_coerce_bool`/`_coerce_int`/`_coerce_int_bounded` с min), + корректный merge defaults, safe reload. Чисто. +- `shell_runner.py` — `shell=True` оправдано (user-typed `!cmd`), timeout 30s, `_truncate` 200 lines, + `subprocess.TimeoutExpired`→124, `OSError`→1. Чисто для своего назначения. +- `duration.py` — `format_duration` обрабатывает `None`, rounding корректен. Чисто. +- `file_refs` — path-restriction (`_is_under_root` после `resolve`), sensitive blocklist (`.env`/ + `.ssh`/`id_rsa`/`.pem`/`node_modules`/…), binary-skip (`\x00`), size limits. Хорошая защита для + attach-пути. +- `tui_app.on_ws_event` dispatch — корректное разделение status-событий (`model_info`/`todo_updated`/ + compression `return`) и chat-forward; `_streaming` guard осмысленный. + +**Сводка этапа 5:** 3 бага (5.B1 — **theme switch не красит chat**, medium; 5.B2 — **потеря сообщения +при reconnect**, medium; 5.B3 — KeyError edge, минор), 1 перф-замечание (5.P1 — `_collect_files` +sort), 3 maintainability (5.M1 — **системный блокирующий I/O**, medium; 5.M2/M3 — минор). Главная +системная находка — 5.M1 (sync `api.*` блокирует event loop по всему app). + +--- + +## Этап 6 — Классический CLI-клиент + +Файлы: `clients/terminal/api.py`, `cli.py`, `config.py`, `render.py`, `state.py`, `ws_client.py`, +`__main__.py` (вне `tui/`). Это альтернативный не-Textual клиент (`--raw` или one-shot PROMPT). + +### Баги / корректность + +**6.B1 — raw CLI `/profile` падает на `current['id']`.** +`cli.py:296-303` (`_handle_command` `/profile`): `click.echo(f"Session: {current['id']}")`. Backend +`api.get_session` возвращает session dict с ключом **`session_id`**, не `id` — TUI везде читает +`session.get("session_id")` (`tui_app.py`, `sessions_picker`). Прямой доступ `current['id']` → +`KeyError: 'id'` → ловится `except Exception` → «Failed to get session: 'id'». Команда `/profile` в +raw-режиме всегда падает. Исправление: `current['session_id']` (или `.get`). Medium bug в classic CLI. + +**6.B2 — raw CLI `/switch` re-raise теряет оригинальную HTTP-ошибку.** +`cli.py:273-288`: при неудачном exact `get_session(target)` код пытается prefix-match; если совпадений +не одно — `raise exc` (285) оригинальную `HTTPStatusError`, которая тут же ловится внешним +`except Exception` (286) и заменяется на «Session not found: {target}». Работает, но теряет реальную +причину (напр. 404 vs 500) и логика re-raise-then-catch запутана. Минор maintainability + потеря +диагностики. + +### Перформанс + +**6.P1 — `api._client()` создаёт новый `httpx.Client` на каждый вызов.** +`api.py:10-11`: каждый `get_session`/`list_sessions`/`create_session`/`get_todos`/`stop_session` → +`with _client() as client:` → новый `httpx.Client` → новое TCP-соединение (нет keep-alive/pooling). +`attach_session` в TUI делает `get_session` + `get_profile_model` (→`get_profiles`) + `get_todos` = 3 +отдельных соединения + handshake. Минор-medium; для частых вызовов (status panel seed, switch) +накладные. Лучше один shared `httpx.Client` (module-level или per-process) или connection-pooled. + +### Maintainability / архитектура + +**6.M1 — `_run_interactive` использует deprecated `asyncio.get_event_loop()`.** +`cli.py:205`: `asyncio.get_event_loop().run_in_executor(...)`. В Python 3.12+ `get_event_loop` без +running loop deprecated; здесь running loop есть, но идиоматичнее `asyncio.to_thread(input, ...)` +(3.9+) или `asyncio.get_running_loop().run_in_executor(...)`. Минор compat/future-proofing. + +**6.M2 — логика session-resolution дублирована в трёх местах.** +`cli.py:_resolve_session_id` (146-189), `tui_app.py:_resolve_session` (159-197), +`commands/builtin.py:SwitchCommand._resolve_session` (122-151) — три копии resume/new/prefix-match +логики с мелкими расхождениями (cli prefix-match в `_handle_command`, tui в `_resolve_session`, etc). +Контракт «разрешить session» — кандидат на единый хелпер. Maintainability. + +**6.M3 — `_run_tui` пишет в приватное `app._mouse_enabled`.** +`cli.py:122-123`: `app._mouse_enabled = mouse` перед `app.run(mouse=...)`. Можно через public-параметр +или метод. Минор. + +### Корректировки предыдущих этапов + +- **5.M2 (`action_stop_stream` iscoroutine) — подтверждено избыточно.** `api.stop_session` + (`api.py:82-86`) — sync, возвращает `dict`, не coroutine. `iscoroutine(result)` в + `tui_app.py:476` всегда `False`, `await` никогда не выполняется. Проверка мёртвая; контракт api + однозначно sync. 5.M2 оставить как «удалить мёртвую проверку». + +### Не-находки (проверено, ок) +- `config.py` — pydantic settings, `websocket_url` http→ws/https→wss корректен. Чисто. +- `render.py` (classic CLI renderer) — буферизация thinking, preview 400, `stream_end` newline, + unknown→`[event: …]`. Чисто для своего назначения (параллель с TUI renderers). +- `state.py` — JSON read-modify-write `~/.navi_code/state.json`, safe load (corrupt→{}). Чисто. +- `ws_client.py` — ревьювался в Этапе 1 (контракт для `WsBridge`); как standalone CLI-клиент + (`run_one_shot`/`run_interactive`) корректен. +- `__main__.py` — thin entry. Чисто. + +**Сводка этапа 6:** 2 бага (6.B1 — **`/profile` crash на `current['id']`**, medium; 6.B2 — switch +re-raise, минор), 1 перф-замечание (6.P1 — нет connection pooling, минор-medium), 3 maintainability +(6.M1 deprecated loop, 6.M2 дублирование resolution, 6.M3 private write). Классический CLI — +дополнительный путь; главная находка — 6.B1 (crash в `/profile`) и дублирование с TUI-логикой +(6.M2). + +--- + +## Этап 7 — Тестовое покрытие и сводный отчёт + +Тесты: `tests/clients/test_*.py` — **320 тестов, все зелёные** (339 passed, ~31 s). Покрытие сильное +для renderers (`test_filesystem_renderer` 48), chat_panel (29, включая `test_chat_model_*`), +settings, themes, code_theme, ws_bridge, todo, file_refs. + +### Пробелы покрытия, соответствующие найденным багам + +| Находка | Покрыто? | Примечание | +|---|---|---| +| 1.B1 (dangling `_current_thinking`) | ❌ | нет теста «thinking_delta после обрыва без thinking_end» | +| 1.B2 (failed tool resume → success:True) | ❌ | load_history tests не проверяют failed tool | +| 2.B1 (ArtifactRenderer) | ⚠️ | `test_diff_artifact_renderers` тестирует renderer **напрямую**, но production-поток его не вызывает — см. ниже | +| 3.B1 (todo markup injection) | ❌ | нет теста с task text, содержащим `[`/`]` | +| 4.B1 (palette IndexError) | ❌ | **`CommandPaletteScreen` не имеет тестов вообще** | +| 4.B2 (`/help` markup literally) | ❌ | `/help` content-markup не тестируется | +| 5.B1 (theme→chat items) | ❌ | нет теста «смена темы пере-красит bubbles» | +| 5.B2 (reconnect message loss) | ❌ | нет теста submit-while-reconnecting | +| 6.B1 (`/profile` crash) | ❌ | `_handle_command /profile` не тестируется | + +### Корректировка 2.B1 (ArtifactRenderer) + +`test_diff_artifact_renderers` создаёт `ArtifactRenderer()` и кормит `{"type": "artifact", ...}` +напрямую — renderer **работает и покрыт**. Но в production ни `ChatModel`, ни WS не эмитят +`type=artifact`. Значит это **не «мёртвый код»** (он не некром/не сломан), а **«неподключённый +renderer»**: либо deliberate заготовка под будущее WS-событие `artifact`, либо забытая интеграция. +Действие — решение продукта: подключить (добавить WS-событие + `ChatItem.kind="artifact"` + +`handle_ws_event` case + `_item_msg` mapping) либо удалить renderer+тесты. + +--- + +## Сводный приоритизированный список (для выбора задач в работу) + +Ниже — все находки, сгруппированные по темам и приоритету. Severity: **HIGH** (реальный баг/заметный +perf, делать), **MED** (заметный, стоит делать), **LOW** (минор/косметика, по желанию). + +### Тема A — Перформанс стриминга markdown (КЛЮЧЕВАЯ) + +- **[HIGH] 2.P1 + 3.P1** — двойной rich-рендер растущего `Markdown` на каждый stream-delta → + **O(N²)** по длине ответа. `AssistantMessageRenderer.render` парсит Markdown, `_build_content` и + `get_content_height` — два полных `console.render` на delta, плюс `ThemedMarkdownRenderable` свой + `Console` + `list(render)` materialize. Решение (вариант): троттлить (Markdown раз в X мс / только + на `stream_end`, во время стрима — простой `Text`), либо кэшировать высоту по signature+width. +- **[MED] 2.P2** — `ThemedMarkdownRenderable` материализует все segments в `list()` + второй проход + link-rewrite на каждый рендер (scroll/resize/reading-toggle). Yield-ить напрямую. +- **[LOW] 3.P2** — `_sync` O(visible) обход на каждый ws-event. Отслеживать только изменившийся item. +- **[LOW] 3.P3 / 1.P1 / 1.P2** — `ChatModel.items` не bounded (memory long sessions) + `stream_end` + purge full-copy + queue growth. Bounded-хранение на уровне модели. + +### Тема B — Корректность UI-состояния + +- **[HIGH] 5.B1** — смена темы не пере-красит уже отрисованные chat items (`apply_theme` не + инвалидирует `_content_cache`). Добавить `ChatPanel.refresh_content()` + вызов из `apply_theme` + (по образцу `set_reading_mode`). +- **[MED] 5.B2** — `on_user_submitted` теряет сообщение во время reconnect (guard по `connected` + вместо всегда-enqueue; queue переживает reconnect). `if self._bridge: enqueue(...)`. +- **[MED] 1.B1** — `stream_start` не сбрасывает `_current_thinking` → склейка reasoning двух ходов + после обрыва. Сбрасывать оба указателя. +- **[LOW] 1.B2** — `load_history` хардкод `success:True` → упавший инструмент при resume показывается + успешным. Root cause в persistence (backend `Message` без `success`); TUI-сторона — читать из + `metadata`/контента, либо backend добавить поле. + +### Тема C — Безопасность разметки / crash-edges + +- **[MED] 3.B1** — `todo_list._render_task_line` rich-markup injection через `text`/`validation`/ + `index`/`milestone` с `[`/`]`. `rich.markup.escape` или `Text.assemble` (как `command_hints`). +- **[MED] 4.B1** — `command_palette.on_list_view_selected` IndexError при пустом фильтре (клик по + «No matching»). Guard `0 <= index < len(self._filtered)`. +- **[MED] 6.B1** — raw CLI `/profile` crash на `current['id']` (backend отдаёт `session_id`). + `current['session_id']`. +- **[LOW] 4.B2** — `/help` формирует content с `[b]…[/b]`, но `StatusRenderer` рендерит `Text(…)` + literally → видны квадратные скобки. `Text.from_markup` или убрать markup из help. +- **[LOW] 2.B2** — grep regex-highlight расходится с реальным поиском при `args.regex=True`. + Компилировать pattern как regex для подсветки. +- **[LOW] 2.B3** — todo `validation.strip()` падает, если validation — не строка. `str(...).strip()`. + +### Тема D — Системный блокирующий I/O + +- **[HIGH] 5.M1** — sync `api.*` (`requests`/`httpx` sync) по всему app в Textual worker (event loop) + → TUI висит на каждом сетевом вызове. Обобщение 4.P1/4.M1. Async-клиент (`httpx.AsyncClient`) или + `run_in_thread`. +- **[LOW] 6.P1** — `api._client()` новый `httpx.Client` на вызов (нет pooling). Shared client. +- **[LOW] 4.P1 / 4.P2** — sessions_picker блокирующий `list_sessions` в `on_mount`; theme_picker + `apply_theme` на каждое highlight. + +### Тема E — Архитектура / maintainability + +- **[MED] 2.B1 (решение)** — `ArtifactRenderer` не подключён к production-потоку. Подключить или + удалить. +- **[MED] 6.M2** — session-resolution дублирован в 3 местах (cli / tui_app / SwitchCommand). Единый + хелпер. +- **[LOW] 1.M1** — `WsBridge` лезет в приватные `ws_client._ws`/`_input_queue`. Public-хуки транспорта. +- **[LOW] 1.M3 / 2.M2 / 2.M1** — fallback-рендер сыплет raw dict в чат (registry, status, plain); + жёсткая привязка `TodoResultRenderer` к backend-строке. +- **[LOW] 3.M1 / 3.M2 / 5.M3 / 6.M3 / 6.M1** — приватные доступы (`ChatModel._current_*`, + `app._mouse_enabled`), дублирование `_item_msg` meta-знания, deprecated `get_event_loop`. + +### Тема F — Покрытие тестами (после фиксов) + +- Добавить тесты для: `CommandPaletteScreen` (нет вовсе), `/profile` handler, todo markup-injection, + theme→chat-items repain, submit-while-reconnecting, dangling-thinking, failed-tool-resume, `/help` + markup, grep-regex-highlight. + +--- + +## Итоговая статистика ревью + +| Этап | Файлов | B (баги) | P (перф) | M (maint) | Ключевая находка | +|---|---|---|---|---|---| +| 1 Транспорт | 4 | 4 | 2 | 5 | 1.B1 dangling thinking | +| 2 Renderers | 16 | 3 | 3 | 5 | 2.P1 markdown-per-delta | +| 3 Widgets | 8 | 3 | 3 | 4 | 3.P1 двойной рендер на delta | +| 4 Screens+Cmd | 6 | 2 | 2 | 3 | 4.B1 palette crash | +| 5 App+подсистемы | 5 | 3 | 1 | 3 | 5.M1 блокирующий I/O | +| 6 Classic CLI | 7 | 2 | 1 | 3 | 6.B1 `/profile` crash | +| **Итого** | **46** | **17** | **12** | **23** | | + +**Топ-5 для работы (рекомендация):** +1. **Тема A (2.P1+3.P1)** — перформанс стриминга; самая заметная пользовательская боль на длинных + ответах. +2. **5.M1** — блокирующий sync I/O; делает TUI «замёрзшим» при сетевых операциях. +3. **5.B1** — theme switch не красит chat; заметный визуальный баг. +4. **Тема C (3.B1, 4.B1, 6.B1)** — три crash/injection-бага, каждый точечный фикс + тест. +5. **5.B2 + 1.B1** — потеря сообщения при reconnect + склейка thinking; корректность потока. + +Все 320 тестов зелёные на момент ревью — ни одна находка не ломает существующих тестов (баги +проявляются в путях, которые тесты не покрывают). + +--- + +# План исправления + +Принципы деления: +- **По приоритету** — заметные пользователю баги/perf первыми, минорный maintainability — в конце. +- **По связности** — близкие фиксы в одном этапе (общие файлы/тесты/контекст), чтобы каждый этап + был самодостаточной волной с зелёным тест-сьютом и отдельным коммитом. +- **Фикс + тест** — на каждую находку пишется тест, её покрывающий (правило памяти: полный pytest + suite должен проходить перед закрытием задачи). +- **Коммиты без Co-Authored-By**, поэтапный `git add` (только файлы этапа), commit по сигналу + пользователя. +- **Обсуждение до реализации** — перед каждым этапом подтверждаю подход; находки, требующие + продуктового/backend-решения, вынесены отдельно. + +Этапы упорядочены по убыванию приоритета (выполнять последовательно; внутри этапа — независимые +точечные фиксы, можно делать в любом порядке). + +--- + +## Этап 1 — Критичные crash/injection + потеря данных (точечные фиксы) + +**Цель:** убрать все найденные crash-edges и injection-баги + потерю сообщения и склейку thinking. +Каждый фикс локальный, низкорисковый, с тестом. Быстрая победа, видимый прирост надёжности. + +| id | Фикс | Файл | Тест | +|---|---|---|---| +| 4.B1 | `on_list_view_selected` guard `0 <= index < len(self._filtered)` | `screens/command_palette.py` | новый `test_command_palette.py` (нет вовсе) | +| 6.B1 | `/profile` `current['id']` → `current['session_id']` (или `.get`) | `cli.py` | `/profile` handler в `test_terminal_client` | +| 3.B1 | `_render_task_line`: `rich.markup.escape` для `text`/`validation`/`index`/`milestone` (или `Text.assemble` как `command_hints`) | `widgets/todo_list.py` | todo text с `[`/`]` в `test_todo_list` | +| 4.B2 | `/help` content — либо `Text.from_markup` в `StatusRenderer`, либо убрать `[b]…[/b]` из help | `commands/builtin.py` (+`renderers/status.py`) | `/help` renders без literal `[b]` | +| 5.B2 | `on_user_submitted`: `if self._bridge: enqueue(...)` вместо guard по `connected` | `tui_app.py` | submit-while-reconnecting в `test_tui_app` | +| 1.B1 | `stream_start` сбрасывает `_current_thinking` (оба указателя) | `chat_model.py` | dangling-thinking (thinking_delta после обрыва без thinking_end) | +| 2.B3 | todo `validation`: `str(args.get("validation") or "").strip()` | `renderers/todo.py` | validation как dict/list | +| 2.B1 | **Удалить** `ArtifactRenderer` + `test_diff_artifact_renderers` + регистрация в `renderers/__init__.py` (артефакты не нужны в TUI — файлы видны штатными средствами ОС) | `renderers/artifact.py`, `renderers/__init__.py`, `tests/clients/test_diff_artifact_renderers.py` | удалён — тесты не падают | + +**Зависимости:** нет. **Риск:** низкий. **Готовность:** все новые тесты зелёные + существующие +320 не сломаны (минус удалённые 3 artifact-теста). + +--- + +## Этап 2 — Перформанс стриминга markdown (тема A, ключевая) + +**Цель:** убрать O(N²) по длине ответа при стриминге, **сохранив live-markdown-форматирование** +(решение пользователя: троттл, не «Text-в-стриме»). + +| id | Фикс | Файл | +|---|---|---| +| 2.P1 + 3.P1 | **Markdown-троттл ~150 мс** (coalesce/debounce): `Markdown` парсится не на каждый delta, а раз в ~150 мс → ≤6 парсингов/сек вместо O(N²). Кэш `get_content_height` по signature+width (не рендерить дважды). Live-форматирование сохраняется (задержка ≤150 мс — незаметно) | `renderers/message.py`, `widgets/chat_panel.py` | +| 2.P2 | `ThemedMarkdownRenderable.__rich_console__` — yield segments напрямую без `list()` materialize; link-rewrite inline в цикле | `renderers/markdown_content.py` | +| 3.P2 | `_sync` — отслеживать только изменившийся item (model возвращает «что изменилось»), убрать O(visible) обход на каждый delta | `widgets/chat_panel.py`, `chat_model.py` | + +**Подход (подтверждён):** троттл Markdown-перепарсинга. Каждый delta только обновляет content +ChatItem (дёшево); рендер `Markdown` запускается не чаще чем раз в ~150 мс (таймер/coalesce) и +обязательно — на `stream_end`. `get_content_height` кэшируется по `(signature, width)` — при +том же content+width высота не пересчитывается. + +**Зависимости:** `test_chat_panel.py` (29 тестов, включая per-token-refresh) — потребуют +обновления (троттл меняет timing ре-рендера). **Риск:** средний (меняет основной рендер-путь). +**Готовность:** `test_chat_panel` + `test_message_renderers` зелёные; визуальная проверка +стриминга короткого и длинного ответа — live-форматирование видно, торможений нет. + +--- + +## Этап 3 — Блокирующий I/O + транспорт (тема D, системная) + +**Цель:** TUI перестаёт «замерзать» при сетевых операциях. + +| id | Фикс | Файл | +|---|---|---| +| 5.M1 | **Async-клиент** (`httpx.AsyncClient`): переписать `api.py` на `async def` + `await` во всех callers (`tui_app`, `commands/builtin`, `sessions_picker`, классический `cli.py`). Убирает блокировку event loop полностью | `api.py`, `tui_app.py`, `commands/builtin.py`, `screens/sessions_picker.py`, `cli.py` | +| 6.P1 | Shared `httpx.AsyncClient` (один на процесс, connection pooling) — объединить с 5.M1 | `api.py` | +| 4.P1 | `sessions_picker.on_mount` — вынести `api.list_sessions()` в worker/async | `screens/sessions_picker.py` | +| 4.P2 | `theme_picker` live-preview — debounce `apply_theme` (применять только на enter, не на каждое highlight) | `screens/theme_picker.py` | +| 5.M2 | Убрать мёртвую `iscoroutine` проверку в `_stop_stream_worker` (api sync — подтверждено 6.М-корректировкой) | `tui_app.py` | + +**Подход (подтверждён):** async-клиент (`httpx.AsyncClient`). `api.py` → `async def` + `await`; +все callers обновляются до `await api.*`. Один shared `AsyncClient` на процесс (connection +pooling, 6.P1). Классический `cli.py` (там уже `asyncio.run`-обёртки) тоже переводится на `await api.*`. + +**Зависимости:** `test_terminal_client`, `test_terminal_ws`, `test_tui_app` (api-mocked). +**Риск:** средний (трогает сетевой слой везде). **Готовность:** mock-тесты + визуальная проверка +attach/switch/stop без freeze. + +--- + +## Этап 4 — UI-состояние: theme + bounded items + артефакты + +**Цель:** корректное пере-красение при theme switch, bounded-память длинных сессий, решение по +артефактам. + +| id | Фикс | Файл | +|---|---|---| +| 5.B1 | `ChatPanel.refresh_content()` (сбрасывает `_rich_renderable`+`_content_cache` всех видимых items, как `set_reading_mode`) + вызов из `apply_theme` | `widgets/chat_panel.py`, `tui_app.py` | +| 3.P3 + 1.P1 | `ChatModel.items` bounded (храни последние N + truncation marker, видимое окно уже ограничено); `stream_end` purge — только хвост, без full-copy | `chat_model.py`, `widgets/chat_panel.py` | +| 2.B1 | **Решение продукта:** подключить `ArtifactRenderer` (WS-событие `artifact` + `ChatItem.kind="artifact"` + `handle_ws_event` case + `_item_msg` mapping) ИЛИ удалить renderer + `test_diff_artifact_renderers` | `renderers/artifact.py`, `chat_model.py`, `renderers/__init__.py` | +| 1.P2 | Bounded input-queue (опционально, минор) | `ws_bridge.py` | + +**Зависимости:** 5.B1 связан с кэшем из Этапа 2 (3.P1 меняет `_content_cache`); выполнять после +Этапа 2. **Риск:** низкий-средний. **Готовность:** test_tui_themes + новый theme→chat test +зелёные; визуальная проверка theme picker live-preview. + +--- + +## Этап 5 — Минорные correctness/perf + maintainability (по желанию) + +**Цель:** остаточные минорные находки. Брать выборочно по желанию пользователя; многие — +косметика. + +**Correctness/perf (LOW):** +- 2.B2 — grep regex-highlight (компилировать pattern как regex при `args.regex=True`) +- 5.P1 — `file_refs._collect_files`: фильтровать sensitive до sort +- 2.P3 — generic `ToolResultRenderer`/`PlainRenderer` truncation большого result +- 3.B2 — `_denoise` edge при `│` в контенте (известное ограничение) +- 3.B3 — `_complete_command` на пустом `/` +- 5.B3 — `session["session_id"]` → `.get` (KeyError edge в `_resolve_session`) +- 6.B2 — `/switch` re-raise теряет оригинальную ошибку +- ~~1.B2~~ — **отложено** (см. «Решения»); в работу не берётся + +**Maintainability (LOW):** +- 6.M2 — единый хелпер session-resolution (3 копии → 1) +- 1.M1 — `WsBridge` public-хуки транспорта (`recv()`, `wait_connected()`), убрать приватный доступ +- 2.M1 — `TodoResultRenderer`: структурированный `{done,total}` в meta вместо парсинга строки +- 1.M3 / 2.M2 — fallback-рендер «unknown event: » без raw dict +- 3.M1 — `ChatModel.reset()` вместо приватных `_current_*` в `ChatPanel.clear` +- 3.M2 — `_item_msg` meta-знание (принять как цену разделения) +- 5.M3 — `WsBridge.stop` `if not None` + обнуление (double-stop cleanup) +- 6.M1 — `asyncio.to_thread` вместо deprecated `get_event_loop()` +- 6.M3 / 5.M2 — приватные доступы `app._mouse_enabled`; `iscoroutine` (уже в Этапе 3) +- 4.M3 — `registry.match("")` поведение (решение по 3.B3) +- 1.M2 / 1.M5 / 2.M3 / 2.M4 / 3.M4(снято) — мелкие + +**Готовность:** по выбранным пунктам, тесты зелёные. + +--- + +## Решения, требующие подтверждения продукта/backend (до соответствующего этапа) + +- **2.B1 (ArtifactRenderer)** — ✅ **РЕШЕНО: удалить.** Артефакты — веб-клиентская концепция + (центр артефактов в UI); navi-code работает на той же машине, файлы видны штатными средствами ОС, + дублировать в TUI не нужно. Удалить `renderers/artifact.py` + `test_diff_artifact_renderers` + + регистрацию в `renderers/__init__.py`. Перенесено в **Этап 1** (простое удаление, низкий риск). +- **1.B2 (failed tool resume)** — ⏸️ **ОТЛОЖЕНО** (по решению пользователя). `load_history` + хардкодит `success:True`, т.к. persisted `Message` не хранит `success`. В работу не берётся; + баг остаётся (минор, косметика при resume). Вернуться можно позже. +- **Этап 2 подход** — ✅ **РЕШЕНО: Markdown-троттл (~150 мс).** Live-форматирование markdown во + время стрима остаётся (требование пользователя). `Markdown` парсится не на каждый delta, а раз в + ~150 мс (coalesce/debounce) — форматирование обновляется live с незаметной задержкой, вместо + O(N²) — ≤6 парсингов/сек. Дополнительно: кэш `get_content_height` по signature+width (не + рендерить дважды). Вариант «live-Text во время стрима» отклонён (форматирование появилось бы + только в конце). +- **Этап 3 подход** — ✅ **РЕШЕНО: async-клиент.** Переписать `api.py` на `httpx.AsyncClient` + + `async def` + `await` во всех callers (`tui_app`, `commands/builtin`, `sessions_picker`, + классический `cli.py`). Чистый async, идиоматично; больше правок, но убирает блокировку event + loop полностью. `run_in_thread` отклонён. + +--- + +## Порядок работы и контрольные точки + +1. Этап 1 (7 точечных фиксов + тесты) → полный pytest зелёный → коммит. +2. Этап 2 (перфоманс стриминга) → pytest + визуальная проверка → коммит. +3. Этап 3 (async I/O) → pytest + визуальная проверка → коммит. +4. Этап 4 (theme + bounded + артефакты) → pytest + визуальная проверка → коммит. +5. Этап 5 (минорные, выборочно) → pytest → коммит(ы). + +Перед каждым этапом — подтверждение подхода (правило «обсуди до реализации»); находки из секции +«Решения» — отдельно до начала соответствующего этапа. \ No newline at end of file diff --git a/tests/clients/test_chat_panel.py b/tests/clients/test_chat_panel.py index 9f40e8f..98594fb 100644 --- a/tests/clients/test_chat_panel.py +++ b/tests/clients/test_chat_panel.py @@ -412,6 +412,24 @@ assert model.items[0].content == "new" +def test_chat_model_stream_start_resets_dangling_thinking() -> None: + """stream_start must reset the thinking pointer so a dropped previous turn + (no thinking_end) does not glue the next turn's reasoning onto the old block.""" + from clients.terminal.tui.chat_model import ChatModel + + model = ChatModel() + # Previous turn's thinking, never closed (socket dropped / stop without event). + model.handle_ws_event({"type": "thinking_delta", "delta": "first reasoning"}) + assert model._current_thinking is not None + # New turn starts before thinking_end arrived. + model.handle_ws_event({"type": "stream_start"}) + assert model._current_thinking is None + # The new turn's thinking lands in its own block, not appended to the old one. + model.handle_ws_event({"type": "thinking_delta", "delta": "second reasoning"}) + blocks = [it for it in model.items if it.kind == "thinking_block"] + assert [b.content for b in blocks] == ["first reasoning", "second reasoning"] + + def test_chat_model_context_compressed_creates_summary_item() -> None: """A live context_compressed event surfaces the summary text as a distinct chat item (for both forced /compact and mid-turn auto-compress), carrying diff --git a/tests/clients/test_code_theme.py b/tests/clients/test_code_theme.py index 220b3e8..ce5d7f9 100644 --- a/tests/clients/test_code_theme.py +++ b/tests/clients/test_code_theme.py @@ -12,7 +12,6 @@ from pygments.styles import get_style_by_name from rich.syntax import Syntax -from clients.terminal.tui.renderers.artifact import ArtifactRenderer from clients.terminal.tui.renderers.filesystem import FilesystemToolResultRenderer from clients.terminal.tui.renderers.markdown_content import _theme_aware_code_theme from clients.terminal.tui.renderers.syntax import highlight_code @@ -50,22 +49,6 @@ assert _pygments_style_name(syntax) == get_style_by_name(theme.code_theme).__name__ -def test_artifact_renderer_uses_theme_code_theme() -> None: - set_active_theme("gnexus-dark") - theme = get_active_theme() - panel = ArtifactRenderer().render( - { - "type": "artifact", - "path": "a.py", - "language": "python", - "content": "x = 1\n", - } - ) - syntax = panel.renderable - assert isinstance(syntax, Syntax) - assert _pygments_style_name(syntax) == get_style_by_name(theme.code_theme).__name__ - - def test_filesystem_read_uses_theme_code_theme() -> None: set_active_theme("gnexus-dark") theme = get_active_theme() diff --git a/tests/clients/test_command_palette.py b/tests/clients/test_command_palette.py new file mode 100644 index 0000000..04f109d --- /dev/null +++ b/tests/clients/test_command_palette.py @@ -0,0 +1,58 @@ +"""Tests for the command palette modal screen.""" + +from __future__ import annotations + +from textual.widgets import ListItem, Static + +from clients.terminal.tui.screens.command_palette import CommandPaletteScreen + + +def test_select_empty_filtered_does_not_crash() -> None: + """Selecting the placeholder row when nothing matches must not IndexError. + + Regression guard for the empty-filter path: ``_filtered`` is empty while + ``_list_items`` holds one "No matching commands" placeholder. A list-select + event on that row used to index ``_filtered[0]`` and crash. + """ + + class _Evt: + def __init__(self, item) -> None: + self.item = item + + screen = CommandPaletteScreen([]) + screen._filtered = [] + screen._list_items = [ListItem(Static("No matching commands"))] + + dismissed: list = [] + screen.dismiss = lambda v: dismissed.append(v) # type: ignore[method-assign] + + screen.on_list_view_selected(_Evt(screen._list_items[0])) + assert dismissed == [] + + +def test_select_valid_filtered_dismisses_with_command() -> None: + """The happy path still dismisses with the selected command.""" + + class _Cmd: + def __init__(self, name: str) -> None: + from clients.terminal.tui.commands.base import CommandMeta + + self.meta = CommandMeta(name, (), "") + + async def execute(self, ctx, args: str) -> None: # pragma: no cover + raise NotImplementedError + + class _Evt: + def __init__(self, item) -> None: + self.item = item + + cmd = _Cmd("help") + screen = CommandPaletteScreen([cmd]) + screen._filtered = [cmd] + screen._list_items = [ListItem(Static("help"))] + + dismissed: list = [] + screen.dismiss = lambda v: dismissed.append(v) # type: ignore[method-assign] + + screen.on_list_view_selected(_Evt(screen._list_items[0])) + assert dismissed == [cmd] \ No newline at end of file diff --git a/tests/clients/test_diff_artifact_renderers.py b/tests/clients/test_diff_artifact_renderers.py deleted file mode 100644 index 75b8d51..0000000 --- a/tests/clients/test_diff_artifact_renderers.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Tests for diff and artifact renderers.""" - -from __future__ import annotations - -from clients.terminal.tui.renderers.artifact import ArtifactRenderer -from clients.terminal.tui.renderers.diff import DiffRenderer -from clients.terminal.tui.themes import set_active_theme - - -def test_diff_renderer_accepts_and_highlights() -> None: - set_active_theme("gnexus-dark") - renderer = DiffRenderer() - msg = { - "type": "diff", - "old_label": "a.py", - "new_label": "b.py", - "content": "--- a.py\n+++ b.py\n@@ -1,2 +1,2 @@\n-old\n+new\n unchanged\n", - } - assert renderer.accepts(msg) - renderable = renderer.render(msg) - text = str(renderable.renderable) - assert "old" in text - assert "new" in text - - -def test_artifact_renderer_accepts_and_renders() -> None: - set_active_theme("gnexus-dark") - renderer = ArtifactRenderer() - msg = { - "type": "artifact", - "path": "src/main.py", - "content": "def main():\n pass\n", - } - assert renderer.accepts(msg) - renderable = renderer.render(msg) - assert renderable.renderable is not None - assert "src/main.py" in renderable.title - - -def test_artifact_guesses_language_from_path() -> None: - renderer = ArtifactRenderer() - msg = { - "type": "artifact", - "path": "config.yaml", - "content": "key: value\n", - } - renderable = renderer.render(msg) - assert "yaml" in renderable.title diff --git a/tests/clients/test_diff_renderer.py b/tests/clients/test_diff_renderer.py new file mode 100644 index 0000000..ad1e081 --- /dev/null +++ b/tests/clients/test_diff_renderer.py @@ -0,0 +1,22 @@ +"""Tests for the unified-diff renderer.""" + +from __future__ import annotations + +from clients.terminal.tui.renderers.diff import DiffRenderer +from clients.terminal.tui.themes import set_active_theme + + +def test_diff_renderer_accepts_and_highlights() -> None: + set_active_theme("gnexus-dark") + renderer = DiffRenderer() + msg = { + "type": "diff", + "old_label": "a.py", + "new_label": "b.py", + "content": "--- a.py\n+++ b.py\n@@ -1,2 +1,2 @@\n-old\n+new\n unchanged\n", + } + assert renderer.accepts(msg) + renderable = renderer.render(msg) + text = str(renderable.renderable) + assert "old" in text + assert "new" in text \ No newline at end of file diff --git a/tests/clients/test_render_plain.py b/tests/clients/test_render_plain.py index 0ecc24a..2cac89d 100644 --- a/tests/clients/test_render_plain.py +++ b/tests/clients/test_render_plain.py @@ -47,7 +47,6 @@ {"type": "context_summary", "content": "summarized"}, {"type": "recall_update", "status": "fired"}, {"type": "diff", "content": "--- a\n+++ b\n@@\n-x\n+y\n", "path": "f"}, - {"type": "artifact", "content": "art"}, {"type": "plain", "content": "raw"}, ], ) diff --git a/tests/clients/test_terminal_client.py b/tests/clients/test_terminal_client.py index 7d7b3dd..558fe84 100644 --- a/tests/clients/test_terminal_client.py +++ b/tests/clients/test_terminal_client.py @@ -148,6 +148,26 @@ def test_resume_hint_format(self) -> None: assert _resume_hint("sess-123-abc") == "navi-code --resume sess-123-abc" + def test_profile_command_uses_session_id( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture + ) -> None: + """``/profile`` must read ``session_id`` from the server, not ``id`` + (the backend never sends ``id``). Regression for a KeyError crash.""" + from clients.terminal.cli import _handle_command + + monkeypatch.setattr( + "clients.terminal.api.get_session", + lambda sid: {"session_id": "sess-abc-123", "profile_id": "navi_code"}, + ) + state = StateManager(tmp_path) + state.set_session_id("sess-abc-123") + import asyncio + + asyncio.run(_handle_command("/profile", state, client=object())) + out = capsys.readouterr().out + assert "Profile: navi_code" in out + assert "Session: sess-abc-123" in out + def test_resume_flag_resumes_given_session( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: diff --git a/tests/clients/test_todo_list.py b/tests/clients/test_todo_list.py index f43ea7f..ef0060c 100644 --- a/tests/clients/test_todo_list.py +++ b/tests/clients/test_todo_list.py @@ -89,6 +89,42 @@ assert "_build_content" in TodoList.__dict__ +def test_task_text_with_brackets_is_rendered_literal() -> None: + """Task text containing ``[``/``]`` is escaped so rich markup does not consume + it as a tag (regression for markup injection via task data).""" + from rich.console import Console + + from clients.terminal.tui.themes import set_active_theme + + set_active_theme("gnexus-dark") + todo = TodoList() + todo._tasks = [ + {"index": 1, "text": "fix [bug] in module", "status": "pending", "validation": ""} + ] + console = Console(record=True, width=80, force_terminal=True, color_system=None) + console.print(todo._build_content()) + rendered = console.export_text() + # Brackets survive verbatim in the rendered text, not swallowed as markup. + assert "fix [bug] in module" in rendered + + +def test_milestone_label_with_brackets_is_rendered_literal() -> None: + """A milestone label containing brackets must not break the group header.""" + from rich.console import Console + + from clients.terminal.tui.themes import set_active_theme + + set_active_theme("gnexus-dark") + todo = TodoList() + todo._tasks = [ + {"index": 1, "text": "step [a]", "status": "done", "validation": "", "milestone": "M [x]"}, + ] + console = Console(record=True, width=80, force_terminal=True, color_system=None) + console.print(todo._build_content()) + rendered = console.export_text() + assert "M [x]" in rendered + + @pytest.mark.anyio async def test_status_markers_are_coloured_and_emphasised() -> None: """Each status gets its own marker colour, and the active step is bolded diff --git a/tests/clients/test_todo_renderers.py b/tests/clients/test_todo_renderers.py index 1b31290..a084881 100644 --- a/tests/clients/test_todo_renderers.py +++ b/tests/clients/test_todo_renderers.py @@ -108,6 +108,20 @@ assert "#1 → in_progress" in str(panel.title) out = _render_text(panel) assert "write the parser" in out + + +def test_update_card_non_string_validation_does_not_crash() -> None: + """``validation`` arrives as arbitrary JSON; a non-string value (dict/list) + must not raise AttributeError on .strip(). Regression for robustness.""" + set_active_theme("gnexus-dark") + msg = { + "type": "tool_started", + "tool": "todo", + "args": {"op": "update", "index": 1, "status": "done", "validation": {"note": "ok"}}, + } + panel = TodoStartedRenderer().render(msg) # must not raise + out = _render_text(panel) + assert "verified" in out assert "no validation" not in out diff --git a/tests/clients/test_tui_app.py b/tests/clients/test_tui_app.py index 65bf64a..813b032 100644 --- a/tests/clients/test_tui_app.py +++ b/tests/clients/test_tui_app.py @@ -859,6 +859,62 @@ @pytest.mark.anyio +async def test_help_command_has_no_literal_markup() -> None: + """/help content must not contain raw ``[b]`` markup: StatusRenderer builds a + literal ``Text(...)`` which does not parse markup, so the tags would show as + literal square brackets. Headers are plain text.""" + from clients.terminal.tui.commands.builtin import HelpCommand + + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + await HelpCommand().execute(pilot.app._ctx, "") + await pilot.pause() + status = [it for it in chat._model.items if it.kind == "status"] + assert status + content = status[-1].content + assert "[b]" not in content + assert "[/b]" not in content + assert "Slash commands" in content + + +@pytest.mark.anyio +async def test_submit_while_reconnecting_enqueues_not_errors() -> None: + """A message submitted while the bridge is reconnecting (connected=False) is + enqueued for later delivery, not dropped with a 'not connected' error — the + WsBridge input loop buffers until the socket is live again.""" + + class _FakeClient: + def __init__(self) -> None: + self.enqueued: list[str] = [] + + def enqueue(self, msg) -> None: + self.enqueued.append(msg) + + class _FakeBridge: + connected = False + + def __init__(self) -> None: + self.client = _FakeClient() + + async def stop(self) -> None: # called from on_unmount on test teardown + return None + + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + bridge = _FakeBridge() + pilot.app._bridge = bridge + input_box = pilot.app.query_one("InputBox") + input_box._input.text = "hello during reconnect" + await pilot.press("enter") + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + assert bridge.client.enqueued, "message was enqueued for delivery" + assert "hello during reconnect" in bridge.client.enqueued[0] + assert not any(it.kind == "error" for it in chat._model.items) + + +@pytest.mark.anyio async def test_elapsed_timer_freezes_on_stream_stopped_without_backend() -> None: """stream_stopped has no elapsed_seconds; the timer freezes at its local value.""" async with NaviCodeTui(new_session=True).run_test() as pilot: