Дата: 2026-07-13. Scope: весь terminal-клиент — clients/terminal/tui/ (48 файлов, ~6144 LOC) + классический CLI clients/terminal/*.py + тесты tests/clients/.
Процесс: поэтапный ревью по слоям, от фундамента к UI. После всех этапов — приоритизация и список задач в работу (этап 7).
Категории находок (по приоритету пользователя): B — баги/корректность, P — перформанс, M — maintainability/архитектура. Безопасность отмечается по факту, но не приоритет.
Каждая находка имеет стабильный id (<этап>.<буква><номер>) для ссылок в итоговом списке задач.
Файлы: 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. Минор.
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-классы, замечаний нет.Сводка этапа 1: 4 бага корректности (1.B1 — реальный, 1.B2 — архитектурный root cause, 1.B3/B4 — минор/зависит от app), 2 перф-замечания (1.P1 значимо для длинных сессий), 5 maintainability. Критичных блокеров нет — транспорт в целом здравый.
Файлы: 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.
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).
Файлы: 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, но на уровне модели) или прокси/оконная модель.
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).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).
Файлы: 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 == 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.
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).
Файлы: 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/.
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_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. Минор.
WsBridge.start) — mitigated на уровне app. attach_session (tui_app.py:246-249) всегда await self._bridge.stop() перед новым WsBridge(...).start(). Прямой двойной start() невозможен через app. Оставить как внутренний robustness-комментарий в WsBridge (guard в start), но снизить приоритет с «баг» до «defensive».format_duration(None)) — снято. duration.py:16-17 format_duration(None) → "—" безопасно. Non-issue.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).
Файлы: 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.
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-параметр или метод. Минор.
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).
Тесты: 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 не тестируется |
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 (минор/косметика, по желанию).
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.ThemedMarkdownRenderable материализует все segments в list() + второй проход link-rewrite на каждый рендер (scroll/resize/reading-toggle). Yield-ить напрямую._sync O(visible) обход на каждый ws-event. Отслеживать только изменившийся item.ChatModel.items не bounded (memory long sessions) + stream_end purge full-copy + queue growth. Bounded-хранение на уровне модели.apply_theme не инвалидирует _content_cache). Добавить ChatPanel.refresh_content() + вызов из apply_theme (по образцу set_reading_mode).on_user_submitted теряет сообщение во время reconnect (guard по connected вместо всегда-enqueue; queue переживает reconnect). if self._bridge: enqueue(...).stream_start не сбрасывает _current_thinking → склейка reasoning двух ходов после обрыва. Сбрасывать оба указателя.load_history хардкод success:True → упавший инструмент при resume показывается успешным. Root cause в persistence (backend Message без success); TUI-сторона — читать из metadata/контента, либо backend добавить поле.todo_list._render_task_line rich-markup injection через text/validation/ index/milestone с [/]. rich.markup.escape или Text.assemble (как command_hints).command_palette.on_list_view_selected IndexError при пустом фильтре (клик по «No matching»). Guard 0 <= index < len(self._filtered)./profile crash на current['id'] (backend отдаёт session_id). current['session_id']./help формирует content с [b]…[/b], но StatusRenderer рендерит Text(…) literally → видны квадратные скобки. Text.from_markup или убрать markup из help.args.regex=True. Компилировать pattern как regex для подсветки.validation.strip() падает, если validation — не строка. str(...).strip().api.* (requests/httpx sync) по всему app в Textual worker (event loop) → TUI висит на каждом сетевом вызове. Обобщение 4.P1/4.M1. Async-клиент (httpx.AsyncClient) или run_in_thread.api._client() новый httpx.Client на вызов (нет pooling). Shared client.list_sessions в on_mount; theme_picker apply_theme на каждое highlight.ArtifactRenderer не подключён к production-потоку. Подключить или удалить.WsBridge лезет в приватные ws_client._ws/_input_queue. Public-хуки транспорта.TodoResultRenderer к backend-строке.ChatModel._current_*, app._mouse_enabled), дублирование _item_msg meta-знания, deprecated get_event_loop.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 для работы (рекомендация):
Все 320 тестов зелёные на момент ревью — ни одна находка не ломает существующих тестов (баги проявляются в путях, которые тесты не покрывают).
Принципы деления:
git add (только файлы этапа), commit по сигналу пользователя.Этапы упорядочены по убыванию приоритета (выполнять последовательно; внутри этапа — независимые точечные фиксы, можно делать в любом порядке).
Цель: убрать все найденные 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-теста).
Цель: убрать 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-форматирование видно, торможений нет.
Цель: 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.
Цель: корректное пере-красение при 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.
Цель: остаточные минорные находки. Брать выборочно по желанию пользователя; многие — косметика.
Correctness/perf (LOW):
args.regex=True)file_refs._collect_files: фильтровать sensitive до sortToolResultRenderer/PlainRenderer truncation большого result_denoise edge при │ в контенте (известное ограничение)_complete_command на пустом /session["session_id"] → .get (KeyError edge в _resolve_session)/switch re-raise теряет оригинальную ошибкуMaintainability (LOW):
WsBridge public-хуки транспорта (recv(), wait_connected()), убрать приватный доступTodoResultRenderer: структурированный {done,total} в meta вместо парсинга строкиChatModel.reset() вместо приватных _current_* в ChatPanel.clear_item_msg meta-знание (принять как цену разделения)WsBridge.stop if not None + обнуление (double-stop cleanup)asyncio.to_thread вместо deprecated get_event_loop()app._mouse_enabled; iscoroutine (уже в Этапе 3)registry.match("") поведение (решение по 3.B3)Готовность: по выбранным пунктам, тесты зелёные.
renderers/artifact.py + test_diff_artifact_renderers + регистрацию в renderers/__init__.py. Перенесено в Этап 1 (простое удаление, низкий риск).load_history хардкодит success:True, т.к. persisted Message не хранит success. В работу не берётся; баг остаётся (минор, косметика при resume). Вернуться можно позже.Markdown парсится не на каждый delta, а раз в ~150 мс (coalesce/debounce) — форматирование обновляется live с незаметной задержкой, вместо O(N²) — ≤6 парсингов/сек. Дополнительно: кэш get_content_height по signature+width (не рендерить дважды). Вариант «live-Text во время стрима» отклонён (форматирование появилось бы только в конце).api.py на httpx.AsyncClient + async def + await во всех callers (tui_app, commands/builtin, sessions_picker, классический cli.py). Чистый async, идиоматично; больше правок, но убирает блокировку event loop полностью. run_in_thread отклонён.Перед каждым этапом — подтверждение подхода (правило «обсуди до реализации»); находки из секции «Решения» — отдельно до начала соответствующего этапа.