Newer
Older
navi-1 / docs / code_review_tui.md

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_syncmaybe_update (signature изменилась) → новый _rich_renderable (registry.renderMarkdown(text) парс растущего текста + внутри ThemedMarkdownRenderable свой Console) → refresh(layout=True). Затем Textual: (1) измеряет высоту get_content_heightlist(console.render(renderable, options)) (полный render #1), (2) рисует render()_build_contentlist(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_syncvisible_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 Noneformat_duration(None). Безопасность зависит от format_duration (Этап 5). Отметить как зависимость; стоит guard.

Не-находки (проверено, ок)

  • command_hintsText.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:

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 == 0self._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_themeapp.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_contentText.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 EDITORvi/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, 196session["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_streammixes 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_quitbridge.stop()self.exit()on_unmountbridge.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.pyshell=True оправдано (user-typed !cmd), timeout 30s, _truncate 200 lines, subprocess.TimeoutExpired→124, OSError→1. Чисто для своего назначения.
  • duration.pyformat_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_sessionwith _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_*.py320 тестов, все зелёные (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.P2ThemedMarkdownRenderable материализует все segments в list() + второй проход link-rewrite на каждый рендер (scroll/resize/reading-toggle). Yield-ить напрямую.
  • [LOW] 3.P2_sync O(visible) обход на каждый ws-event. Отслеживать только изменившийся item.
  • [LOW] 3.P3 / 1.P1 / 1.P2ChatModel.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.B2on_user_submitted теряет сообщение во время reconnect (guard по connected вместо всегда-enqueue; queue переживает reconnect). if self._bridge: enqueue(...).
  • [MED] 1.B1stream_start не сбрасывает _current_thinking → склейка reasoning двух ходов после обрыва. Сбрасывать оба указателя.
  • [LOW] 1.B2load_history хардкод success:True → упавший инструмент при resume показывается успешным. Root cause в persistence (backend Message без success); TUI-сторона — читать из metadata/контента, либо backend добавить поле.

Тема C — Безопасность разметки / crash-edges

  • [MED] 3.B1todo_list._render_task_line rich-markup injection через text/validation/ index/milestone с [/]. rich.markup.escape или Text.assemble (как command_hints).
  • [MED] 4.B1command_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.P1api._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.M1WsBridge лезет в приватные 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.pyasync 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 → коммит(ы).

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