# 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: <type>» без 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: <type>» без 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 → коммит(ы).

Перед каждым этапом — подтверждение подхода (правило «обсуди до реализации»); находки из секции
«Решения» — отдельно до начала соответствующего этапа.