Newer
Older
navi-1 / docs / terminal_tool_plan.md

План: инструмент терминала — гэпы + UI управления терминалами

Контекст: см. анализ в конце обсуждения (terminal.py, terminal_manager.py, navi_code profile). Гэпы: TUI не обрабатывает terminal_output/terminal_closed; TerminalClosed не эмитируется; live-stream background-терминала обрывается после open (per-tool sink закрывается); нет TUI-рендерера terminal tool_call; нет способа посмотреть/закрыть терминалы из UI.

Решение по live-выводу: модалка + count (не live-панель). TUI накапливает terminal_output по терминалам; /terminals модалка показывает список + last output; статус-бар — count открытых.

Этап 1 — Backend: session-broadcast sink + lifecycle events

Корень гэпов 2+3: event_sink сейчас per-tool-call (current_event_sink.set в _execute_tools_with_sink, _TOOL_DONE после возврата open). Background-терминал живёт в terminal_manager, его reader-tasks пишут в output_buffer (deque 500), но в стрим уже некому. TerminalClosed не эмитируется вообще.

Что делаем:

  • TerminalManager: per-session broadcast sink — bind_session(session_id, sink) / unbind_session(session_id). Хранит dict[session_id, sink]. Reader-tasks и lifecycle-события пишут в session-sink (живёт пока сессия), не в per-tool sink.
  • terminal_manager.open: reader-tasks (_read_stream) → TerminalOutputDelta в session-sink (если bound), не в per-tool event_sink. Per-tool sink остаётся только для immediate open-feedback.
  • terminal_manager._close_one: emit TerminalClosed(name, reason) в session-sink.
  • Новое событие TerminalOpened (events.py: dataclass + to_wire{"type":"terminal_opened", "terminal_name","description","pid","background"}), emit в open (после старта proc).
  • orchestrator/container: bind_session(session_id, broadcast) при старте хода/attach, unbind + close_all на session end / shutdown. (Точка: где session-broadcast создаётся — orchestrator, который шлёт WS.)

Риск: средний. Меняет sink-архитектуру terminal_manager. Совместимость: webclient уже handle terminal_output/terminal_closed; terminal_opened — новый handler (мелочь).

Этап 2 — Backend: REST endpoints для list/close

/terminals модалке нужен источник списка + закрытие. Тянуть через REST (как sessions_picker).

  • GET /sessions/{id}/terminalsterminal_manager.list(session_id) (массив summary).
  • POST /sessions/{id}/terminals/{name}/closeterminal_manager.close(session_id, name).
  • api.py (terminal client, async): list_terminals(session_id), close_terminal(session_id, name).

Риск: низкий. Прозрачные endpoint-ы над существующим terminal_manager.

Этап 3 — TUI: terminal tool_call renderer (action-aware)

Сейчас tool_call с tool="terminal" → generic ToolResultRenderer (без структуры). filesystem имеет спец-рендерер; terminal — нет.

  • renderers/terminal.py: action-aware (по образцу filesystem):
    • run: команда + exit-code (success/error цвет) + output (capped).
    • open: terminal_name + description + PID + background-флаг.
    • list: таблица активных (статус-эмодзи, name, description, PID, uptime).
    • status: name/desc/command/PID/cwd/uptime + output tail.
    • send_input: echo «Sent input to ».
    • close: «Terminal closed».
  • Регистрация в renderers/__init__.py перед generic ToolResultRenderer (как filesystem/todo).
  • chat_panel._item_msg для tool_call уже раскрывает meta — terminal renderer читает args.action/result/success/metadata.

Риск: низкий. Только рендер.

Этап 4 — TUI: handle terminal events + count в статусе

  • ChatModel.handle_ws_event: cases для terminal_opened / terminal_output / terminal_closed:
    • накапливать состояние terminals: dict[name → {status, output_tail, pid, background, closed}];
    • не создавать chat-пузырь (как model_info/todo_updatedreturn без форварда в чат).
  • tui_app.on_ws_event: forward в chat_model + обновлять StatusPanel count.
  • StatusPanel: заменить _hint («Ctrl+P palette | /help commands») на «Terminals: N». Seed на attach (Этап 2 api.list_terminals), live-обновление из events.
  • Seed count на attach_session через api.list_terminals (async, в worker — как get_todos).

Риск: низкий-средний. StatusPanel hint → count (видимый UX change, может захотеть combos куда-то перенести — но пользователь сказал заменить).

Этап 5 — TUI: /terminals команда + модалка

  • screens/terminals_picker.py (модалка, по образцу sessions_picker):
    • список: api.list_terminals(session_id) (REST seed) + live из ChatModel-состояния (terminal_output обновляет output_tail, terminal_opened/closed — список).
    • строка: статус-эмодзи + name + description + PID + uptime; expand → last output tail.
    • up/down — навигация, enterclose выбранного (api.close_terminal), escape — cancel. Без подтверждения (явное действие в модалке).
  • commands/builtin.py: TerminalsCommand (/terminals) → app._open_terminals_picker(). meta.keybind — опционально (напр. ctrl+x t свободен? сейчас ctrl+x t = toggle thinking; подберём свободный или без keybind).
  • tui_app._open_terminals_picker: push screen + callback (close → run_worker).

Риск: средний. Модалка + live-обновление списка при events (refresh при terminal_output/ opened/closed, если модалка открыта).

Порядок и контроль

Этапы 1→2 (backend) → 3→4→5 (TUI). Каждый: подтверждение подхода → реализация + тесты → полный pytest зелёный → коммит (без Co-Authored-By).

Зависимости: Этап 4/5 зависят от 1 (events) + 2 (REST). Этап 3 (renderer) независим.

Тесты (по этапам)

  • 1: terminal_manager bind/unbind; terminal_opened/terminal_closed emit в session-sink; background output стримится после возврата open.
  • 2: REST list/close (через test-client + mock terminal_manager).
  • 3: renderer: каждый action → структура (run exit-code, open PID, list таблица, status tail).
  • 4: ChatModel cases (накапливает, не создаёт пузырь); StatusPanel «Terminals: N» обновляется.
  • 5: модалка: список seed + close → api.close_terminal; live refresh.

Out of scope

  • Live-панель с реалтайм-выводом (отклонено — модалка + count).
  • Подсветка terminal-output как код (минор, можно позже).
  • Дедуп _resolve_working_dir (terminal/code_exec) — maintainability, не в этом плане.