diff --git a/README.md b/README.md index 77070eb..0cef635 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ under `.agents/tools/` (see [Custom tools](#custom-tools)). > loop, translated to idiomatic Python and direct provider SDKs. - `cothis ask "..."` — one-shot prompt, plain-text output (pipe-friendly). -- `cothis chat` — interactive multi-turn session in the Textual TUI (3-pane layout, worktree-aware sessions, `--legacy` for the old REPL). +- `cothis chat` — interactive multi-turn session (rich REPL by default: `>>> ` prompt + streamed Markdown; `--tui` for the worker-based Textual shell). Requires Python ≥ 3.14. @@ -95,11 +95,14 @@ VERBOSE=1 uv run cothis ask "list the files in src" uv run cothis chat ``` -`chat` now launches the **Textual TUI** by default (#237): a 3-pane -layout — `SessionList` (left) + `ConversationView` (center) + a `TextArea` -input (bottom). Press `n` to create a new session (worktree picker or current -directory); type a prompt + `Ctrl+Enter` to send. The worker streams -deltas, tool-call cards, and interactive questions (`AskUserModal`). +`chat` launches the **Textual TUI** by default (#237): a focused +transcript shell — a full-height scrollable `ConversationView` with a fixed +composer dock below it (input + shortcut hint) and a one-line status bar. +Session navigation is transient (`/sessions` picker), never a sidebar; the +input holds focus so you can always type. Press `Ctrl+N` to create a new +session (worktree picker or current directory); type a prompt + `Ctrl+Enter` +to send. The worker streams deltas, tool-call cards, and interactive +questions (`AskUserModal`). ```bash uv run cothis chat -m anthropic/claude-3.5-haiku @@ -123,15 +126,16 @@ Run `cothis --help`, `cothis ask --help`, or `cothis chat --help` for the full list of flags. -### `tui` — alias for the default TUI path +### `tui` — the worker-based Textual shell ```bash uv run cothis tui ``` -Same as `chat` — launches the Textual TUI with Supervisor-backed session -spawn. Provided as a separate entrypoint for users who want the TUI -without the `chat` name. Same `--model` / `--provider` flags. +Launches the opt-in Textual shell with Supervisor-backed session spawn +(the same experience as `chat --tui`). Provided as a separate entrypoint +for users who want the widget shell without the `chat` name. Same +`--model` / `--provider` flags. ### Session management — `history` / `delete` / `archive` diff --git a/packages/cothis-cli/src/cothis/cli.py b/packages/cothis-cli/src/cothis/cli.py index ee689f3..cf38d44 100644 --- a/packages/cothis-cli/src/cothis/cli.py +++ b/packages/cothis-cli/src/cothis/cli.py @@ -24,8 +24,6 @@ import click # cost: ~5ms import typer # cost: ~30ms (loads click + shell completion) from rich.console import Console # cost: ~15ms -from rich.live import Live # cost: ~5ms -from rich.markdown import Markdown # cost: ~5ms from cothis.agent import Agent, MaxIterationsError, ToolCallEvent, ToolResultEvent from cothis.session import ( @@ -444,10 +442,21 @@ def chat( "Synthesises a load_skill pair after the first user message." ), ), + tui: bool = typer.Option( + False, + "--tui", + help=( + "Launch the worker-based Textual shell instead of the rich REPL " + "(the default chat experience)." + ), + ), legacy: bool = typer.Option( False, "--legacy", - help="Use the legacy REPL instead of the Textual TUI (#237).", + help=( + "Accepted for compatibility; the rich REPL is now the default " + "chat experience, so this flag is a no-op." + ), ), ) -> None: """Run an interactive multi-turn chat session. @@ -461,41 +470,36 @@ def chat( picker. Errors with "not found, run ``cothis history``" if the id is missing or out of this directory's scope. - **Default: launches the Textual TUI** (#237 staged migration). The - TUI supports ``--model`` / ``--provider`` and ``--resume`` (auto-spawns a - worker for the resumed session on startup). ``--skill`` still falls back - to the legacy REPL with a notice. Pass ``--legacy`` to force the REPL. + **Default: the rich streaming chat** — prompt_toolkit input (``❯ ``) + with a virtualized transcript (only visible lines render), follow-end + scroll, and a ``/`` command menu. Pass ``--tui`` for the opt-in worker-based + Textual shell (multi-session picker, transient ``/sessions`` + switching, persistent-focus composer). ``--skill`` works in both. - On the default (TUI) path all five worker-subprocess tuning flags — + On the ``--tui`` path all five worker-subprocess tuning flags — ``--max-concurrent-tools``, ``--max-tool-result-chars``, ``--tool-timeout``, ``--summary-model``, and ``--min-retained-turns`` — are forwarded to the spawned worker as ``COTHIS_*`` env vars and take - effect there (mirroring the legacy REPL / ``ask`` behavior). + effect there (mirroring the rich REPL / ``ask`` behavior). """ - # Staged migration (#237): default to TUI; --legacy keeps the REPL. - # Staged migration (#237): default to TUI; --legacy keeps the REPL. - # --resume is supported via the TUI's on_mount auto-spawn. - # --skill still falls back to legacy (not yet wired in the TUI). - if not legacy: - if not skill: - if resume is not None: - _validate_session_id_arg(resume) - _check_resume_exists(_resolve_db_path(), resume) - _launch_tui_app( - model=model, - provider=provider, - resume=resume, - max_concurrent_tools=max_concurrent_tools, - max_tool_result_chars=max_tool_result_chars, - tool_timeout=tool_timeout, - summary_model=summary_model, - min_retained_turns=min_retained_turns, - ) - return - console.print( - "[dim]--skill not yet supported by the TUI; " - "using legacy REPL. Pass --legacy to silence this notice.[/dim]" + if tui: + # Opt-in worker-based Textual shell (previously the default). + if resume is not None: + _validate_session_id_arg(resume) + _check_resume_exists(_resolve_db_path(), resume) + _launch_tui_app( + model=model, + provider=provider, + resume=resume, + max_concurrent_tools=max_concurrent_tools, + max_tool_result_chars=max_tool_result_chars, + tool_timeout=tool_timeout, + summary_model=summary_model, + min_retained_turns=min_retained_turns, ) + return + # Rich REPL is the default chat experience (prompt_toolkit + rich + # streaming). ``--skill`` works here directly (preactivate_skills). asyncio.run( _chat_session( model=model, @@ -567,112 +571,19 @@ async def _chat_session( ) agent.attach_session(session) - # prompt_toolkit over stdlib ``input()``: CPython auto-loads GNU readline - # for ``input``, which mis-counts CJK / wide-char column width and leaves - # visual residue on backspace. prompt_toolkit does its own ``wcwidth`` - # accounting and renders the line itself. - # - # ``prompt_async`` (not sync ``prompt`` via ``asyncio.to_thread``): the - # latter races interpreter shutdown on Ctrl-C — the worker stays blocked - # on stdin while the main thread unwinds, producing a noisy traceback. - # prompt_toolkit (~115ms) is chat-REPL-only — import here, not at - # module top, so ask/history/delete/archive/tui/worker/--help don't - # pay for a REPL widget they never instantiate (#386). - from prompt_toolkit.shortcuts import PromptSession - prompts = PromptSession() - try: - while True: - try: - prompt_text = await prompts.prompt_async(">>> ") - except EOFError, KeyboardInterrupt: - console.print() - break - if not prompt_text.strip(): - continue - - await _stream_answer(agent, prompt_text) - finally: - await agent.aclose() + # Full-screen streaming chat: virtualized transcript (rich + + # prompt_toolkit) with follow-end scroll and a ``/`` command menu. + # ``run_streaming_chat`` owns ``agent.aclose()``. + from cothis.streaming_tui import run_streaming_chat + + await run_streaming_chat(agent) finally: - # Idempotent: if attach succeeded, ``agent.aclose()`` above already - # closed the session (drained + joined + storage closed). If Agent + # Idempotent: if the streaming chat already closed the agent + # (drained + joined + storage closed), this is a no-op; if Agent # construction failed before attach, this is the cleanup path. session.close() -async def _stream_answer(agent: Agent, prompt: str) -> None: - """Run one turn of the agent and stream the final answer as Markdown. - - Event protocol from ``Agent.run_stream``: - * ``ToolCallEvent`` — printed inline (``calling fs.read(...)``) so the - user can see why a multi-step turn is taking time. Printed *above* - the spinner's animation row, which rich's Status handles cleanly. - * ``ContentDelta`` — a content delta of the final answer (``kind`` - separates normal text from thinking; only ``text`` is rendered here). - - The ReAct loop is multi-turn: tool-call turns and content turns alternate. - This consumer drives a two-state display: - * ``thinking`` — spinner running; ToolCallEvents printed inline. - * ``streaming`` — spinner stopped; a Live Markdown view re-renders as - content deltas arrive. - Transitions happen per-event, not per-turn, because a single provider - turn can interleave tool calls and content. The consumer must drain - the *whole* generator — closing it early (the old ``break`` + ``return`` - shape) truncated the ReAct loop the moment a tool-call-only turn had no - content delta to show, so the agent stopped after one tool call. - """ - stream = agent.run_stream(prompt) - status = console.status("thinking...", spinner="dots") - live: Live | None = None - has_max_iterations_error = False - accumulated = "" - status.start() - try: - async for event in stream: - if isinstance(event, ToolCallEvent): - if live is not None: - live.stop() - live = None - accumulated = "" - status.start() - console.print(_format_tool_call(event), style="dim") - continue - if isinstance(event, ToolResultEvent): - # Legacy REPL doesn't render tool completion inline — the - # next assistant delta implicitly signals "tools done". - # Consumed by the TUI via WS instead (#254). - continue - # Content delta — first one spins up Live, subsequent ones update it. - if live is None: - status.stop() - accumulated = event.text - live = Live( - Markdown(accumulated), console=console, refresh_per_second=10 - ) - live.start() - else: - accumulated += event.text - live.update(Markdown(accumulated)) - except MaxIterationsError as exc: - has_max_iterations_error = True - if live is not None: - live.stop() - else: - status.stop() - console.print(f"[red]Error:[/red] {exc}") - return - finally: - if has_max_iterations_error: - pass # already handled in except - elif live is not None: - live.stop() - console.print() - elif accumulated: - console.print(Markdown(accumulated)) - else: - status.stop() - - def _format_tool_call(event: ToolCallEvent) -> str: """One-line human-readable summary of a tool call. diff --git a/packages/cothis-cli/src/cothis/streaming_tui.py b/packages/cothis-cli/src/cothis/streaming_tui.py new file mode 100644 index 0000000..a76f396 --- /dev/null +++ b/packages/cothis-cli/src/cothis/streaming_tui.py @@ -0,0 +1,412 @@ +"""Streaming chat TUI — rich + prompt_toolkit with a virtualized transcript. + +Full-screen layout (no Textual): + +:: + + ┌──────────────────────────────────────────────┐ + │ scrollable conversation (virtualized) │ ← only visible lines rendered + │ … past turns + the live answer │ + ├──────────────────────────────────────────────┤ + │ ❯ prompt_toolkit input │ ← Enter sends, / pops the slash menu + └──────────────────────────────────────────────┘ + +Virtual rendering + The conversation control stores pre-rendered rich lines; the Window + requests only the visible slice per frame (``UIContent.get_line`` is + lazy), so a long transcript costs O(viewport) per render — no full + re-render, no widget-per-message DOM growth. + +Continuous scroll / follow-end + ``window.vertical_scroll`` is the preferred scroll (overrides the + cursor-based computation). New content pins the view to the bottom + only while the user is already there (``_following``); scrolling away + (PgUp / wheel up) freezes the viewport so the stream never yanks the + reader; PgDn / wheel down re-follows. + +Slash menu + Typing ``/`` pops a completion menu (``SlashCompleter``) of commands: + ``/help``, ``/exit``, ``/clear``. +""" + +from __future__ import annotations + +import asyncio +from contextlib import suppress +from typing import TYPE_CHECKING, Any, cast + +from prompt_toolkit.application import Application +from prompt_toolkit.completion import Completer, Completion +from prompt_toolkit.filters import Condition +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.layout import ( + HSplit, + Layout, + ScrollbarMargin, + ScrollOffsets, + UIContent, + Window, +) +from prompt_toolkit.layout.controls import Point, UIControl +from prompt_toolkit.styles import Style +from prompt_toolkit.widgets import TextArea +from rich.console import Console, ConsoleOptions +from rich.markdown import Markdown + +if TYPE_CHECKING: + from prompt_toolkit.document import Document + +# Rich → prompt_toolkit fragment conversion ------------------------------- + +_RICH_CONSOLE = Console() + + +def _rich_to_pt(renderable: Any, width: int) -> list[list[tuple[str, str]]]: + """Render a rich renderable to per-line ``(style, text)`` fragments. + + prompt_toolkit formatted-text fragments are ``(style, text)`` tuples — + the reverse of rich's Segment order. Swapping here means the Window + paints the text with its style instead of parsing the text as a style + string (which crashes on the ``❯`` prompt char). The Window only ever + requests the visible slice of these lines (virtual rendering); the + conversion itself is the only O(content) cost, and it is throttled for + the streaming block. + """ + options = _RICH_CONSOLE.options.update(width=width) + seg_lines = _RICH_CONSOLE.render_lines(renderable, options) + return [ + [(str(seg.style) if seg.style else "", seg.text) for seg in line] + for line in seg_lines + ] + + +def _markdown_lines(text: str, width: int) -> list[list[tuple[str, str]]]: + return _rich_to_pt(Markdown(text), width) + + +# Slash menu --------------------------------------------------------------- + +_SLASH_COMMANDS = { + "help": "show this command list", + "exit": "leave the session", + "quit": "leave the session", + "clear": "clear the transcript (history stays in the session)", +} + + +class SlashCompleter(Completer): + """Complete ``/`` while the input starts with ``/``.""" + + def get_completions( + self, document: Document, complete_event: Any + ) -> list[Completion]: + text = document.text_before_cursor + if not text.startswith("/"): + return [] + prefix = text[1:].lower() + out: list[Completion] = [] + for name, desc in _SLASH_COMMANDS.items(): + if name.startswith(prefix): + out.append( + Completion( + f"/{name}", + start_position=-len(text), + display=f"/{name}", + display_meta=desc, + ) + ) + return out + + +# Virtualized conversation control ----------------------------------------- + + +class ConversationControl(UIControl): + """Transcript control backed by a line list; renders the visible slice. + + ``create_content`` hands the Window a ``UIContent`` whose ``get_line`` + serves pre-rendered lines lazily — prompt_toolkit's renderer only asks + for the rows it paints, so a long session stays O(viewport) per frame. + """ + + def __init__(self) -> None: + self._lines: list[list[tuple[str, str]]] = [] + # Index where the live streaming block starts (re-rendered per + # delta); lines before it are finalized history. + self._stream_start: int | None = None + + @property + def line_count(self) -> int: + return len(self._lines) + + def append_fragments(self, fragments: list[tuple[str, str]]) -> None: + """Append one logical line of formatted text.""" + self._lines.append(fragments) + + def append_text(self, text: str, style: str = "") -> None: + self.append_fragments([(style, text)]) + + def begin_stream(self) -> None: + """Open a streaming block at the current end of the transcript.""" + self._stream_start = len(self._lines) + + def update_stream(self, fragments: list[list[tuple[str, str]]]) -> None: + """Replace the streaming block with re-rendered fragments.""" + if self._stream_start is None: + self.begin_stream() + del self._lines[self._stream_start :] + self._lines.extend(fragments) + + def end_stream(self) -> None: + """Close the streaming block (finalized into history).""" + self._stream_start = None + + def clear(self) -> None: + self._lines.clear() + self._stream_start = None + + def create_content(self, width: int, height: int) -> UIContent: + lines = self._lines + n = len(lines) + + def get_line(i: int) -> list[tuple[str, str]]: + if 0 <= i < n: + return lines[i] + return [] + + return UIContent( + # ``get_line`` returns the narrower ``list[tuple[str, str]]``; + # prompt_toolkit's renderer accepts it (the broader type also + # allows mouse-event tuples we never emit). + get_line=cast("Any", get_line), + line_count=n, + cursor_position=Point(max(0, n - 1), 0), + show_cursor=False, + ) + + +# Streaming application ----------------------------------------------------- + + +class StreamingChatApp: + """prompt_toolkit full-screen chat: virtualized transcript + prompt.""" + + def __init__(self, run_turn: Any) -> None: + self._run_turn = run_turn # async (prompt: str) -> None + self.control = ConversationControl() + self._following = True + self._turn_task: asyncio.Task[Any] | None = None + self._in_turn = False + + self._input = TextArea( + multiline=False, + prompt="❯ ", + completer=SlashCompleter(), + complete_while_typing=Condition( + lambda: (self._input.text or "").startswith("/") + ), + accept_handler=self._on_accept, + style="class:input", + ) + + self._window = Window( + content=self.control, + wrap_lines=False, + right_margins=[ScrollbarMargin()], + scroll_offsets=ScrollOffsets(top=0, bottom=0), + ) + self._window.vertical_scroll = 10**9 # pin to bottom at launch + + kb = KeyBindings() + self._bind_keys(kb) + + self._app = Application( + layout=Layout(HSplit([self._window, self._input], padding=0)), + key_bindings=kb, + mouse_support=True, + full_screen=True, + style=Style( + [ + ("input", "ansiteal"), + ("prompt", "ansiteal bold"), + ("muted", "ansibrightblack"), + ] + ), + ) + + def _bind_keys(self, kb: KeyBindings) -> None: + kb.add("pageup")(self._scroll(-1)) + kb.add("pagedown")(self._scroll(+1)) + kb.add("")(self._scroll(-1)) + kb.add("")(self._scroll(+1)) + kb.add("c-c")(self._on_ctrl_c) + + def _scroll(self, direction: int) -> Any: + def handler(event: Any) -> None: + info = self._window.render_info + page = max(1, (info.window_height - 2) if info else 10) + current = self._window.vertical_scroll or 0 + target = current + direction * page + max_scroll = max(0, info.content_height - info.window_height) if info else 0 + target = max(0, min(target, max_scroll + 1)) + self._window.vertical_scroll = target + self._following = bool( + direction > 0 and (info is None or target >= max_scroll) + ) + self._app.invalidate() + + return handler + + def _on_ctrl_c(self, event: Any) -> None: + if self._turn_task is not None and not self._turn_task.done(): + self._turn_task.cancel() + self.append_text("[interrupted]", style="class:muted") + self._following = True + self._app.invalidate() + + def _on_accept(self, buffer: Any) -> bool: + text = (buffer.text or "").strip() + buffer.text = "" + if not text: + return True + if text.startswith("/"): + self._handle_slash(text) + return True + self._start_turn(text) + return True + + def _handle_slash(self, text: str) -> None: + cmd = text[1:].split(None, 1)[0].lower() + if cmd in ("exit", "quit"): + self._app.exit() + elif cmd == "help": + for name, desc in _SLASH_COMMANDS.items(): + self.append_text(f" /{name:<6} {desc}", style="class:muted") + elif cmd == "clear": + self.control.clear() + else: + self.append_text( + f"unknown command: /{cmd} (try /help)", style="class:muted" + ) + self._app.invalidate() + + def _start_turn(self, prompt: str) -> None: + if self._in_turn: + self.append_text( + "[busy — wait for the turn to finish]", style="class:muted" + ) + self._app.invalidate() + return + self._in_turn = True + self.append_text("❯ " + prompt, style="class:prompt") + self.control.begin_stream() + self._turn_task = self._app.create_background_task(self._run_turn(prompt)) + + # Helpers used by the turn consumer -------------------------------- + + def append_text(self, text: str, style: str = "") -> None: + self.control.append_text(text, style) + self._pin_or_freeze() + + def append_rich(self, renderable: Any) -> None: + width = self._width() + for line in _rich_to_pt(renderable, width): + self.control.append_fragments(line) + self._pin_or_freeze() + + def stream_update(self, markdown_text: str) -> None: + width = self._width() + self.control.update_stream(_markdown_lines(markdown_text, width)) + self._pin_or_freeze() + + def stream_end(self) -> None: + self.control.end_stream() + + def mark_turn_done(self) -> None: + self._in_turn = False + self._pin_or_freeze() + + def _width(self) -> int: + with suppress(Exception): + return max(20, self._app.output.get_size().columns) + return 80 + + def _pin_or_freeze(self) -> None: + """Pin to the bottom while following; freeze the viewport otherwise.""" + if self._following: + self._window.vertical_scroll = 10**9 + self._app.invalidate() + + async def run(self) -> None: + await self._app.run_async() + if self._turn_task is not None and not self._turn_task.done(): + self._turn_task.cancel() + + +# Convenience entry --------------------------------------------------------- + + +async def run_streaming_chat(agent: Any) -> None: + """Run the streaming chat loop against an ``Agent`` (in-process). + + ``agent.run_stream(prompt)`` drives each turn; events render into the + virtualized transcript. Slash commands are handled by the app itself. + """ + from cothis.agent import ( + ContentDelta, + MaxIterationsError, + ToolCallEvent, + ToolResultEvent, + ) + + state = {"busy": False} + app_ref: dict[str, StreamingChatApp] = {} + + async def run_turn(prompt: str) -> None: + app = app_ref["app"] + if state["busy"]: + return + state["busy"] = True + accumulated = "" + stream = agent.run_stream(prompt) + try: + async for event in stream: + if isinstance(event, ToolCallEvent): + if accumulated: + app.stream_end() + app.append_text("") + app.append_text( + "calling " + + event.name + + "(" + + ", ".join(f"{k}={v!r}" for k, v in event.arguments.items()) + + ")", + style="class:muted", + ) + accumulated = "" + app.control.begin_stream() + elif isinstance(event, ToolResultEvent): + continue + elif isinstance(event, ContentDelta): + if event.kind and event.kind != "text": + continue + accumulated += event.text + app.stream_update(accumulated) + except MaxIterationsError as exc: + app.stream_end() + app.append_text(f"[red]Error:[/red] {exc}") + except asyncio.CancelledError: + app.stream_end() + app.append_text("[interrupted]", style="class:muted") + finally: + if accumulated: + app.stream_end() + app.mark_turn_done() + state["busy"] = False + + app = StreamingChatApp(run_turn=run_turn) + app_ref["app"] = app + try: + await app.run() + finally: + await agent.aclose() diff --git a/packages/cothis-tui/src/cothis/tui.py b/packages/cothis-tui/src/cothis/tui.py index d5a4b1a..dd67163 100644 --- a/packages/cothis-tui/src/cothis/tui.py +++ b/packages/cothis-tui/src/cothis/tui.py @@ -1,18 +1,19 @@ -"""``cothis.tui`` — Textual TUI core (#228). - -Grid layout (four rows, no dock hacks — every widget is placed by the -grid, ``Header { dock: none }`` overrides its default top-dock): - -- row 1 ``Header`` (1 row): app title bar. -- row 2 ``#main`` (``1fr``): ``SessionList`` (left) + ``ConversationView`` - (center). The sidebar is auto-hidden in single-session mode (≤1 session - listed or attached) so ``ConversationView`` takes the full width — the - sidebar only appears when the user can actually switch among sessions. -- row 3 ``TextArea`` input (``auto``): multiline input with Ctrl+Enter to - send. Auto-grows with content (``min-height: 3``) up to ``max-height: 8``, - then scrolls internally — a long prompt never squeezes the conversation. -- row 4 ``CothisFooter`` (1 row): one-line status bar — - model / session short-id / context pressure / active skills / run-state. +"""``cothis.tui`` — focused transcript TUI. + +The shell follows pi's alternate-screen model (``tui-plan.md``): one +full-height scrollable transcript with a fixed working dock beneath it, +and focus that always returns to the composer. + +- ``ConversationView`` — the transcript. Full viewport, the ONLY + scrolling context region. +- ``#composer`` — fixed dock: one ``TextArea`` input + a shortcut hint. + Auto-grows with content, scrolls internally past its cap. +- ``CothisFooter`` — fixed one-line status dock (model / session short-id + when multi-session / ctx pressure / skills / run state). +- Session navigation is TRANSIENT (``/sessions`` opens a picker overlay), + never a permanent sidebar. +- The input owns focus at launch and after every session switch / modal + dismissal — pi's editor-always-focused contract. Stream routing per the design-review sign-off (#228, 2026-07-24): ``ContentDelta(kind="text")`` renders as normal assistant content; @@ -21,17 +22,10 @@ as inline cards with a status badge. WS attach (``attach_ws`` / ``attach_session_ws``) + ``run_turn`` -forwarding (``send_run_turn``) landed with #252/#319. Multi-session -dispatch + the worktree picker (#234) are wired up; ``on_worktree_pick`` -is the spawn contract for production CLI wiring. - -Esc-to-interrupt: ``Binding('escape','interrupt_turn')`` -cancels the in-flight turn via the worker's ``interrupt_turn`` control -message (the same task-cancel primitive used for run_turn-supersede and -disconnect). The worker emits ``turn_started`` / ``turn_finished`` frames -that drive the footer's run-state cell + post-turn refresh; ``action_interrupt_turn`` -is a no-op unless a turn is running. The TUI does not speak ACP — the WS -bridge is the minimal, correct interrupt path. +forwarding (``send_run_turn``) land via the worker's WS bridge; +``on_worktree_pick`` is the spawn contract for production CLI wiring. +Esc-to-interrupt: ``Binding('escape','interrupt_turn')`` cancels the +in-flight turn via the worker's ``interrupt_turn`` control message. """ from __future__ import annotations @@ -40,18 +34,19 @@ import json import logging import time +from contextlib import suppress from pathlib import Path from typing import TYPE_CHECKING from textual.app import App, ComposeResult from textual.binding import Binding -from textual.containers import Horizontal, VerticalScroll +from textual.containers import Vertical, VerticalScroll from textual.reactive import reactive from textual.screen import ModalScreen +from textual.theme import Theme from textual.widgets import ( Button, Collapsible, - Header, Label, ListItem, ListView, @@ -72,57 +67,51 @@ _TOOL_STATUS_ICONS = {"running": ">>", "done": "OK", "failed": "XX"} +# pi's dark theme palette (packages/coding-agent/src/modes/interactive/theme/dark.json) +# — the visual identity the transcript shell is built on: near-black page +# (#18181e), slate user-message blocks (#343541), state-colored tool cards +# (pending #282832 / success #283228 / error #3c2828), teal accent (#8abeb7), +# warm markdown headings (#f0c674). Mapping the palette onto Textual's theme +# slots keeps every ``$var`` in the CSS resolving to a pi colour. +_PI_THEME = Theme( + name="pi-dark", + primary="#8abeb7", + secondary="#5f87ff", + accent="#8abeb7", + foreground="#d4d4d4", + background="#18181e", + surface="#1e1e24", + panel="#282832", + boost="#343541", + warning="#ffff00", + error="#cc6666", + success="#b5bd68", + dark=True, + variables={ + "tool-success": "#283228", + "tool-error": "#3c2828", + "heading": "#f0c674", + "link": "#81a2be", + "dim": "#666666", + }, +) + # Streaming-render throttle + finalisation (#407). Re-parsing Markdown on -# every text delta is O(S²) in the segment size (the parser runs ~1.3 µs/char -# and a 20 KB answer is ~4000 deltas → tens of seconds of parse work). While -# streaming, deltas accumulate into a plain ``Static`` (no Markdown parse) -# refreshed at most every ``_STREAM_REFRESH_S``; the segment is parsed into a -# ``Markdown`` widget ONCE, ``_STREAM_FINALIZE_S`` after the last delta (an -# idle-debounce proxy for turn-end — the worker emits no turn-end frame) or at -# a tool-call boundary. Net per-segment cost: O(S) appends + one O(S) parse. +# every text delta is O(S²) in the segment size. While streaming, deltas +# accumulate into a plain ``Static`` (no Markdown parse) refreshed at most +# every ``_STREAM_REFRESH_S``; the segment is parsed into a ``Markdown`` +# widget ONCE, ``_STREAM_FINALIZE_S`` after the last delta (an idle-debounce +# proxy for turn-end — the worker emits no turn-end frame) or at a +# tool-call boundary. Net per-segment cost: O(S) appends + one O(S) parse. _STREAM_REFRESH_S = 0.05 _STREAM_FINALIZE_S = 0.3 -# --------------------------------------------------------------------- -# Skill selection persistence (#235) -# --------------------------------------------------------------------- - - -# Skill-selection persistence (save/load_skill_selection) lives in -# ``cothis.skills`` so the worker subprocess can import it without the -# Textual cost (#415). Imported locally where used below. - - # --------------------------------------------------------------------- # Widgets # --------------------------------------------------------------------- -class SessionList(ListView): - """Left pane — sessions from the session table. - - Populated by ``CothisApp.refresh_session_list`` (driven from - ``Storage.list_sessions_in_cwd_tree``). Each row's label carries - the session's cwd + worktree branch when applicable (#234 AC #3); - rows are sorted by cwd for visual grouping by worktree. - Selection fires ``on_list_view_selected`` → ``on_session_selected``. - - Display is adaptive: ``CothisApp._sync_sidebar`` hides the pane when - there's at most one session to switch among (single-session mode) so - the conversation takes the full width; the pane appears when a second - session is listed or attached. - """ - - DEFAULT_CSS = """ - SessionList { - width: 24; - dock: left; - border: round $primary; - } - """ - - class ToolCallCard(Static): """Inline card for one tool dispatch — name + status badge. @@ -136,13 +125,24 @@ class ToolCallCard(Static): ToolCallCard { margin: 0 0 0 2; padding: 0 1; - background: $surface; - border-left: thick $accent; + background: $panel; + border-left: thick $secondary; + } + ToolCallCard.tool-success { + background: #283228; + border-left: thick #b5bd68; + } + ToolCallCard.tool-error { + background: #3c2828; + border-left: thick #cc6666; } """ def __init__( - self, name: str, status: str = "running", call_id: str | None = None, + self, + name: str, + status: str = "running", + call_id: str | None = None, ) -> None: self._name = name self._status = status @@ -151,6 +151,14 @@ def __init__( def set_status(self, status: str) -> None: self._status = status + for cls in ("tool-pending", "tool-success", "tool-error"): + self.remove_class(cls) + if status == "done": + self.add_class("tool-success") + elif status == "failed": + self.add_class("tool-error") + else: + self.add_class("tool-pending") self.update(self._render_str()) def _render_str(self) -> str: @@ -159,7 +167,7 @@ def _render_str(self) -> str: class ConversationView(VerticalScroll): - """Center pane — scrollable Markdown + tool-call cards. + """Full-viewport transcript — scrollable Markdown + tool-call cards. ``append_delta`` is the primary API the WS client calls per ``assistant_delta`` message. ``append_tool_call`` mounts a card @@ -170,15 +178,43 @@ class ConversationView(VerticalScroll): DEFAULT_CSS = """ ConversationView { - width: 2fr; - border: round $accent; - padding: 0 1; + width: 1fr; + height: 1fr; + padding: 1 2; + scrollbar-gutter: stable; + } + ConversationView > .user-message { + background: $boost; + color: $text; + padding: 1 2; + margin: 0 0 1 0; + border-left: thick $accent; + } + ConversationView > .user-message > Markdown { + color: $text; + } + ConversationView MarkdownH1, + ConversationView MarkdownH2, + ConversationView MarkdownH3 { + color: #f0c674; + } + ConversationView MarkdownParagraph, + ConversationView MarkdownStream { + color: $text; + } + ConversationView MarkdownBlockQuote { + color: $text-muted; + border-left: thick $secondary; + } + ConversationView MarkdownFence { + background: $panel; + color: #b5bd68; } ConversationView > Collapsible.thinking-block { margin: 0 0 0 2; padding: 0 1; - border-left: thick $primary-darken-2; - color: $text-disabled; + border-left: thick $secondary; + color: $text-muted; } """ @@ -188,31 +224,28 @@ def __init__(self) -> None: # so the per-delta append path stays linear. ``renderable_str`` joins # lazily — only the final Markdown parse needs the joined string. self._text_buf: list[str] = [] - # Thinking-segment accumulator. Kept separate from - # ``_text_buf`` so ``renderable_str`` (the text-segment source, read - # by tests + inspection) stays free of reasoning content. Finalised - # into a collapsed, dimmed ``Collapsible`` so the model's reasoning is - # available but doesn't clutter the conversation. + # Thinking-segment accumulator. Kept separate from ``_text_buf`` so + # ``renderable_str`` (the text-segment source, read by tests + + # inspection) stays free of reasoning content. Finalised into a + # collapsed, dimmed ``Collapsible``. self._thinking_buf: list[str] = [] # Plain-text widget shown WHILE a segment streams (#407). Mounting it - # avoids re-parsing Markdown on every delta; it is swapped for a - # ``Markdown`` widget (one parse) at finalisation. + # avoids re-parsing Markdown on every delta; swapped for a ``Markdown`` + # widget (one parse) at finalisation. self._stream_static: Static | None = None # Monotonic timestamp of the last plain-text refresh (throttle). self._last_stream_refresh: float = 0.0 # Idle-finalise debounce timer (#407): rearmed per delta; fires # ``_STREAM_FINALIZE_S`` after the LAST delta to parse Markdown once. - # The worker emits no turn-end frame, so idle is the turn-end proxy. self._finalize_timer: Timer | None = None # True once the current buffer has been parsed into a mounted Markdown - # widget. Gates idempotent re-finalise (timer then a boundary) and - # signals ``append_delta`` to start a fresh segment when text resumes. - # The buffer is RETAINED across finalise so ``renderable_str`` (tests - # + inspection) still reflects the last segment's text. + # widget. Gates idempotent re-finalise and signals ``append_delta`` to + # start a fresh segment when text resumes. The buffer is RETAINED + # across finalise so ``renderable_str`` still reflects the segment. self._finalized: bool = False - # Cards indexed by ``call_id`` (#252 item 4) so result frames - # can update the matching card's status badge without ambiguity - # when the same tool runs twice in one turn. + # Cards indexed by ``call_id`` (#252 item 4) so result frames can + # update the matching card's status badge without ambiguity when the + # same tool runs twice in one turn. self._cards_by_call_id: dict[str, ToolCallCard] = {} @property @@ -225,20 +258,14 @@ def append_delta(self, kind: str, text: str) -> None: ``kind="text"`` → accumulate (O(1)) + cheap plain-text refresh; the segment is parsed into Markdown ONCE at finalisation, not per delta - (#407 — per-delta Markdown re-parse was O(S²) in segment size). - ``kind="thinking"`` → accumulate into ``_thinking_buf`` (separate - from the text buffer) and finalise into a collapsed, dimmed - ``Collapsible`` so the model's reasoning is available without - cluttering the conversation. A kind switch (thinking → text or vice - versa) finalises the active segment first, so each kind renders as - its own block in event order. + (#407). ``kind="thinking"`` → accumulate into ``_thinking_buf`` and + finalise into a collapsed, dimmed ``Collapsible``. A kind switch + finalises the active segment first, so each kind renders as its own + block in event order. """ if kind == "text": # Close any streaming thinking segment so text is its own block. self._finalize_thinking() - # If the previous segment already finalised (idle timer fired, or - # a user/tool boundary), start a fresh segment below it — the old - # Markdown widget stays mounted; the buffer + handles reset. if self._finalized: self._text_buf = [] self._finalized = False @@ -246,42 +273,38 @@ def append_delta(self, kind: str, text: str) -> None: self._refresh_stream() self._arm_finalize() elif kind == "thinking": - # Close any streaming text segment so thinking is its own block. self._finalize_segment() self._thinking_buf.append(text) self._arm_finalize() def append_user_message(self, text: str) -> None: - """Render a user prompt with a distinct prefix. + """Render a user prompt as a background-tinted block (pi's ``userMessageBg`` box). - Finalises any streaming segment first (so the prompt is its own - block), then renders the escaped prompt as Markdown. This is one - call per user message — not per token — so it is not on the hot - streaming path. User text is Markdown-escaped (brackets) so - injected links or markup can't activate inside the widget. + Finalises any streaming segment first so the prompt is its own + block, then mounts the prompt as a Markdown widget with the + ``.user-message`` class — a full-width tinted box, not a ``you:`` + prefix line. Text is Markdown-escaped (brackets) so injected + links or markup can't activate inside the widget. """ safe = text.replace("[", "\\[").replace("]", "\\]") self._finalize_active() self._text_buf = [] self._finalized = False - self._text_buf.append(f"\n> **you**: {safe}\n\n") - self._finalize_segment() + at_bottom = self._at_bottom() + self.mount(Markdown(safe, classes="user-message")) + self._follow(at_bottom) def append_tool_call( - self, name: str, status: str = "running", call_id: str | None = None, + self, + name: str, + status: str = "running", + call_id: str | None = None, ) -> ToolCallCard: """Mount an inline tool-call card; return it for status updates. Finalises the active text segment (parses its Markdown once) and resets the buffer so the next text delta starts a fresh segment - below this card. Without the reset, all text would accumulate in - one segment and the card would render below all of it — violating - the "tool calls render as inline cards" rule (#228 Rule 3). - - ``call_id`` indexes the card in ``_cards_by_call_id`` so a - subsequent ``tool_call_result_pointer`` frame can find it (#252 - item 4). ``None`` keeps the legacy un-indexed behaviour (no - status update will land for this card). + below this card. """ self._finalize_active() self._text_buf = [] @@ -295,14 +318,12 @@ def append_tool_call( return card def update_tool_call_status( - self, call_id: str, *, is_error: bool, + self, + call_id: str, + *, + is_error: bool, ) -> ToolCallCard | None: - """Flip a card's status badge to ``done`` / ``failed`` by call_id. - - Returns the card if found, ``None`` if no card is indexed under - ``call_id`` (e.g. the start frame predates this wiring, or the - card was mounted by a caller that didn't pass call_id). - """ + """Flip a card's status badge to ``done`` / ``failed`` by call_id.""" card = self._cards_by_call_id.get(call_id) if card is None: return None @@ -314,74 +335,43 @@ def render_replayed_message(self, msg: dict) -> None: Replay-on-attach reuses the existing primitives — there is no parallel renderer. A user text block routes through - ``append_user_message`` (multiple text blocks in one message are - concatenated into one call; ``tool_result`` blocks are skipped - because the matching ``tool_use`` card already renders on the - assistant side). An assistant text block mounts one Markdown - widget via ``_finalize_segment`` (the buffer is seeded directly - so the streaming-throttle path is bypassed). A ``thinking`` block - mounts the SAME collapsed, dimmed ``Collapsible`` the live path - uses (via ``_mount_thinking_block``) so replay matches live; an - ``image`` block is silently skipped (deferred). A ``tool_use`` - block mounts a ``done``-status card (historical calls are already - finished). - - Leaves the view state clean (``_finalized=True``, - ``_stream_static=None``) after each assistant text block, so the - next live ``append_delta`` starts a fresh segment — the existing - streaming path stays behaviour-identical. + ``append_user_message``; an assistant text block mounts one Markdown + segment; a tool_use block mounts a card (tool_result blocks are + skipped — the matching card already renders on the assistant side). """ role = msg.get("role") - blocks = msg.get("content", []) or [] + content = msg.get("content") or [] if role == "user": - # Concatenate text blocks into one user-echo call; skip - # tool_result (the tool_use card already shows on the - # assistant side). - text_parts = [ - b.get("text", "") - for b in blocks - if b.get("type") == "text" - ] - if text_parts: - self.append_user_message("\n".join(text_parts)) + texts = [b.get("text", "") for b in content if b.get("type") == "text"] + if texts: + self.append_user_message(" ".join(texts)) return - if role == "assistant": - for b in blocks: - btype = b.get("type") - if btype == "text" and b.get("text"): - # Seed the buffer + mark unfinalised so ``_finalize_segment`` - # mounts one Markdown widget for this block (bypassing the - # streaming Static + throttle path). ``_finalize_segment`` - # removes any mounted Static itself (idempotent), so we do - # NOT pre-null ``_stream_static`` — that would skip its - # cleanup and orphan a mounted Static. - self._text_buf = [b["text"]] - self._finalized = False - self._finalize_segment() - elif btype == "thinking" and b.get("thinking"): - # Reuse the live path's primitive so replay matches live - # — reasoning mounts as a collapsed, dimmed Collapsible. - self._mount_thinking_block(b["thinking"]) - elif btype == "tool_use": - self.append_tool_call( - b.get("name", "?"), - status="done", - call_id=b.get("id"), - ) - # image / tool_result: deferred. + if role != "assistant": + return + for block in content: + btype = block.get("type") + if btype == "text": + self.append_delta("text", block.get("text", "")) + elif btype == "thinking": + self.append_delta( + "thinking", + block.get("thinking") or block.get("text", ""), + ) + elif btype == "tool_use": + name = block.get("name") or block.get("tool") or "?" + self.append_tool_call( + name, + status="done", + call_id=block.get("id") or block.get("call_id"), + ) + # image / tool_result: deferred. + # Replay is a batch operation: force-finalise any pending segment so + # the last assistant text mounts deterministically instead of waiting + # on the idle-finalise timer (which a test pause may not reach). + self._finalize_active() def clear(self) -> None: - """Unmount every rendered message + reset the streaming state. - - Fired on a session change (``on_active_session_changed``): the - previous session's messages are wiped so the target session's - replayed history is the only thing on screen. Resets the - streaming accumulators (``_text_buf`` / ``_thinking_buf`` / - ``_stream_static`` / ``_finalized``) and the ``_cards_by_call_id`` - index so a fresh live stream after the swap starts clean, and - stops the idle-finalise timer so a pending parse can't fire into - the now-empty view. - """ + """Unmount every rendered message + reset the streaming state.""" if self._finalize_timer is not None: self._finalize_timer.stop() self._finalize_timer = None @@ -394,13 +384,7 @@ def clear(self) -> None: self._finalized = False def _refresh_stream(self) -> None: - """Mount/refresh the plain-text streaming widget, throttled. - - No Markdown parse here — that is the #407 win. The first delta of a - segment mounts a ``Static``; later deltas update it at most every - ``_STREAM_REFRESH_S`` (cheap text layout, no parser), so the per-delta - cost stays O(1) amortised rather than O(S) per call. - """ + """Mount/refresh the plain-text streaming widget, throttled.""" if self._stream_static is None: at_bottom = self._at_bottom() self._stream_static = Static("".join(self._text_buf)) @@ -420,48 +404,29 @@ def _arm_finalize(self) -> None: if self._finalize_timer is not None: self._finalize_timer.stop() self._finalize_timer = self.set_timer( - _STREAM_FINALIZE_S, self._finalize_active, + _STREAM_FINALIZE_S, + self._finalize_active, ) def _finalize_active(self) -> None: - """Flush whichever segment(s) are streaming — text and/or thinking. - - The idle-finalise timer's callback, and the boundary flush called by - ``append_tool_call`` / ``append_user_message``. Order matters only in - that text mounts before thinking when both are pending (the model - streams text then a trailing thinking block rarely); DOM order is - otherwise driven by the kind-switch finalisation in ``append_delta``. - """ + """Flush whichever segment(s) are streaming — text and/or thinking.""" self._finalize_segment() self._finalize_thinking() def _mount_thinking_block(self, text: str) -> None: - """Mount one thinking block as a collapsed, dimmed ``Collapsible``. - - Shared by the live streaming path (``_finalize_thinking``) and the - replay-on-attach path (``render_replayed_message``) so both render - reasoning through the SAME primitive — they cannot drift. Collapsed - by default (``Collapsible`` ctor) with the ``.thinking-block`` CSS - class so reasoning stays dimmed + out of the way until the user - expands it — the "dimmed/collapsed, toggle to expand" contract - documented on ``ContentDelta``. - """ + """Mount one thinking block as a collapsed, dimmed ``Collapsible``.""" at_bottom = self._at_bottom() self.mount( Collapsible( - Markdown(text), title="reasoning", classes="thinking-block", + Markdown(text), + title="reasoning", + classes="thinking-block", ) ) self._follow(at_bottom) def _finalize_thinking(self) -> None: - """Mount the accumulated thinking as a collapsed, dimmed ``Collapsible``. - - Idempotent: a no-op when ``_thinking_buf`` is empty. The buffer is - cleared on mount so a subsequent thinking segment starts fresh. - Delegates to ``_mount_thinking_block`` (shared with the replay path) - so the live + replay renderers cannot drift. - """ + """Mount the accumulated thinking as a collapsed ``Collapsible``.""" if not self._thinking_buf: return source = "".join(self._thinking_buf) @@ -469,17 +434,10 @@ def _finalize_thinking(self) -> None: self._mount_thinking_block(source) def _finalize_segment(self) -> None: - """Swap the streaming ``Static`` for a ``Markdown`` widget (one parse). - - Called by the idle-finalise timer (turn-end proxy) and by the segment - boundaries (``append_tool_call`` / ``append_user_message``). Idempotent: - a no-op when the buffer is empty or ``_finalized`` is already set. The - buffer is retained, so ``renderable_str`` still reflects the segment. - """ + """Swap the streaming ``Static`` for a ``Markdown`` widget (one parse).""" if self._finalize_timer is not None: self._finalize_timer.stop() self._finalize_timer = None - # Idempotent: nothing to parse, or this segment already parsed. if self._finalized or not self._text_buf: return at_bottom = self._at_bottom() @@ -493,34 +451,47 @@ def _finalize_segment(self) -> None: self._follow(at_bottom) def _at_bottom(self) -> bool: - """True when the view is within a line of the bottom. - - The "user is watching the stream" state. Captured BEFORE a content - change so a user who scrolled up to read earlier output isn't yanked - back to the bottom on the next delta (#409). - """ + """True when the view is within a line of the bottom (#409).""" return self.scroll_y >= self.max_scroll_y - 1 def _follow(self, was_at_bottom: bool) -> None: """Re-pin to the bottom iff the user was already there. - Synchronous (``immediate=True``): Textual updates the container's - virtual size during ``mount`` / ``Static.update``, so the new - ``max_scroll_y`` is current when ``scroll_end`` reads it — no need to - defer past a refresh, which would race a user's manual scroll-up. + The immediate pin reads the max_scroll_y current at the moment the + content changed, which can sit a couple of lines short of the + settled height (mount/update layout lands a beat later on slower + runners). A deferred re-check re-pins then — but only if the user + has NOT scrolled since the pin, preserving the #409 no-yank + contract for someone who scrolled up to read earlier output. """ if was_at_bottom: self.scroll_end(animate=False, immediate=True) + self.call_after_refresh(self._recheck_follow, self.scroll_y) + def _recheck_follow(self, pinned_at: float, attempts: int = 0) -> None: + """Re-pin to the settled bottom after a layout pass, bounded. -class ConfigMenuModal(ModalScreen[set[str] | None]): - """Config menu modal — toggleable skill entries (#235). + ``pinned_at`` is the scroll position the immediate pin reached. If + the user scrolled up since then (scroll_y dropped below it), do not + yank them back (#409). Otherwise re-pin to the now-settled + max_scroll_y; if the height grew again mid-check, re-check once + more (bounded) rather than leaving the view a couple of lines + short on a loaded runner. + """ + if self.scroll_y < pinned_at or attempts >= 3: + return + self.scroll_end(animate=False, immediate=True) + if self.scroll_y < self.max_scroll_y - 1: + self.call_after_refresh(self._recheck_follow, pinned_at, attempts + 1) - Each skill is a ``Button`` that toggles selected/unselected on click. - ``Done`` dismisses with the selected set; ``Esc`` dismisses with - ``None`` (cancel). The selection persists across sessions via - ``save/load_skill_selection`` (#415). - """ + +# --------------------------------------------------------------------- +# Modals +# --------------------------------------------------------------------- + + +class ConfigMenuModal(ModalScreen[set[str] | None]): + """Skill selection menu (Ctrl-M).""" DEFAULT_CSS = """ ConfigMenuModal { @@ -528,9 +499,7 @@ class ConfigMenuModal(ModalScreen[set[str] | None]): } ConfigMenuModal > Label { padding: 0 2; - } - ConfigMenuModal > Button.skill-toggle.-active { - background: $accent; + width: 100%; } """ @@ -538,18 +507,20 @@ class ConfigMenuModal(ModalScreen[set[str] | None]): def __init__(self, skills: list[str], *, selected: set[str] | None = None) -> None: self._skills = skills - # Seed from the persisted selection (caller passes it) so the menu - # reflects what was saved last time (#415). self._selected: set[str] = set(selected) if selected else set() super().__init__() def compose(self) -> ComposeResult: - yield Label("Configurable Skills", id="menu-title") - if not self._skills: - yield Label("(no skills configured)", id="menu-empty") + yield Label("Active skills (click to toggle)", id="config-prompt") for name in self._skills: - classes = "skill-toggle -active" if name in self._selected else "skill-toggle" - yield Button(name, id=f"skill-{name}", classes=classes) + classes = ( + "skill-toggle -active" if name in self._selected else "skill-toggle" + ) + yield Button( + f"{'[x]' if name in self._selected else '[ ]'} {name}", + id=f"skill-{name}", + classes=classes, + ) yield Button("Done", id="menu-done") def action_dismiss_modal(self) -> None: @@ -560,22 +531,16 @@ def on_button_pressed(self, event: Button.Pressed) -> None: if bid == "menu-done": self.dismiss(self._selected) elif bid.startswith("skill-"): - skill = bid[len("skill-"):] - if skill in self._selected: - self._selected.discard(skill) - event.button.remove_class("-active") + name = bid[len("skill-") :] + if name in self._selected: + self._selected.discard(name) else: - self._selected.add(skill) - event.button.add_class("-active") + self._selected.add(name) + event.button.label = f"{'[x]' if name in self._selected else '[ ]'} {name}" class AskUserModal(ModalScreen[str | None]): - """Modal for interactive tool questions (#229). - - Shows ``prompt`` + one ``Button`` per choice. On click: dismiss - with the chosen value. Esc or Cancel: dismiss with ``None`` - (the caller treats ``None`` as "user declined"). - """ + """Mid-turn question from a tool (``ask_user``).""" DEFAULT_CSS = """ AskUserModal { @@ -591,7 +556,7 @@ class AskUserModal(ModalScreen[str | None]): def __init__(self, prompt: str, choices: list[str]) -> None: self._prompt = prompt - self._choices = list(choices) + self._choices = choices super().__init__() def compose(self) -> ComposeResult: @@ -604,22 +569,15 @@ def action_dismiss_modal(self) -> None: self.dismiss(None) def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "ask-cancel": + bid = event.button.id or "" + if bid == "ask-cancel": self.dismiss(None) - elif event.button.id and event.button.id.startswith("choice-"): - self.dismiss(event.button.id[len("choice-"):]) + elif bid.startswith("choice-"): + self.dismiss(bid[len("choice-") :]) class WorktreePickerModal(ModalScreen[str | None]): - """Modal for choosing a git worktree for a new session (#234). - - Shows one ``Button`` per worktree (label = branch name when on a - branch, else the path basename). On click: dismiss with the - worktree's ``path`` as a string — that's what the caller stuffs - into the new session's ``cwd``. Esc or Cancel: dismiss with - ``None`` (the caller treats ``None`` as "user cancelled, no new - session"). - """ + """Choose a git worktree for a new session.""" DEFAULT_CSS = """ WorktreePickerModal { @@ -639,9 +597,6 @@ def __init__(self, worktrees: list[Worktree]) -> None: def compose(self) -> ComposeResult: if not self._worktrees: - # Empty-list UX: the default "Pick a worktree" label would - # mislead — there's nothing to pick. The current-directory - # button is the only useful option here (besides Cancel). yield Label( "No worktrees found. Pick the current directory below, " "or run `git worktree add ` outside cothis, then retry.", @@ -649,16 +604,9 @@ def compose(self) -> ComposeResult: ) else: yield Label("Pick a worktree for the new session", id="worktree-prompt") - # Index-based IDs: paths contain ``/`` which Textual IDs reject. - # The button label is branch name (preferred) or path basename - # for detached HEAD — branch is what the user thinks in terms of. for i, wt in enumerate(self._worktrees): label = wt.branch or wt.path.name yield Button(label, id=f"wt-{i}") - # Always-present fallback: a session scoped to the current cwd - # (the directory the TUI was launched from). Gives users a path - # forward in a non-git cwd, and a quick "just use here" option - # even when worktrees are available. yield Button("Current directory", id="worktree-cwd") yield Button("Cancel", id="worktree-cancel") @@ -676,25 +624,105 @@ def on_button_pressed(self, event: Button.Pressed) -> None: self.dismiss(str(self._worktrees[idx].path)) +class SessionPickerModal(ModalScreen[str | None]): + """Transient session switcher — never a permanent sidebar. + + One row per known session (id, label); the active session is + marked ``•``. Enter on a row (or a click) dismisses with the + session id; Esc / Cancel dismisses with ``None``. Focus lands on + the list so the keyboard can drive the switch immediately. + """ + + DEFAULT_CSS = """ + SessionPickerModal { + align: center middle; + background: $background 80%; + } + #session-picker { + width: 72; + max-width: 90%; + height: 70%; + max-height: 24; + padding: 1 2; + border: round $accent; + background: $surface; + } + #session-picker-title { + height: 1; + color: $text; + text-style: bold; + } + #session-picker-list { + height: 1fr; + margin: 1 0; + } + #session-picker-cancel { + width: 100%; + } + """ + + BINDINGS = [("escape", "dismiss_modal", "Cancel")] + + def __init__(self, sessions: list[tuple[str, str]], active: str | None) -> None: + self._sessions = sessions + self._active = active + super().__init__() + + def compose(self) -> ComposeResult: + with Vertical(id="session-picker"): + yield Label("Sessions", id="session-picker-title") + yield ListView( + *[ + ListItem( + Label( + ("• " if session_id == self._active else " ") + label, + ), + id=f"p_{session_id}", + ) + for session_id, label in self._sessions + ], + id="session-picker-list", + ) + yield Button("Cancel", id="session-picker-cancel") + + def on_mount(self) -> None: + self.query_one("#session-picker-list", ListView).focus() + + def action_dismiss_modal(self) -> None: + self.dismiss(None) + + def on_list_view_selected(self, event: ListView.Selected) -> None: + item_id = event.item.id or "" + if item_id.startswith("p_"): + self.dismiss(item_id[2:]) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "session-picker-cancel": + self.dismiss(None) + + +# --------------------------------------------------------------------- +# Status dock +# --------------------------------------------------------------------- + + class CothisFooter(Static): - """One-line status bar — surfaces run-state + key signals at a glance. + """One-line status dock — fixed beneath the composer. Renders up to five cells left-to-right: ``model | [session: |] ctx: | skills:[a,b] | state:`` * ```` — first 8 chars of the active session id. Shown ONLY - when more than one session is attached (``CothisApp._attached_session_count - > 1``); in the common single-session case the id is redundant noise and - is hidden. - * ```` — the ``PressureLevel`` value string (``none`` / ``low`` / - ``medium`` / ``high`` / ``critical``) or ``?`` when unknown. + when more than one session is attached; in the common single-session + case the id is redundant noise and is hidden. + * ```` — the ``PressureLevel`` value string (``none`` / + ``low`` / ``medium`` / ``high`` / ``critical``) or ``?`` when unknown. * ``skills`` — comma-joined sorted active-skills set, or ``-`` when empty. * ``run_state`` — ``idle`` / ``running`` / ``interrupted``. - The widget itself holds no state; it is repainted by ``CothisApp``'s - combined ``_refresh_footer`` watcher whenever one of the footer - reactives flips. No polling, no per-second timer. + The widget holds no state; it is repainted by ``CothisApp``'s combined + ``_refresh_footer`` watcher whenever one of the footer reactives flips. """ DEFAULT_CSS = """ @@ -713,11 +741,23 @@ class CothisFooter(Static): class CothisApp(App): - """Textual app shell — adaptive pane layout, single or multi session. + """Focused transcript shell with a transient session index. + + Composition (top → bottom): + + - ``ConversationView`` — full-viewport scrollable transcript. + - ``#composer`` — fixed dock: input + shortcut hint. + - ``CothisFooter`` — fixed status dock. - Keymap per design-review sign-off (#228, 2026-07-24): + The input is focused at launch and restored after every transient UI + (pi's editor-always-focused model). All app commands use modified + keys so none are shadowed by the focused input. + + Keymap: | Ctrl+Enter | send prompt | + | Ctrl+N | new session | + | Ctrl+M | config menu | | Esc | interrupt / clear / dismiss overlay | | Ctrl+C | quit | """ @@ -725,49 +765,66 @@ class CothisApp(App): TITLE = "cothis" CSS = """ Screen { - layout: grid; - grid-size: 1 4; - grid-rows: 1 1fr auto 1; - grid-columns: 1fr; + layout: vertical; + background: $background; } - Header { - dock: none; + ConversationView { + height: 1fr; + width: 1fr; } - SessionList > ListItem.active-session { - background: $boost; - text-style: bold; + #composer { + height: auto; + min-height: 6; + max-height: 12; + padding: 0 1; + border-top: solid $panel; + background: $surface; + } + #status-line { + height: 1; + padding: 0 1; + color: $text-muted; } TextArea#input { height: auto; min-height: 3; max-height: 8; - border: round $secondary; + border: round $panel; + } + TextArea#input:focus { + border: round $accent; + } + #composer-hint { + height: 1; + padding: 0 1; + color: $text-disabled; + } + CothisFooter#footer { + height: 1; + background: $boost; + color: $text-disabled; + padding: 0 1; } """ BINDINGS = [ Binding("ctrl+enter", "send_prompt", "Send", show=False), Binding("ctrl+c", "quit", "Quit", show=False), - Binding("n", "new_session", "New session", show=True), + # Ctrl+N (not bare ``n``): the input holds focus by default and bare + # keys are text, so a bare ``n`` would type into the prompt instead of + # opening the picker. Modified keys route through the app binding even + # while the input is focused. + Binding("ctrl+n", "new_session", "New session", show=True), Binding("ctrl+m", "menu", "Menu", show=True), - # Esc → interrupt a running turn. No ``priority=True``: an - # app-level priority binding would steal Esc from pushed modal - # screens (modals install their own non-priority Esc binding to - # dismiss). Textual resolves bindings top-screen-first for non- - # priority bindings, so a modal's Esc wins while it's open and the - # app binding fires only when no modal is pushed. Esc is not a text - # character, so a focused TextArea does not consume it — the - # binding routes to the app the same way ctrl+enter does. + # Esc → interrupt a running turn. Non-priority: modals install their + # own Esc binding to dismiss, and Textual resolves bindings + # top-screen-first — the modal's Esc wins while it's open. Binding("escape", "interrupt_turn", "Interrupt", show=False), ] # ----------------------------------------------------------------- - # Run-state + footer reactives — the app's first reactives. - # Plain attrs would work, but reactives let a single combined watcher - # (``_refresh_footer``) re-render the footer widget on any change with - # no ad-hoc call sites. ``run_state`` is a constrained literal set - # (idle|running|interrupted) so the Esc guard ``run_state != "running"`` - # stays narrow + ty-friendly. + # Footer reactives — the status dock repaints through one combined + # watcher whenever any cell flips. # ----------------------------------------------------------------- run_state: reactive[str] = reactive("idle") footer_model: reactive[str] = reactive("") @@ -775,44 +832,73 @@ class CothisApp(App): footer_pressure: reactive[str] = reactive("") footer_skills: reactive[list[str]] = reactive[list[str]](list) - def action_new_session(self) -> None: - """Trigger the new-session flow (#234). - - Lists git worktrees visible from ``Path.cwd()`` and forwards them - to ``on_new_session`` — an overridable hook the subclass / caller - wires to a picker UI. Default hook logs + returns; subclasses - override to mount a modal that lets the user choose where to - create the session (then call ``Session.new`` + ``attach_ws``). - - Subprocess bound: ``list_worktrees`` runs ``git worktree list`` - synchronously with a 5s timeout (the helper's safety net). - Acceptable here because the action is user-triggered (Ctrl-N) - and the bound timeout prevents indefinite blocking. + # WS attach state (#252 item 1). ``None`` until ``attach_ws`` runs. + # Mutable collections are instance attrs (``__init__``), not class attrs, + # so concurrent app instances never share state. + + def __init__(self) -> None: + super().__init__() + self._ws: Any = None + self._ws_pump_task: asyncio.Task[None] | None = None + # Multi-session WS connections (#230). Keyed by session_id; each + # entry has its own pump task. + self._ws_by_session: dict[str, Any] = {} + self._ws_pump_tasks_by_session: dict[str, asyncio.Task[None]] = {} + # Active session id (#230) — the session the user is interacting with. + self._active_session_id: str | None = None + # Session DB last used to populate the session index / replay history. + self._db_path: Path | None = None + # Session index for the transient picker: (id, label) rows populated + # by ``refresh_session_list``. The transcript never hosts a sidebar; + # this is the single source the ``/sessions`` picker renders from. + self._session_rows: list[tuple[str, str]] = [] + + def compose(self) -> ComposeResult: + # The transcript owns all flexible height. Everything below it is + # fixed to the bottom — pi's transcript + dock model: + # [status line] [input] [hint] above the status bar. + yield ConversationView() + with Vertical(id="composer"): + yield Label("", id="status-line") + yield TextArea(id="input") + yield Static( + "Ctrl+Enter send · /sessions switch · Ctrl+N new session · Ctrl+M menu", + id="composer-hint", + ) + yield CothisFooter("", id="footer") + + async def on_mount(self) -> None: + """Focus the composer input on launch — the persistent-focus contract.""" + self._refresh_footer() + self._refocus_input() + + def _refocus_input(self) -> None: + """Return focus to the composer input — the default + persistent focus. + + Textual does NOT restore focus when a modal pops (verified + empirically: focus stays on the modal's button), so every dismiss + callback re-focuses explicitly — pi's editor-always-focused model. + Guarded for the not-yet-mounted case. """ + with suppress(Exception): # compose may not have run yet + self.query_one("#input", TextArea).focus() + + # ----------------------------------------------------------------- + # New session (#234) — Ctrl+N → worktree picker → on_worktree_pick. + # ----------------------------------------------------------------- + + def action_new_session(self) -> None: + """List git worktrees visible from ``Path.cwd()``; open the picker.""" from cothis.git import list_worktrees worktrees = list_worktrees(Path.cwd()) self.on_new_session(worktrees) def on_new_session(self, worktrees: list) -> None: - """Mount ``WorktreePickerModal``; route the chosen path to ``on_worktree_pick`` (#234). - - Hook fired by ``action_new_session`` (the ``n`` keypress). The - picker shows one ``Button`` per worktree; on dismiss the chosen - path (or ``None`` for Esc / Cancel) is forwarded to - ``on_worktree_pick`` — the single entry point for "create a - session bound to this cwd". - - Subclasses can also override ``on_new_session`` itself to - capture the worktree list without mounting the modal (existing - tests do this). - """ - logger.info( - "tui: new-session action fired; %d worktree(s) visible", - len(worktrees), - ) + """Mount ``WorktreePickerModal``; route the chosen path to ``on_worktree_pick``.""" def _on_dismiss(value: str | None) -> None: + self._refocus_input() if value is None: logger.info("tui: new-session cancelled (no worktree picked)") return @@ -821,17 +907,11 @@ def _on_dismiss(value: str | None) -> None: self.push_screen(WorktreePickerModal(worktrees), _on_dismiss) def on_worktree_pick(self, path: str) -> None: - """Hook fired when the user picks a worktree for a new session (#234). - - Default: log the choice. The CLI / caller overrides this to - call ``Supervisor.spawn_worker`` + ``SessionStorage.new`` + - ``attach_session_ws`` with the picked path as the session cwd. + """Hook fired when the user picks a worktree for a new session. - Kept as a separate hook so the TUI doesn't need to know about - the Supervisor/SessionStorage APIs — same inversion as - ``attach_ws`` (caller decides how the worker was spawned). - Tests / headless runs can also override to capture the path - without spawning. + The CLI / caller overrides this to call ``Supervisor.spawn_worker`` + + ``SessionStorage.new`` + ``attach_session_ws`` with the picked + path as the session cwd. """ logger.info( "tui: worktree picked for new session: %s " @@ -840,225 +920,87 @@ def on_worktree_pick(self, path: str) -> None: ) # ----------------------------------------------------------------- - # Menu binding (#235) — Ctrl-M opens the config menu. - # The modal listing skills / MCP / LSP servers lands in the config menu; - # this is the binding + dispatch contract only. + # Config menu (#235) — Ctrl-M. # ----------------------------------------------------------------- def action_menu(self) -> None: - """Trigger the config menu (#235). - - Calls ``on_menu_open`` — an overridable hook the subclass wires - to a ``ModalScreen`` that lists discoverable skills, MCP servers, - and LSP servers. Default: log + return. - """ self.on_menu_open() def on_menu_open(self) -> None: - """Hook fired by ``action_menu`` (Ctrl-M). - - Default: log + return. Subclasses override to mount a modal - that lists skills via ``discover_tools``, MCP servers - via ``MCPServer``, and any LSP servers. Selecting entries - re-runs ``discover_tools`` with the chosen layers. - """ - logger.info("tui: menu action fired (Ctrl-M)") - from cothis.skills import load_skill_selection, save_skill_selection - - skills = self.list_configurable_skills() - saved = load_skill_selection() + """Mount ``ConfigMenuModal`` listing discoverable skills; persist on Done.""" def _on_config_done(selected: set[str] | None) -> None: + self._refocus_input() if selected is None: # Esc / Cancel return + from cothis.skills import save_skill_selection + save_skill_selection(selected) - # Seed the menu with the saved-and-still-available skills so it - # reflects the last choice; persist the new selection on Done (#415). + skills = self.list_configurable_skills() + from cothis.skills import load_skill_selection + + saved = load_skill_selection() self.push_screen( ConfigMenuModal(skills, selected=saved & set(skills)), _on_config_done, ) def list_configurable_skills(self) -> list[str]: - """Return the names of skills discoverable from the current cwd. - - Wraps ``cothis.skills.discover_skills`` so the menu modal - (not yet implemented) can display the list without - importing the skills module directly. Returns an empty list - when no skills are installed. - """ + """Return the names of skills discoverable from the current cwd.""" from cothis.skills import discover_skills return [s.name for s in discover_skills(Path.cwd())] - # WS attach state (#252 item 1). ``None`` until ``attach_ws`` runs; - # ``attach_ws`` re-uses these slots idempotently. Typed as ``Any`` - # because websockets' client connection class moved across versions - # (``ClientConnection`` in v13+, ``WebSocketClientProtocol`` pre-v13) - # and we don't need to call any methods on it outside this file. - _ws: Any = None - _ws_pump_task: asyncio.Task[None] | None = None - # Multi-session WS connections (#230). Keyed by session_id; - # each entry has its own pump task. The single-session ``_ws`` / - # ``_ws_pump_task`` above stay for backward compat (``attach_ws``). - _ws_by_session: dict[str, Any] = {} - _ws_pump_tasks_by_session: dict[str, asyncio.Task[None]] = {} - # Active session id (#230) — the session the user is currently - # interacting with. Future slices route ``send_run_turn`` to the active - # session's WS + highlight the entry in ``SessionList``. - _active_session_id: str | None = None - # Session DB last used to populate ``SessionList`` / replay history. - # Captured in ``refresh_session_list`` + ``attach_session_ws`` so - # ``on_active_session_changed`` can replay the target session's history - # on a switch. ``None`` for the base app / storage-less tests → the - # view swap is a no-op (single-session flow stays intact). - _db_path: Path | None = None - - def compose(self) -> ComposeResult: - yield Header() - with Horizontal(id="main"): - # SessionList starts empty; ``refresh_session_list`` (caller-driven, - # e.g. from the CLI once storage is wired) populates it from the - # session DB. No placeholder items — they used to read as fake - # "session-1"/"session-2" rows on launch. - yield SessionList(id="session-list") - yield ConversationView() - yield TextArea(id="input") - # Footer: docked at the very bottom, beneath the input. Both - # widgets use ``dock: bottom``; Textual stacks docked siblings in DOM - # order with the LAST mounted closest to the screen edge, so mounting - # the footer after the input places it below the input. - yield CothisFooter("", id="footer") - - async def on_mount(self) -> None: - """Focus the session list on launch — preserve the pre-#375 target. - - Removing the ``InputBar(Container)`` wrapper (#375) lets the bare - ``TextArea`` grab initial focus, which would shadow the bare ``n`` - "new session" shortcut — a focused ``TextArea`` consumes printable - keys as text. Re-focusing ``SessionList`` keeps that shortcut (and - every existing test) working. The input is still Tab-reachable, - exactly the issue's scenario ("focuses the input bar, and types"); - the fix is that typing now inserts characters instead of being - dropped by the old wrapper. - """ - self._sync_sidebar() - self.query_one(SessionList).focus() - # Seed the footer with the initial idle render so the status bar - # shows the documented cells (``state:idle`` etc.) before any WS - # frame arrives. The watcher paths refresh it thereafter. - self._refresh_footer() - # ----------------------------------------------------------------- - # Footer reactives → re-render. One ``watch_*`` per reactive - # delegates to a single combined callback so a turn_finished payload - # (which updates all four data cells at once) re-paints the footer - # once per changed field rather than four times. + # Session index + transient picker (``/sessions``). # ----------------------------------------------------------------- - def watch_run_state(self, _value: str) -> None: - self._refresh_footer() - - def watch_footer_model(self, _value: str) -> None: - self._refresh_footer() - - def watch_footer_session(self, _value: str) -> None: - self._refresh_footer() - - def watch_footer_pressure(self, _value: str) -> None: - self._refresh_footer() - - def watch_footer_skills(self, _value: list[str]) -> None: - self._refresh_footer() - - def _render_footer_str(self) -> str: - """Compose the one-line footer render from the current reactives. - - Cells: ``model | [session: |] ctx: | - skills:[..] | state:``. The ``session:`` cell - is rendered ONLY when more than one session is attached — in the - common single-session case the id is redundant noise, so it is - hidden (see ``_attached_session_count``). ```` is the - first 8 chars of ``footer_session`` (the full id is stored; only - the render is shortened). ```` falls back to ``?`` when - unknown (the TUI never carries the raw ``None`` to the user-facing - string). - """ - pressure = self.footer_pressure or "?" - skills = ",".join(self.footer_skills) if self.footer_skills else "-" - model = self.footer_model or "-" - cells = [model] - if self._attached_session_count() > 1: - cells.append(f"session:{self.footer_session[:8]}") - cells.append(f"ctx:{pressure}") - cells.append(f"skills:{skills}") - cells.append(f"state:{self.run_state}") - return " | ".join(cells) + def _picker_rows(self) -> list[tuple[str, str]]: + """(id, label) rows for the picker: attached WS ids + the index. - def _attached_session_count(self) -> int: - """Number of sessions with a live WS the user can switch among. - - The footer's ``session:`` cell shows only when this is > 1, so the - common single-session case hides the redundant id. Multi-session WS - attach (#230) is the signal that switching is meaningful — a bare - ``attach_ws`` (single-session path) leaves this at 0 and the cell - stays hidden, matching the "no need to show the active session when - there's only one" contract. + Attached sessions (live WS) are always shown, labeled by short id; + the persisted index adds known sessions. De-duplicated by id. """ - return len(self._ws_by_session) + rows: dict[str, str] = {} + for sid in self._ws_by_session: + rows[sid] = f"{sid[:8]} (live)" + for sid, label in self._session_rows: + rows.setdefault(sid, label) + return list(rows.items()) + + def action_sessions(self) -> None: + """Open the transient session picker (``/sessions`` command).""" + rows = self._picker_rows() + if not rows: + logger.info("tui: no sessions to switch to") + self._refocus_input() + return - def _sync_sidebar(self) -> None: - """Show the ``SessionList`` sidebar only when switching is possible. + def _on_dismiss(session_id: str | None) -> None: + self._refocus_input() + if session_id is None: + return + self.on_session_selected(session_id) - Single-session mode (≤1 session listed or attached) hides the pane - so ``ConversationView`` takes the full width — the "weird layout" - complaint was a near-empty 24-col sidebar eating a quarter of the - screen for one session. Visible iff more than one row is listed OR - more than one WS is attached — the same multi-session signal that - gates the footer's ``session:`` cell. Re-run at mount, after - ``refresh_session_list``, and after attach/detach; idempotent. - """ - try: - session_list = self.query_one(SessionList) - except Exception: # noqa: BLE001 — compose may not have run yet - return - session_list.display = ( - len(session_list.children) > 1 or self._attached_session_count() > 1 + self.push_screen( + SessionPickerModal(rows, self._active_session_id), + _on_dismiss, ) - def _refresh_footer(self) -> None: - """Re-render the footer widget from the current reactives. - - Safe to call before ``compose`` finishes (the watcher fires during - reactive init); the ``try``/``except NoMatches`` guards the - not-yet-mounted case the same way ``on_active_session_changed`` - does. ``Static.update`` is the cheap text-relayout path — no - Markdown parse, no DOM remount. - """ - try: - self.query_one(CothisFooter).update(self._render_footer_str()) - except Exception: # noqa: BLE001 — footer not yet mounted - pass + # ----------------------------------------------------------------- + # Prompt input + slash commands. + # ----------------------------------------------------------------- async def action_send_prompt(self) -> None: """Read input text → render locally → forward to worker if attached. Slash-prefixed commands (``/``) are intercepted BEFORE local echo: - ``/session `` (alias ``/switch``) changes the active session - without echoing or forwarding to the worker. An unknown ``/...`` - returns False from ``_handle_slash_command`` and falls through to - the normal prompt path so agent-side slash commands still work. - - For normal prompts, local echo always runs (the user expects to see - their prompt immediately). When a WS is attached (#252 item 1), the - prompt is also forwarded as a ``run_turn`` control message — the - worker drives the assistant-side rendering via subsequent - ``assistant_delta`` frames pumped by ``_pump_ws``. - - Textual actions can be async; the framework awaits coroutine - results, so ``await self.send_run_turn(text)`` blocks the - action until the frame is on the wire (typically <1 ms). + ``/sessions`` opens the transient picker; ``/session `` (alias + ``/switch``) changes the active session without echoing. An unknown + ``/...`` returns False from ``_handle_slash_command`` and falls + through to the normal prompt path so agent-side slash commands + still work. """ input_widget = self.query_one("#input", TextArea) text = input_widget.text.strip() @@ -1074,22 +1016,19 @@ async def action_send_prompt(self) -> None: await self.send_run_turn(text) def _handle_slash_command(self, text: str) -> bool: - """Route a ``/``-prefixed command. Return True if consumed. - - ``/session `` (alias ``/switch``): switch the active session by - full id or unambiguous prefix (short-id friendly). Resolution runs - against the attached session ids (``_ws_by_session``); when nothing - is attached the raw arg still routes to ``on_session_selected`` so a - subclass can wire spawn-and-attach. Returns False for an empty or - unknown command so the caller falls through to the normal prompt - path (preserving agent-side slash commands + the literal "/..." case). - """ + """Route a ``/``-prefixed command. Return True if consumed.""" parts = text[1:].split(None, 1) if not parts or not parts[0]: return False cmd = parts[0].lower() arg = parts[1].strip() if len(parts) > 1 else "" - if cmd in ("session", "switch"): + if cmd in ("sessions", "switch"): + if cmd == "sessions": + self.action_sessions() + return True + self._switch_session_command(arg) + return True + if cmd in ("session",): self._switch_session_command(arg) return True return False @@ -1097,15 +1036,11 @@ def _handle_slash_command(self, text: str) -> bool: def _switch_session_command(self, arg: str) -> None: """``/session `` — switch the active session by id or unique prefix. - Matches ``arg`` against the attached session ids: exact match first, - then a unique-prefix match (so an 8-char short id works). An - ambiguous prefix logs + is a no-op; a missing arg logs + is a no-op. - The resolved id routes to ``on_session_selected`` — the SAME hook - the left-pane click uses — so the two switch paths (``/`` command + - SessionList click) stay identical. The raw arg routes there ONLY - when nothing is attached (the driven-app subclass spawn+attaches a - NEW session for an unknown id); with sessions attached an unmatched - id is a logged no-op, not a phantom switch. + Matches ``arg`` against attached session ids: exact match first, + then a unique-prefix match (so an 8-char short id works). The + resolved id routes to ``on_session_selected``. The raw arg routes + there ONLY when nothing is attached (the driven-app subclass + spawn+attaches a NEW session for an unknown id). """ if not arg: logger.info("tui: /session requires a session id") @@ -1120,59 +1055,55 @@ def _switch_session_command(self, arg: str) -> None: logger.info( "tui: /session %r matches %d attached sessions; " "use more characters", - arg, len(matches), + arg, + len(matches), ) return if target is not None: self.on_session_selected(target) return - # No exact/prefix match. Route the raw arg ONLY when nothing is - # attached — the driven-app subclass overrides on_session_selected - # to spawn+attach a NEW session for an unknown id. With sessions - # already attached, an unmatched id would set a phantom active id - # (no ws for it) and silently break the next turn, so no-op. if not session_ids: self.on_session_selected(arg) return logger.info("tui: /session %r matches no attached session", arg) - def append_assistant_delta(self, kind: str = "text", text: str = "") -> None: - """Forward a WS ``assistant_delta`` to the conversation view. + # ----------------------------------------------------------------- + # Stream + tool routing from WS frames. + # ----------------------------------------------------------------- - ``kind`` defaults to ``"text"`` for mixed-version compatibility - (old servers without the ``kind`` field in the WS message). - """ + def append_assistant_delta(self, kind: str = "text", text: str = "") -> None: + """Forward a WS ``assistant_delta`` to the conversation view.""" self.query_one(ConversationView).append_delta(kind, text) def append_tool_call( - self, name: str, status: str = "running", call_id: str | None = None, + self, + name: str, + status: str = "running", + call_id: str | None = None, ) -> Any: """Forward a WS ``tool_call_started`` to the conversation view.""" return self.query_one(ConversationView).append_tool_call( - name, status, call_id=call_id, + name, + status, + call_id=call_id, ) - def refresh_session_list(self, db_path: Path) -> None: - """Repopulate ``SessionList`` from the session storage DB. - - Opens ``Storage`` transiently for the read; no fcntl lock is - acquired on read-only access (the worker's lock is on its own - write connection). Closes the connection immediately so the - TUI doesn't hold a long-running reader on the worker's DB. + # ----------------------------------------------------------------- + # Session index (``refresh_session_list``) — populates the transient + # picker, never a permanent sidebar. + # ----------------------------------------------------------------- - Sessions visible from ``Path.cwd()`` (the user's current - directory tree) are listed; others are filtered out by - ``list_sessions_in_cwd_tree``. + def refresh_session_list(self, db_path: Path) -> None: + """Repopulate the session index from the session storage DB. - Failures (missing DB, corrupt schema) log a warning + leave - the existing list intact — the TUI stays usable without a - session picker if the storage layer is unavailable. + Opens ``Storage`` transiently for the read (no fcntl lock on + read-only access); closes the connection immediately. Sessions + visible from ``Path.cwd()`` are listed; labels carry the session + title + cwd + worktree branch when applicable. Failures log a + warning and leave the existing index intact. """ from cothis.session.storage import Storage - # Remember the DB so a later session switch (SessionList click / - # ``/session``) can replay the target session's history without - # the caller re-passing the path. self._db_path = db_path try: storage = Storage(db_path) @@ -1187,91 +1118,38 @@ def refresh_session_list(self, db_path: Path) -> None: finally: storage.close() - session_list = self.query_one(SessionList) - session_list.clear() - # Look up worktrees once; each session's label is enriched with - # its worktree's branch when the session cwd belongs to a known - # worktree (#234 AC #3). Failure to list worktrees (not a git - # repo, git binary missing) degrades to plain cwd labels — the - # list stays usable. from cothis.git import find_worktree_for_path, list_worktrees worktrees = list_worktrees(Path.cwd()) - # Group sessions by worktree cwd (#234 AC #5). Stable sort by cwd - # so sessions in the same worktree land adjacent — visual grouping - # rather than chronological scatter. Updated_at desc stays as the - # tiebreaker inside a group, preserving the "recent first" feel - # within one worktree's sessions. rows_sorted = sorted( rows, key=lambda r: (str(r.cwd) if r.cwd else "", r.updated_at), reverse=False, ) + self._session_rows = [] for row in rows_sorted: label = row.title or f"session {row.id[:8]}" cwd_hint = str(row.cwd) if row.cwd else "(no cwd)" - wt = ( - find_worktree_for_path(Path(row.cwd), worktrees) - if row.cwd else None - ) + wt = find_worktree_for_path(Path(row.cwd), worktrees) if row.cwd else None if wt is not None and wt.branch is not None: cwd_hint = f"{cwd_hint} · branch:{wt.branch}" - # Parens (not square brackets) — Textual parses ``[...]`` as - # markup tags, so a bracketed cwd path raises MarkupError. - # ``id`` prefix ``s_`` because Textual IDs can't begin with a - # number — session ids are hex and may start with a digit. - # ``on_list_view_selected`` strips the prefix. - session_list.append( - ListItem(Label(f"{label} ({cwd_hint})"), id=f"s_{row.id}") - ) - # The listed-count may have crossed the 1-boundary that gates the - # sidebar — hide/show the pane for the single/multi-session modes. - self._sync_sidebar() + self._session_rows.append((row.id, f"{label} ({cwd_hint})")) # ----------------------------------------------------------------- - # Session selection (#252 item 5 — selection half; list-half landed - # in #280). The user clicks a ListItem in SessionList; ListView - # posts a ``Selected`` event; the handler reads the session id off - # the item (set as Textual ``id`` by ``refresh_session_list``) and - # calls ``on_session_selected`` — a hook subclasses / tests can - # override to wire up spawn-and-attach. + # Session selection (#252 item 5). # ----------------------------------------------------------------- - def on_list_view_selected(self, event: ListView.Selected) -> None: - """Read the selected ListItem's session id; call the hook.""" - # The event also fires for ListView subclasses; we only care - # about SessionList. Both have the same API, so this handler - # is fine as-is — but narrow explicitly to SessionList to avoid - # triggering on any future ListView in the app. - if not isinstance(event.list_view, SessionList): - return - session_id = event.item.id - if not session_id or not session_id.startswith("s_"): - return - self.on_session_selected(session_id[2:]) - def on_session_selected(self, session_id: str) -> None: - """Hook called when the user picks a session in SessionList. + """Hook called when the user picks a session (picker / slash command). - Default behaviour: ``set_active_session(session_id)`` + log. - Callers that want to spawn a worker + attach WS on selection - subclass ``CothisApp`` and override this method, OR monkeypatch - the bound method on an existing instance. + Default behaviour: ``set_active_session(session_id)``. Callers that + want to spawn a worker + attach WS on selection subclass + ``CothisApp`` and override this method. """ self.set_active_session(session_id) - # ----------------------------------------------------------------- - # Active-session tracking (#230) - # ----------------------------------------------------------------- - def set_active_session(self, session_id: str) -> None: - """Mark ``session_id`` as the active session + fire the change hook. - - Called by ``on_session_selected`` and by callers that spawn a - new session (``on_new_session`` override → spawn → ``set_active``). - Future slices use this to route ``send_run_turn`` to the right - WS connection (#230) + highlight the focused entry. - """ + """Mark ``session_id`` as the active session + fire the change hook.""" previous = self._active_session_id self._active_session_id = session_id if previous != session_id: @@ -1280,71 +1158,43 @@ def set_active_session(self, session_id: str) -> None: def on_active_session_changed(self, session_id: str) -> None: """Hook fired when the active session changes (#230). - Default: update SessionList visual highlight — the matching - ListItem gains ``active-session`` CSS class; all others lose - it — AND mirror ``session_id`` into ``footer_session`` so the - (now conditional) footer ``session:`` cell reflects the active - session immediately after a switch, not only on the next worker - ``turn_finished`` frame. When a session DB is known - (``_db_path``), ALSO clear ``ConversationView`` + replay the - target session's stored history so the view matches the active - session after a switch — a no-op with no ``_db_path`` (base - app / storage-less tests) so the single-session flow stays - green. Subclasses can override for additional effects (input - focus routing etc.) but should call - ``super().on_active_session_changed()`` to preserve the - highlight + footer sync + view swap. + Default: mirror ``session_id`` into ``footer_session`` so the + (conditional) status ``session:`` cell reflects the active session + immediately, AND — when a session DB is known — clear + ``ConversationView`` + replay the target session's stored history. + Focus returns to the composer input. """ logger.info("tui: active session changed → %s", session_id) self.footer_session = session_id - try: - session_list = self.query_one(SessionList) - except Exception: # noqa: BLE001 — compose may not have run yet - session_list = None - if session_list is not None: - target_id = f"s_{session_id}" - for item in session_list.query(ListItem): - if item.id == target_id: - item.add_class("active-session") - else: - item.remove_class("active-session") - # Clear the previous session's messages + replay the target - # session's stored history so the view matches the active - # session after a switch. No-op when no session DB is known — - # the view keeps its content and the single-session / - # storage-less test paths stay green. + self._refresh_footer() if self._db_path is None: + self._refocus_input() return try: view = self.query_one(ConversationView) except Exception: # noqa: BLE001 — compose may not have run yet + self._refocus_input() return view.clear() self.replay_session_history(session_id, self._db_path) + self._refocus_input() # ----------------------------------------------------------------- - # WS attach (#252 item 1) — caller supplies URI + bearer token - # from a worker spawn (via Supervisor.spawn_worker or a direct - # ``cothis worker`` subprocess). The app opens a client, pumps - # inbound frames to ``ConversationView`` / ``ToolCallCard``, and - # exposes ``send_run_turn`` for ``action_send_prompt`` to use. + # WS attach (#252 item 1) — caller supplies URI + bearer token. # ----------------------------------------------------------------- async def attach_ws(self, uri: str, token: str) -> None: """Open a WS client to a worker; pump inbound frames to the view. - Caller decides how the worker got spawned (Supervisor, direct - subprocess, etc.) — this method only needs the bind-handshake - output (URI + bearer token). Inbound frames dispatch by - ``type`` to ``append_assistant_delta`` / ``append_tool_call``. - - Idempotent: calling again replaces the previous attachment. + Caller decides how the worker got spawned — this method only needs + the bind-handshake output (URI + bearer token). Idempotent. """ import websockets await self.detach_ws() self._ws = await websockets.connect( - uri, additional_headers={"Authorization": f"Bearer {token}"}, + uri, + additional_headers={"Authorization": f"Bearer {token}"}, ) self._ws_pump_task = asyncio.create_task(self._pump_ws()) @@ -1354,41 +1204,25 @@ async def detach_ws(self) -> None: self._ws_pump_task = None ws = self._ws self._ws = None - # A dropped worker mid-turn leaves run_state stale ("running"); if - # the active session has no other reachable WS, return to idle so - # the footer + the Esc guard don't reference a dead connection. if self._ws_by_session.get(self._active_session_id or "") is None: self.run_state = "idle" if task is not None and not task.done(): task.cancel() - try: + with suppress(asyncio.CancelledError, Exception): await task - except (asyncio.CancelledError, Exception): - pass if ws is not None: await ws.close() # ----------------------------------------------------------------- - # Multi-session WS attach (#230) + # Multi-session WS attach (#230). # ----------------------------------------------------------------- def replay_session_history(self, session_id: str, db_path: Path) -> None: """Replay a session's stored history into ``ConversationView``. - Reads the rebuilt messages via ``Session.peek_messages`` — the - lock-free, read-only storage surface already used by - ``cothis history ``. Lock-free is essential here: the worker - subprocess holds the cross-process file lock on the session, so - a locking read from the TUI would contend with it. Each rebuilt - ``{role, content: [blocks]}`` message routes through - ``ConversationView.render_replayed_message``, which reuses the - existing rendering primitives (no parallel renderer). - - Best-effort: a missing DB / corrupt schema / unknown id logs a - warning + returns so the TUI stays usable — mirrors - ``refresh_session_list``'s failure contract. A missing/empty - session (``peek_messages`` returns ``[]``) renders nothing, so a - fresh session's view stays correctly blank. + Reads the rebuilt messages via ``Session.peek_messages`` (the + lock-free, read-only storage surface). Best-effort: a missing DB / + corrupt schema / unknown id logs a warning + returns. """ from cothis.session import Session @@ -1397,7 +1231,9 @@ def replay_session_history(self, session_id: str, db_path: Path) -> None: except Exception as exc: # noqa: BLE001 — best-effort replay logger.warning( "tui: cannot replay history for %s from %s: %s", - session_id[:8], db_path, exc, + session_id[:8], + db_path, + exc, ) return view = self.query_one(ConversationView) @@ -1405,119 +1241,73 @@ def replay_session_history(self, session_id: str, db_path: Path) -> None: view.render_replayed_message(msg) async def attach_session_ws( - self, session_id: str, uri: str, token: str, + self, + session_id: str, + uri: str, + token: str, *, db_path: Path | None = None, ) -> None: """Open a WS client for a specific session (multi-session #230). - Stores the connection in ``_ws_by_session`` keyed by - ``session_id`` + starts a dedicated pump task. Marks the - session as active via ``set_active_session``. Idempotent: - re-attaching replaces the previous connection for that session. - - Replay-on-attach: when ``db_path`` is supplied, the - session's stored history is replayed into ``ConversationView`` - AFTER the WS connects + is stored but BEFORE the pump task - starts — so the rendered history is visible immediately on - attach and a fast worker frame can't race the history render. - Defaults ``None`` (no replay) so every existing 3-positional-arg - caller (tests, crash-restart re-attach) stays behaviour-identical. + Stores the connection in ``_ws_by_session`` keyed by session_id + + starts a dedicated pump task. Marks the session as active via + ``set_active_session``. Idempotent. When ``db_path`` is supplied, + the session's stored history is replayed AFTER the WS connects but + BEFORE the pump task starts. """ import websockets await self.detach_session_ws(session_id) ws = await websockets.connect( - uri, additional_headers={"Authorization": f"Bearer {token}"}, + uri, + additional_headers={"Authorization": f"Bearer {token}"}, ) self._ws_by_session[session_id] = ws if db_path is not None: - # Remember the DB so a later switch to ANOTHER session can - # replay that target's history without the caller re-passing - # the path. self._db_path = db_path self.replay_session_history(session_id, db_path) self._ws_pump_tasks_by_session[session_id] = asyncio.create_task( self._pump_ws_connection(ws) ) self.set_active_session(session_id) - # Attached-count may have crossed the 1-boundary that gates the - # sidebar — show the pane once a second session is attached. - self._sync_sidebar() + self._refresh_footer() async def detach_session_ws(self, session_id: str) -> None: """Close + remove one session's WS connection (multi-session #230).""" task = self._ws_pump_tasks_by_session.pop(session_id, None) ws = self._ws_by_session.pop(session_id, None) - # If the detached session was active, its worker is going away — - # clear a stale "running" so the footer + Esc guard don't reference - # a dead connection. if session_id == self._active_session_id: self.run_state = "idle" - # The attached-session count may have crossed the 1-boundary that - # gates the footer's ``session:`` cell. Detaching a NON-active - # session flips no reactive (run_state / footer_session unchanged), - # so repaint explicitly to hide/show the cell. Idempotent with the - # run_state watcher path above. self._refresh_footer() - # Same boundary gates the sidebar — hide the pane when the count - # drops back to single-session mode. - self._sync_sidebar() if task is not None and not task.done(): task.cancel() - try: + with suppress(asyncio.CancelledError, Exception): await task - except (asyncio.CancelledError, Exception): - pass if ws is not None: await ws.close() async def send_run_turn(self, prompt: str) -> None: - """Forward a prompt as a ``run_turn`` control message over WS. - - Routes to the active session's WS when multi-session is in use - (``_ws_by_session``); falls back to the single-session ``_ws`` - for backward compat. No-op when neither is attached. - """ + """Forward a prompt as a ``run_turn`` control message over WS.""" ws = self._ws_by_session.get(self._active_session_id or "") or self._ws if ws is None: return await ws.send(json.dumps({"type": "run_turn", "prompt": prompt})) async def send_interrupt_turn(self) -> None: - """Forward an ``interrupt_turn`` control message to the active worker. - - Mirrors ``send_run_turn``'s WS routing (active-session WS with a - single-session ``_ws`` fallback). No-op when no WS is attached — - ``action_interrupt_turn`` already guards on run-state AND active-WS - presence before calling this, but the no-op keeps the helper safe - to call directly from tests / subclasses. - """ + """Forward an ``interrupt_turn`` control message to the active worker.""" ws = self._ws_by_session.get(self._active_session_id or "") or self._ws if ws is None: return - # Guard the send: the WS may be mid-close when Esc is pressed (a - # worker crash mid-turn is exactly when the user reaches for the - # escape hatch). Swallow the send error so run_state — already - # optimistically "interrupted" — stays "interrupted" until the next - # turn / re-attach, mirroring worker._emit_turn_finished's guard. - try: + with suppress(asyncio.CancelledError, Exception): await ws.send(json.dumps({"type": "interrupt_turn"})) - except (asyncio.CancelledError, Exception): # noqa: BLE001 - pass async def action_interrupt_turn(self) -> None: """Esc-key action — interrupt the in-flight turn. Guarded on run-state: only interrupts when ``run_state == "running"``. - When idle (or already interrupted) Esc is a harmless no-op — it sends - nothing and leaves state untouched. Also no-ops when no WS is - attached for the active session (cannot reach the worker). - - Sets ``run_state="interrupted"`` optimistically so the footer - reflects the in-flight cancel AND a second Esc press is a no-op - (``interrupted != "running"``). Reconciled to ``"idle"`` when the - worker's terminal ``turn_finished`` frame lands. + Sets ``run_state="interrupted"`` optimistically; reconciled to + ``"idle"`` when the worker's terminal ``turn_finished`` frame lands. """ if self.run_state != "running": return @@ -1527,6 +1317,10 @@ async def action_interrupt_turn(self) -> None: self.run_state = "interrupted" await self.send_interrupt_turn() + # ----------------------------------------------------------------- + # WS pump + dispatch. + # ----------------------------------------------------------------- + async def _pump_ws(self) -> None: """Read inbound WS frames from ``self._ws`` (single-session path).""" if self._ws is None: @@ -1534,12 +1328,7 @@ async def _pump_ws(self) -> None: await self._pump_ws_connection(self._ws) async def _pump_ws_connection(self, ws: Any) -> None: - """Read inbound WS frames from a specific connection + dispatch. - - Shared between single-session (``_pump_ws``) and multi-session - (``attach_session_ws``) paths. Parameterized on ``ws`` so - concurrent pump tasks don't race on ``self._ws`` (#230). - """ + """Read inbound WS frames from a specific connection + dispatch.""" import websockets try: @@ -1558,17 +1347,15 @@ def _dispatch_ws_message(self, msg: dict) -> None: typ = msg.get("type") if typ == "assistant_delta": self.append_assistant_delta( - msg.get("kind", "text"), msg.get("text", ""), + msg.get("kind", "text"), + msg.get("text", ""), ) elif typ == "tool_call_started": self.append_tool_call( - msg.get("tool", "?"), call_id=msg.get("call_id"), + msg.get("tool", "?"), + call_id=msg.get("call_id"), ) elif typ == "tool_call_result_pointer": - # #252 item 4: flip the matching card's status badge by - # call_id. Falls back to a debug log when the card isn't - # found (start frame predated call_id wiring, or a stale - # result arrives after the user cleared the view). call_id = msg.get("call_id") if call_id is None: logger.debug( @@ -1579,35 +1366,25 @@ def _dispatch_ws_message(self, msg: dict) -> None: return view = self.query_one(ConversationView) card = view.update_tool_call_status( - call_id, is_error=bool(msg.get("is_error")), + call_id, + is_error=bool(msg.get("is_error")), ) if card is None: logger.debug( "tui: tool_call_result_pointer for %s (call_id=%s) — " "no matching card; dropping", - msg.get("tool"), call_id, + msg.get("tool"), + call_id, ) elif typ == "ask_user_request": - # #229: forward to the overridable hook. Default - # auto-rejects (sends resolve_ask with value=None) so the - # worker doesn't block in tests; subclasses mount a modal - # (handled by the CLI integration). self.on_ask_user_request( ask_id=msg.get("ask_id", ""), prompt=msg.get("prompt", ""), choices=msg.get("choices", []), ) elif typ == "turn_started": - # Worker opened a turn — flip run-state so the footer's - # state cell reads "running" and Esc becomes an armed interrupt. self.run_state = "running" elif typ == "turn_finished": - # Terminal frame on every turn exit path (normal end, - # timeout, error, interrupt). This is the authoritative refresh - # — it carries the post-turn context pressure + any skill - # load/deactivate that happened during the turn. Reconciles - # run_state to "idle" (an optimistic "interrupted" set by - # ``action_interrupt_turn`` lands here too). self.footer_model = msg.get("model") or "" sid = msg.get("session_id") or "" self.footer_session = sid @@ -1620,52 +1397,109 @@ def _dispatch_ws_message(self, msg: dict) -> None: logger.debug("tui: ignoring unknown WS message type: %r", typ) def on_ask_user_request( - self, *, ask_id: str, prompt: str, choices: list, + self, + *, + ask_id: str, + prompt: str, + choices: list, ) -> None: - """Mount ``AskUserModal``; route the user's pick to ``resolve_ask``. - - Hook fired when the worker emits an ``ask_user_request`` (#229). - The modal shows ``prompt`` + one button per choice; on dismiss - the chosen value (or ``None`` for Esc / Cancel) is sent back over - the active session's WS as a ``resolve_ask`` control message — - which the worker forwards to ``Agent.resolve_ask``, unblocking - the tool that called ``_ask_user``. - - Replies target the active session's WS (``_ws_by_session`` with - a ``_ws`` fallback for the single-session case). If the WS has - been detached by the time the user picks, the reply is dropped - — the agent's Future will simply not resolve and the turn will - hit the worker's ``_TURN_TIMEOUT_S``. - """ - logger.info("tui: ask_user_request %s: %s", ask_id, prompt) + """Mount ``AskUserModal``; route the user's pick to ``resolve_ask``.""" def _on_dismiss(value: str | None) -> None: - ws = ( - self._ws_by_session.get(self._active_session_id or "") - or self._ws - ) + self._refocus_input() + ws = self._ws_by_session.get(self._active_session_id or "") or self._ws if ws is None: logger.warning( - "tui: no active WS when resolving ask_id=%s; " - "reply dropped", + "tui: no active WS when resolving ask_id=%s; reply dropped", ask_id, ) return - asyncio.create_task(ws.send(json.dumps({ - "type": "resolve_ask", "ask_id": ask_id, "value": value, - }))) + asyncio.create_task( + ws.send( + json.dumps( + { + "type": "resolve_ask", + "ask_id": ask_id, + "value": value, + } + ) + ) + ) self.push_screen(AskUserModal(prompt, choices), _on_dismiss) + # ----------------------------------------------------------------- + # Status dock — one combined watcher repaints the footer. + # ----------------------------------------------------------------- + + def watch_run_state(self, _value: str) -> None: + self._refresh_footer() + self._refresh_status() + + def watch_footer_model(self, _value: str) -> None: + self._refresh_footer() + + def watch_footer_session(self, _value: str) -> None: + self._refresh_footer() + + def watch_footer_pressure(self, _value: str) -> None: + self._refresh_footer() + + def watch_footer_skills(self, _value: list[str]) -> None: + self._refresh_footer() + + def _render_footer_str(self) -> str: + """Compose the one-line status dock from the current reactives. + + Cells: ``model | [session: |] ctx: | + skills:[..] | state:``. The ``session:`` cell + renders ONLY when more than one session is attached — in the common + single-session case the id is redundant noise, so it is hidden. + """ + pressure = self.footer_pressure or "?" + skills = ",".join(self.footer_skills) if self.footer_skills else "-" + model = self.footer_model or "-" + cells = [model] + if self._attached_session_count() > 1: + cells.append(f"session:{self.footer_session[:8]}") + cells.append(f"ctx:{pressure}") + cells.append(f"skills:{skills}") + cells.append(f"state:{self.run_state}") + return " | ".join(cells) + + def _attached_session_count(self) -> int: + """Number of sessions with a live WS the user can switch among.""" + return len(self._ws_by_session) + + def _refresh_status(self) -> None: + """Update the composer status line (pi's statusContainer slot). + + Shows the working state while a turn is in flight (``>> running — + Esc to interrupt``) and a brief interrupted marker; empty when + idle so the dock stays calm. + """ + if self.run_state == "running": + text = "[b]>> running[/b] — Esc to interrupt" + elif self.run_state == "interrupted": + text = "[b]>> interrupted[/b] — awaiting turn end" + else: + text = "" + with suppress(Exception): # status line not yet mounted + self.query_one("#status-line", Label).update(text) + + def _refresh_footer(self) -> None: + """Re-render the status dock from the current reactives.""" + with suppress(Exception): # footer not yet mounted + self.query_one(CothisFooter).update(self._render_footer_str()) + def run(app: CothisApp | None = None) -> None: """Entry point: ``python -m cothis.tui``. - ``app`` lets a caller (e.g. the CLI ``tui`` command) pass a - subclass of ``CothisApp`` with hooks overridden for production - wiring (Supervisor-backed spawn, real session routing). Default - is a bare ``CothisApp`` — useful for development, tests, and - scenarios where the TUI runs without a Supervisor. + ``app`` lets a caller (e.g. the CLI ``tui`` command) pass a subclass of + ``CothisApp`` with hooks overridden for production wiring. Default is a + bare ``CothisApp`` — useful for development, tests, and scenarios where + the TUI runs without a Supervisor. """ if app is None: app = CothisApp() diff --git a/tests/test_cli.py b/tests/test_cli.py index 26814e4..70c0828 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1287,16 +1287,52 @@ def fake_run(app: object | None = None) -> None: # --------------------------------------------------------------------- # Chat → TUI dispatch routing (#237) # -# ``chat`` defaults to the TUI; ``--legacy`` and ``--skill`` fall back -# to the REPL. These tests verify the routing without launching either -# path (both are stubbed). +# ``chat`` defaults to the rich REPL (prompt_toolkit + rich streaming); +# ``--tui`` opts into the worker-based Textual shell; ``--legacy`` is a +# no-op for compatibility. These tests verify the routing without +# launching either path (both are stubbed). # --------------------------------------------------------------------- -def test_chat_defaults_to_tui( +def test_chat_defaults_to_rich_repl( monkeypatch: pytest.MonkeyPatch, ) -> None: - """AC #237: ``cothis chat`` (no flags) routes to ``_launch_tui_app``.""" + """``cothis chat`` (no flags) routes to the rich REPL (asyncio.run). + + The rich REPL — prompt_toolkit input + rich streaming — is the default + chat experience; the worker-based Textual shell is the ``--tui`` opt-in. + """ + import asyncio + + import cothis.cli as cli_mod + + tui_called: list[bool] = [] + asyncio_called: list[bool] = [] + + def fake_launch(*args: object, **kwargs: object) -> None: + tui_called.append(True) + + def fake_run(coro: Any) -> None: + asyncio_called.append(True) + coro.close() + + monkeypatch.setattr(cli_mod, "_launch_tui_app", fake_launch) + monkeypatch.setattr(asyncio, "run", fake_run) + + from typer.testing import CliRunner + runner = CliRunner() + result = runner.invoke(cli_mod.app, ["chat"]) + assert result.exit_code == 0, f"chat command failed: {result.output}" + assert tui_called == [], "default chat must NOT launch the Textual shell" + assert len(asyncio_called) == 1, "rich REPL (asyncio.run) should be called" + + +def test_chat_tui_flag_launches_worker_tui( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``cothis chat --tui`` opts into the worker-based Textual shell.""" + import asyncio + import cothis.cli as cli_mod captured: list[dict] = [] @@ -1323,19 +1359,18 @@ def fake_launch( "min_retained_turns": min_retained_turns, }) + def fake_run(coro: Any) -> None: + raise AssertionError("rich REPL must not run with --tui") + monkeypatch.setattr(cli_mod, "_launch_tui_app", fake_launch) + monkeypatch.setattr(asyncio, "run", fake_run) from typer.testing import CliRunner runner = CliRunner() - result = runner.invoke(cli_mod.app, ["chat"]) - assert result.exit_code == 0, f"chat command failed: {result.output}" - assert len(captured) == 1, ( - f"expected _launch_tui_app called once; got {captured}" - ) + result = runner.invoke(cli_mod.app, ["chat", "--tui"]) + assert result.exit_code == 0, f"chat --tui failed: {result.output}" + assert len(captured) == 1, f"expected _launch_tui_app once; got {captured}" assert captured[0]["resume"] is None - # The five tuning flags are forwarded from the chat call site to - # _launch_tui_app (resolved typer defaults here: 8 / 20000 / None / - # None / 4). Pinning the wiring end-to-end at the call site. assert captured[0]["max_concurrent_tools"] == 8 assert captured[0]["max_tool_result_chars"] == 20_000 assert captured[0]["tool_timeout"] is None @@ -1673,17 +1708,16 @@ async def test_chat_session_threads_compaction_flags_to_agent( cli_mod, "Agent", lambda **kw: (captured.update(kw), mock_agent)[1], ) - # PromptSession.prompt_async -> EOFError on first call so the REPL loop - # breaks immediately without reading stdin (Python 3.14 parses the - # ``except EOFError, KeyboardInterrupt:`` tuple form, so EOFError is - # caught and the loop breaks). - class _EOFSession: - async def prompt_async(self, *_a: object, **_k: object) -> str: - raise EOFError + # The rich streaming chat is the loop; patch it out so the test + # only verifies the Agent ctor flags (the app itself is covered by + # test_streaming_tui_*). + streamed: list[object] = [] + + async def fake_streaming_chat(agent: object) -> None: + streamed.append(agent) monkeypatch.setattr( - "prompt_toolkit.shortcuts.PromptSession", - lambda *a, **k: _EOFSession(), + "cothis.streaming_tui.run_streaming_chat", fake_streaming_chat, ) await cli_mod._chat_session( @@ -1729,11 +1763,9 @@ def test_ask_tool_tuning_flags_override_env(monkeypatch: pytest.MonkeyPatch) -> async def test_chat_session_threads_tool_tuning_flags_to_agent( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - """``_chat_session`` (chat's ``--legacy`` REPL path) forwards the tool-tuning - flags into the in-process ``Agent(...)`` ctor. - - The TUI default path is intentionally NOT asserted here — it spawns a - worker subprocess that does not yet carry the flags (deferred follow-up). + """``_chat_session`` (the default rich streaming path) forwards the + tool-tuning flags into the in-process ``Agent(...)`` ctor, then hands + the agent to the streaming chat. """ from unittest.mock import AsyncMock @@ -1752,13 +1784,13 @@ async def test_chat_session_threads_tool_tuning_flags_to_agent( cli_mod, "Agent", lambda **kw: (captured.update(kw), mock_agent)[1], ) - class _EOFSession: - async def prompt_async(self, *_a: object, **_k: object) -> str: - raise EOFError + streamed: list[object] = [] + + async def fake_streaming_chat(agent: object) -> None: + streamed.append(agent) monkeypatch.setattr( - "prompt_toolkit.shortcuts.PromptSession", - lambda *a, **k: _EOFSession(), + "cothis.streaming_tui.run_streaming_chat", fake_streaming_chat, ) await cli_mod._chat_session( @@ -1772,4 +1804,7 @@ async def prompt_async(self, *_a: object, **_k: object) -> str: assert captured["max_tool_result_chars"] == 1234 assert captured["tool_timeout"] == 7.5 + assert streamed == [mock_agent], ( + "the streaming chat must receive the constructed agent" + ) diff --git a/tests/test_streaming_tui.py b/tests/test_streaming_tui.py new file mode 100644 index 0000000..2c568d3 --- /dev/null +++ b/tests/test_streaming_tui.py @@ -0,0 +1,107 @@ +"""Tests for ``cothis.streaming_tui`` — the rich+prompt_toolkit chat shell. + +Covers the three contracts the module exists for: + +- virtual rendering: the conversation control serves lines lazily, so a + long transcript costs O(viewport) per frame, not O(content); +- streaming block semantics: per-delta re-renders replace only the live + block, never finalized history; +- the ``/`` menu: the completer suggests slash commands only while the + input starts with ``/``. +""" + +from __future__ import annotations + + +def _line(text: str) -> list[tuple[str, str]]: + # prompt_toolkit fragments are (style, text) tuples. + return [("", text)] + + +def test_virtual_control_serves_lines_lazily() -> None: + """``create_content`` hands the Window a lazy ``get_line``. + + The control stores all lines, but the ``UIContent`` only materializes + the slice the Window actually paints — the O(viewport) per-frame + contract. Out-of-range indices return an empty line (the Window asks + past the end while the content grows). + """ + from cothis.streaming_tui import ConversationControl + + ctl = ConversationControl() + for i in range(100): + ctl.append_text(f"line {i}") + + content = ctl.create_content(width=80, height=24) + assert content.line_count == 100 + # Lazy: get_line(50) is served from storage, get_line(999) is a no-op. + assert content.get_line(50) == _line("line 50") + assert content.get_line(999) == [] + # The content grows without touching the already-created UIContent. + ctl.append_text("line 100") + assert ctl.line_count == 101 + + +def test_stream_block_replaces_only_live_lines() -> None: + """``update_stream`` re-renders the live block; history stays intact.""" + from cothis.streaming_tui import ConversationControl + + ctl = ConversationControl() + ctl.append_text("user: hi") # finalized history + ctl.begin_stream() + ctl.update_stream([_line("the "), _line("answer")]) + assert ctl.line_count == 3 + + # A delta re-renders ONLY the stream block. + ctl.update_stream([_line("the full answer")]) + assert ctl.line_count == 2 + assert ctl._lines[0] == _line("user: hi") + assert ctl._lines[1] == _line("the full answer") + + # Tool-call boundary: finalize the block, start a fresh one. + ctl.end_stream() + ctl.begin_stream() + ctl.update_stream([_line("next segment")]) + assert ctl.line_count == 3 + assert ctl._lines[1] == _line("the full answer") + + +def test_slash_completer_suggests_commands_only_for_slash_input() -> None: + """``/``-prefixed input pops the menu; plain text yields nothing.""" + from prompt_toolkit.document import Document + + from cothis.streaming_tui import SlashCompleter + + comp = SlashCompleter() + + def names(document_text: str) -> list[str]: + doc = Document(document_text) + return [c.text for c in comp.get_completions(doc, None)] + + assert "/exit" in names("/ex") + assert "/help" in names("/") + assert names("hello") == [] + assert names("/nonexistent") == [] + + +def test_fragments_are_style_text_tuples() -> None: + """The control emits ``(style, text)`` tuples — prompt_toolkit's contract. + + A swapped ``(text, style)`` order makes the renderer parse the prompt + text as a style string, crashing with ``Wrong color format '❯'`` + (the ``❯`` prompt char). Regression guard for that crash. + """ + from cothis.streaming_tui import ConversationControl + + ctl = ConversationControl() + ctl.append_text("\u276f hi", style="class:prompt") + line = ctl.create_content(width=80, height=24).get_line(0) + assert line == [("class:prompt", "\u276f hi")] + + # rich-rendered markdown also comes out as (style, text). + from cothis.streaming_tui import _markdown_lines + + md_lines = _markdown_lines("**bold**", width=40) + for frag in md_lines[0]: + assert isinstance(frag, tuple) and len(frag) == 2 + assert not frag[1].startswith("class") # text is the second element diff --git a/tests/test_tui.py b/tests/test_tui.py index d48f609..1037b42 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -1,11 +1,12 @@ -"""Tests for ``cothis.tui`` (#228). +"""Tests for ``cothis.tui`` — the focused transcript shell. -Covers the 3-pane layout + interactivity API: +Covers the pi-inspired composition + interactivity API: -- 3 panes exist + are queryable. +- the transcript owns the full viewport; composer + status dock are fixed. +- no header / no session sidebar (session switching is a transient picker). - ``ConversationView.append_delta(kind, text)`` routes text vs thinking. -- ``ConversationView.append_tool_call`` mounts an inline card. -- the input ``TextArea`` accepts multi-line text + clears on send. +- user prompts render as background-tinted blocks. +- the input holds default + persistent focus. - ``action_send_prompt`` echoes the user prompt into the conversation. - ``append_assistant_delta`` + ``append_tool_call`` forward to the view. """ @@ -19,43 +20,44 @@ @pytest.mark.asyncio -async def test_app_launches_with_three_panes() -> None: - """Pilot launches CothisApp; all three panes are queryable.""" - from cothis.tui import ConversationView, CothisApp +async def test_app_launches_with_focused_transcript_shell() -> None: + """Pilot launches CothisApp; the transcript shell panes are queryable. + + No header, no sidebar: conversation (full viewport), composer dock + (input + hint), status dock — the pi-inspired composition. + """ + from cothis.tui import ConversationView, CothisApp, CothisFooter app = CothisApp() async with app.run_test() as pilot: await pilot.pause() - assert app.query_one("SessionList") is not None assert app.query_one(ConversationView) is not None + assert app.query_one("#composer") is not None assert app.query_one("#input") is not None + assert app.query_one(CothisFooter) is not None @pytest.mark.asyncio -async def test_grid_layout_stacks_panes_vertically() -> None: - """The four grid rows stack top-to-bottom: header / main / input / footer. +async def test_transcript_composer_footer_stack_vertically() -> None: + """The shell stacks top-to-bottom: transcript / composer / status dock. - The grid (``grid-rows: 1 1fr auto 1``) replaces the old dock-based - layout; every pane must be placed by the grid, so the footer sits - BENEATH the input (not docked above it) and the conversation row owns - the remaining height. + The transcript owns all flexible height (pi's scrollable-transcript + model); the composer and footer are fixed below it. No grid, no dock + hacks — a plain vertical layout. """ from cothis.tui import ConversationView, CothisApp, CothisFooter app = CothisApp() async with app.run_test(size=(100, 40)) as pilot: await pilot.pause() - header = app.query_one("Header") - main = app.query_one("#main") conv = app.query_one(ConversationView) + composer = app.query_one("#composer") input_box = app.query_one("#input") footer = app.query_one(CothisFooter) - assert header.region.y < main.region.y < input_box.region.y < footer.region.y - # Conversation gets the full remaining height: taller than the - # input and footer combined, and it spans the full width in - # single-session mode (sidebar hidden). - assert conv.region.height > input_box.region.height + footer.region.height + assert conv.region.y < composer.region.y < footer.region.y + # Transcript spans the full width and holds the flexible height. assert conv.region.width == app.size.width + assert conv.region.height > input_box.region.height + footer.region.height @pytest.mark.asyncio @@ -86,7 +88,6 @@ async def test_input_auto_grows_then_scrolls_internally() -> None: assert conv.region.height < idle_conv_height # conversation shrank - @pytest.mark.asyncio async def test_conversation_view_appends_text_delta() -> None: """``append_delta(kind='text', ...)`` accumulates into renderable.""" @@ -151,7 +152,7 @@ async def test_input_bar_accepts_text() -> None: @pytest.mark.asyncio async def test_send_prompt_echoes_into_conversation() -> None: """``action_send_prompt`` posts the input text to ConversationView + clears.""" - from textual.widgets import TextArea + from textual.widgets import Markdown, TextArea from cothis.tui import ConversationView, CothisApp @@ -163,7 +164,8 @@ async def test_send_prompt_echoes_into_conversation() -> None: await app.action_send_prompt() await pilot.pause() view = app.query_one(ConversationView) - assert "what is 2+2?" in view.renderable_str + boxes = [w for w in view.query(Markdown) if "user-message" in w.classes] + assert boxes and "what is 2+2?" in boxes[-1]._markdown assert bar.text == "" @@ -212,7 +214,7 @@ async def test_append_tool_call_via_app() -> None: @pytest.mark.asyncio async def test_ctrl_enter_keypress_sends_prompt() -> None: """Ctrl+Enter binding triggers send_prompt via the actual keypress.""" - from textual.widgets import TextArea + from textual.widgets import Markdown, TextArea from cothis.tui import ConversationView, CothisApp @@ -224,14 +226,15 @@ async def test_ctrl_enter_keypress_sends_prompt() -> None: await pilot.press("ctrl+enter") await pilot.pause() view = app.query_one(ConversationView) - assert "via keypress" in view.renderable_str + boxes = [w for w in view.query(Markdown) if "user-message" in w.classes] + assert boxes and "via keypress" in boxes[-1]._markdown assert bar.text == "" @pytest.mark.asyncio async def test_user_message_brackets_are_escaped() -> None: """Brackets in user text are escaped so Markdown injection is blocked.""" - from textual.widgets import TextArea + from textual.widgets import Markdown, TextArea from cothis.tui import ConversationView, CothisApp @@ -243,8 +246,10 @@ async def test_user_message_brackets_are_escaped() -> None: await app.action_send_prompt() await pilot.pause() view = app.query_one(ConversationView) - assert "\\[click\\]" in view.renderable_str - assert "[click]" not in view.renderable_str.replace("\\[click\\]", "") + boxes = [w for w in view.query(Markdown) if "user-message" in w.classes] + assert boxes, "user prompt must render as a tinted box" + assert "\\[click\\]" in boxes[-1]._markdown + assert "[click]" not in boxes[-1]._markdown.replace("\\[click\\]", "") @pytest.mark.asyncio @@ -285,7 +290,9 @@ async def test_tool_call_flushes_text_segment_for_dom_order() -> None: # immediate children of the ConversationView scroll container. children = list(view.children) positions_md = [i for i, c in enumerate(children) if isinstance(c, Markdown)] - positions_card = [i for i, c in enumerate(children) if isinstance(c, ToolCallCard)] + positions_card = [ + i for i, c in enumerate(children) if isinstance(c, ToolCallCard) + ] assert len(positions_card) == 1 assert positions_md[0] < positions_card[0] < positions_md[1] @@ -333,7 +340,7 @@ async def _run(n_deltas: int, flush_every: int) -> float: ratio = t_large / t_small if t_small > 0 else float("inf") assert ratio <= 8.0, ( f"expected ≤8.0× slowdown on 4× workload (linear + overhead); " - f"got {ratio:.2f}× (small={t_small*1000:.1f}ms, large={t_large*1000:.1f}ms) — " + f"got {ratio:.2f}× (small={t_small * 1000:.1f}ms, large={t_large * 1000:.1f}ms) — " f"buffer is accumulating O(N²) work somewhere" ) @@ -726,9 +733,12 @@ async def fake_connect(uri: str, **kw: object) -> _FakeWS: await app.action_send_prompt() await pilot.pause() - # Local echo: user prompt lands in the view. + # Local echo: user prompt lands in the view as a tinted box. + from textual.widgets import Markdown + view = app.query_one(ConversationView) - assert "what is 2+2?" in view.renderable_str + boxes = [w for w in view.query(Markdown) if "user-message" in w.classes] + assert boxes and "what is 2+2?" in boxes[-1]._markdown # Bar cleared. assert bar.text == "" # Outbound: run_turn control message on the WS. @@ -760,7 +770,10 @@ async def test_action_send_prompt_no_forwarding_when_not_attached() -> None: await app.action_send_prompt() await pilot.pause() view = app.query_one(ConversationView) - assert "hello" in view.renderable_str + from textual.widgets import Markdown + + boxes = [w for w in view.query(Markdown) if "user-message" in w.classes] + assert boxes and "hello" in boxes[-1]._markdown assert bar.text == "" # No WS attached → ``send_run_turn`` is a no-op. The view's # content matches exactly the local echo (no extra frames). @@ -783,26 +796,32 @@ async def test_tool_call_result_pointer_updates_card_status_by_call_id( from cothis.tui import CothisApp, ToolCallCard frames = [ - _json.dumps({ - "type": "tool_call_started", - "tool": "fs.read", - "arguments": {"path": "a.py"}, - "call_id": "tu_first", - }), - _json.dumps({ - "type": "tool_call_started", - "tool": "fs.read", - "arguments": {"path": "b.py"}, - "call_id": "tu_second", - }), - _json.dumps({ - "type": "tool_call_result_pointer", - "tool": "fs.read", - "is_error": False, - "duration_ms": 5, - "pointer": "session:s:tool:tu_second", - "call_id": "tu_second", - }), + _json.dumps( + { + "type": "tool_call_started", + "tool": "fs.read", + "arguments": {"path": "a.py"}, + "call_id": "tu_first", + } + ), + _json.dumps( + { + "type": "tool_call_started", + "tool": "fs.read", + "arguments": {"path": "b.py"}, + "call_id": "tu_second", + } + ), + _json.dumps( + { + "type": "tool_call_result_pointer", + "tool": "fs.read", + "is_error": False, + "duration_ms": 5, + "pointer": "session:s:tool:tu_second", + "call_id": "tu_second", + } + ), ] fake = _FakeWS(frames) @@ -841,20 +860,24 @@ async def test_tool_call_result_pointer_error_flips_card_to_failed( from cothis.tui import CothisApp, ToolCallCard frames = [ - _json.dumps({ - "type": "tool_call_started", - "tool": "fs.read", - "arguments": {"path": "a.py"}, - "call_id": "tu_err", - }), - _json.dumps({ - "type": "tool_call_result_pointer", - "tool": "fs.read", - "is_error": True, - "duration_ms": 5, - "pointer": None, - "call_id": "tu_err", - }), + _json.dumps( + { + "type": "tool_call_started", + "tool": "fs.read", + "arguments": {"path": "a.py"}, + "call_id": "tu_err", + } + ), + _json.dumps( + { + "type": "tool_call_result_pointer", + "tool": "fs.read", + "is_error": True, + "duration_ms": 5, + "pointer": None, + "call_id": "tu_err", + } + ), ] fake = _FakeWS(frames) @@ -891,20 +914,24 @@ async def test_tool_call_result_pointer_without_call_id_is_no_op( from cothis.tui import CothisApp, ToolCallCard frames = [ - _json.dumps({ - "type": "tool_call_started", - "tool": "fs.read", - "arguments": {"path": "a.py"}, - "call_id": "tu_x", - }), + _json.dumps( + { + "type": "tool_call_started", + "tool": "fs.read", + "arguments": {"path": "a.py"}, + "call_id": "tu_x", + } + ), # Result frame without call_id — legacy shape. - _json.dumps({ - "type": "tool_call_result_pointer", - "tool": "fs.read", - "is_error": False, - "duration_ms": 5, - "pointer": None, - }), + _json.dumps( + { + "type": "tool_call_result_pointer", + "tool": "fs.read", + "is_error": False, + "duration_ms": 5, + "pointer": None, + } + ), ] fake = _FakeWS(frames) @@ -936,18 +963,18 @@ async def fake_connect(uri: str, **kw: object) -> _FakeWS: @pytest.mark.asyncio async def test_refresh_session_list_populates_from_db( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: - """AC #252 item 5 (list): ``refresh_session_list`` shows sessions visible from cwd. + """AC #252 item 5 (list): ``refresh_session_list`` indexes cwd-visible sessions. Seeds a Storage DB with two sessions (one matching the test cwd, one in an unrelated directory), then calls refresh_session_list. - Only the cwd-visible session appears in SessionList. + Only the cwd-visible session appears in the session index (the + source the transient ``/sessions`` picker renders from). """ - from textual.widgets import ListItem - from cothis.session import Session - from cothis.tui import CothisApp, SessionList + from cothis.tui import CothisApp db_path = tmp_path / "session.db" @@ -958,7 +985,10 @@ async def test_refresh_session_list_populates_from_db( # Hidden session: cwd is an unrelated directory. hidden = Session.new( - db_path, cwd=tmp_path / "elsewhere", model="m", flush_sync=True, + db_path, + cwd=tmp_path / "elsewhere", + model="m", + flush_sync=True, ) hidden.append_message("user", [{"type": "text", "text": "out of scope"}]) hidden.close() @@ -971,10 +1001,10 @@ async def test_refresh_session_list_populates_from_db( app.refresh_session_list(db_path) await pilot.pause() - session_list = app.query_one(SessionList) - items = list(session_list.query(ListItem)) - # Only the visible session shows up. - assert len(items) == 1 + ids = [row[0] for row in app._session_rows] + # Only the visible session is indexed. + assert visible.session_id in ids + assert hidden.session_id not in ids @pytest.mark.asyncio @@ -984,9 +1014,9 @@ async def test_refresh_session_list_missing_db_is_no_crash( """AC #252 item 5: a missing / corrupt DB is logged, not crashed on. The TUI must stay usable when the session DB can't be opened — - refresh leaves the SessionList empty + a warning in the log. + refresh leaves the session index empty + a warning in the log. """ - from cothis.tui import CothisApp, SessionList + from cothis.tui import CothisApp bogus_path = tmp_path / "does-not-exist.db" app = CothisApp() @@ -995,25 +1025,23 @@ async def test_refresh_session_list_missing_db_is_no_crash( # No raise; the call logs + returns. app.refresh_session_list(bogus_path) await pilot.pause() - # The app stays alive + queryable when storage can't be opened. - assert app.query_one(SessionList) is not None + # The app stays alive + the index stays empty. + assert app._session_rows == [] + assert app.query_one("#input") is not None @pytest.mark.asyncio async def test_session_selection_fires_hook_with_session_id( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: - """AC #252 item 5 (selection): clicking a ListItem fires ``on_session_selected``. + """AC #252 item 5 (selection): picking a session fires ``on_session_selected``. - The hook receives the session id (without the ``s_`` prefix that - Textual imposes because hex session ids can begin with a digit). - Subclasses override the hook to wire spawn-and-attach; this test - uses a capturing subclass to verify the call. + The transient ``/sessions`` picker hands the picked id (without any + id prefix) to the hook. Subclasses override the hook to wire + spawn-and-attach; this test uses a capturing subclass. """ - from textual.widgets import ListItem - - from cothis.session import Session - from cothis.tui import CothisApp, SessionList + from cothis.tui import CothisApp, SessionPickerModal class _CapturingApp(CothisApp): def __init__(self) -> None: @@ -1024,6 +1052,8 @@ def on_session_selected(self, session_id: str) -> None: self.captured.append(session_id) db_path = tmp_path / "session.db" + from cothis.session import Session + s = Session.new(db_path, cwd=tmp_path, model="m", flush_sync=True) s.append_message("user", [{"type": "text", "text": "hi"}]) sid = s.session_id @@ -1036,39 +1066,34 @@ def on_session_selected(self, session_id: str) -> None: await pilot.pause() app.refresh_session_list(db_path) await pilot.pause() + assert len(app._session_rows) == 1 - session_list = app.query_one(SessionList) - items = list(session_list.query(ListItem)) - assert len(items) == 1 - # Trigger selection by posting a Selected message directly. The - # user-facing way is keyboard enter on the cursor, but the test - # harness wants the explicit message — it bypasses the focus / - # cursor-position dance that flaked earlier pilot runs. - first_item = items[0] - session_list.post_message(SessionList.Selected(session_list, first_item, 0)) + # Open the transient picker and select the (only) session. + app.action_sessions() + await pilot.pause() + assert isinstance(app.screen, SessionPickerModal) + await pilot.press("enter") # first row selected await pilot.pause() assert app.captured == [sid], ( - f"expected on_session_selected called once with {sid!r}; " - f"got {app.captured!r}" + f"expected on_session_selected called once with {sid!r}; got {app.captured!r}" ) @pytest.mark.asyncio async def test_refresh_session_list_enriches_label_with_worktree_branch( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: - """AC #234 #3: SessionList label includes ``branch:`` when the session's cwd is in a known worktree. + """AC #234 #3: session index label includes ``branch:`` when cwd is in a known worktree. - Stubs ``list_worktrees`` so the test is hermetic (no real git binary - needed). When the stub returns a Worktree whose path is an ancestor - of the session's cwd, the label gains ``· branch:``. + Stubs ``list_worktrees`` so the test is hermetic. When the stub + returns a Worktree whose path is an ancestor of the session's cwd, + the label gains ``· branch:``. """ - from textual.widgets import Label, ListItem - from cothis.git import Worktree from cothis.session import Session - from cothis.tui import CothisApp, SessionList + from cothis.tui import CothisApp db_path = tmp_path / "session.db" s = Session.new(db_path, cwd=tmp_path, model="m", flush_sync=True) @@ -1088,31 +1113,23 @@ def fake_list_worktrees(_cwd: Path) -> list[Worktree]: app.refresh_session_list(db_path) await pilot.pause() - session_list = app.query_one(SessionList) - items = list(session_list.query(ListItem)) - assert len(items) == 1 - label_widget = items[0].query_one(Label) - # ``Label`` inherits ``Static``; the source text lives on the - # mangled private attr ``_Static__content``. - label_str = str(getattr(label_widget, "_Static__content")) - assert "branch:feature-branch" in label_str, ( - f"expected branch enrichment in label; got {label_str!r}" - ) + assert len(app._session_rows) == 1 + label = app._session_rows[0][1] + assert "branch:feature-branch" in label, f"got {label!r}" @pytest.mark.asyncio async def test_refresh_session_list_skips_branch_when_no_worktree( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """AC #234 #3: when ``list_worktrees`` returns ``[]``, label has no branch suffix. The cwd-only label is preserved — the TUI degrades cleanly when not in a git repo or git binary missing. """ - from textual.widgets import Label, ListItem - from cothis.session import Session - from cothis.tui import CothisApp, SessionList + from cothis.tui import CothisApp db_path = tmp_path / "session.db" s = Session.new(db_path, cwd=tmp_path, model="m", flush_sync=True) @@ -1128,21 +1145,17 @@ async def test_refresh_session_list_skips_branch_when_no_worktree( app.refresh_session_list(db_path) await pilot.pause() - session_list = app.query_one(SessionList) - items = list(session_list.query(ListItem)) - assert len(items) == 1 - label_widget = items[0].query_one(Label) - label_str = str(getattr(label_widget, "_Static__content")) - assert "branch:" not in label_str, ( - f"no branch expected when worktrees empty; got {label_str!r}" - ) + assert len(app._session_rows) == 1 + label = app._session_rows[0][1] + assert "branch:" not in label, f"got {label!r}" @pytest.mark.asyncio async def test_refresh_session_list_groups_sessions_by_cwd( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: - """AC #234 #5: sessions with the same cwd land adjacent in SessionList. + """AC #234 #5: sessions with the same cwd land adjacent in the session index. Visual grouping by worktree: stable sort by cwd, with ``updated_at`` as the within-group tiebreaker. Three sessions in two cwds end up @@ -1157,11 +1170,9 @@ async def test_refresh_session_list_groups_sessions_by_cwd( import re from itertools import groupby - from textual.widgets import Label, ListItem - from cothis.session import Session from cothis.session.storage import SessionRow - from cothis.tui import CothisApp, SessionList + from cothis.tui import CothisApp db_path = tmp_path / "session.db" cwd_a = tmp_path / "worktree-a" @@ -1200,16 +1211,13 @@ async def test_refresh_session_list_groups_sessions_by_cwd( app.refresh_session_list(db_path) await pilot.pause() - session_list = app.query_one(SessionList) - items = list(session_list.query(ListItem)) - assert len(items) == 3 + assert len(app._session_rows) == 3 - # Pull cwd out of each item's label "(path)" suffix to check grouping. + # Pull cwd out of each label's "(path)" suffix to check grouping. cwds_in_list_order: list[str] = [] - for item in items: - label_str = str(getattr(item.query_one(Label), "_Static__content")) - match = re.search(r"\(([^)]+)\)", label_str) - assert match is not None, f"no cwd in label {label_str!r}" + for _sid, label in app._session_rows: + match = re.search(r"\(([^)]+)\)", label) + assert match is not None, f"no cwd in label {label!r}" cwds_in_list_order.append(match.group(1).split(" · ")[0]) # Sessions with the same cwd must be adjacent. @@ -1229,7 +1237,8 @@ async def test_refresh_session_list_groups_sessions_by_cwd( @pytest.mark.asyncio async def test_action_new_session_fires_hook_with_worktrees( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """AC #234: ``action_new_session`` calls ``on_new_session`` with the worktree list. @@ -1266,7 +1275,8 @@ def fake_list_worktrees(_cwd: Path) -> list[Worktree]: @pytest.mark.asyncio async def test_action_new_session_passes_empty_list_when_not_in_git_repo( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """AC #234: when ``list_worktrees`` returns ``[]``, the hook gets an empty list. @@ -1317,8 +1327,7 @@ async def test_on_new_session_default_mounts_worktree_picker( modal = app.screen assert isinstance(modal, WorktreePickerModal), ( - f"expected WorktreePickerModal on top; " - f"got {type(app.screen).__name__}" + f"expected WorktreePickerModal on top; got {type(app.screen).__name__}" ) modal.action_dismiss_modal() @@ -1329,12 +1338,13 @@ async def test_on_new_session_default_mounts_worktree_picker( async def test_action_new_session_keypress_pushes_picker( monkeypatch: pytest.MonkeyPatch, ) -> None: - """AC #234: ``n`` keypress → ``action_new_session`` → picker mounts. + """AC #234: ``Ctrl+N`` keypress → ``action_new_session`` → picker mounts. - End-to-end via the actual keypress binding (``n``, not ``ctrl+n`` — - the binding was added previously). The default ``on_new_session`` - mounts ``WorktreePickerModal``; the test verifies the modal is on - top of the screen stack after the keypress. + End-to-end via the actual keypress binding. The binding is a modified + key (not bare ``n``) because the input holds focus — a bare ``n`` + would type into the prompt. The default ``on_new_session`` mounts + ``WorktreePickerModal``; the test verifies the modal is on top of the + screen stack after the keypress. """ from cothis.git import Worktree from cothis.tui import CothisApp, WorktreePickerModal @@ -1350,7 +1360,7 @@ async def test_action_new_session_keypress_pushes_picker( app = CothisApp() async with app.run_test() as pilot: await pilot.pause() - await pilot.press("n") + await pilot.press("ctrl+n") await pilot.pause() modal = app.screen @@ -1428,10 +1438,12 @@ def on_worktree_pick(self, path: str) -> None: # type: ignore[override] async with app.run_test() as pilot: await pilot.pause() # Mount the picker via the default on_new_session hook. - app.on_new_session([ - Worktree(Path("/repo/main"), "main"), - Worktree(Path("/repo/feat"), "feature/x"), - ]) + app.on_new_session( + [ + Worktree(Path("/repo/main"), "main"), + Worktree(Path("/repo/feat"), "feature/x"), + ] + ) await pilot.pause() modal = app.screen @@ -1439,15 +1451,12 @@ def on_worktree_pick(self, path: str) -> None: # type: ignore[override] # Click the second worktree's button — routes to on_worktree_pick # with that path (index-based ID). - feature_button = next( - b for b in modal.query(Button) if b.id == "wt-1" - ) + feature_button = next(b for b in modal.query(Button) if b.id == "wt-1") await pilot.click(feature_button) await pilot.pause() assert captured == [str(Path("/repo/feat"))], ( - f"expected on_worktree_pick to be called with /repo/feat; " - f"got {captured}" + f"expected on_worktree_pick to be called with /repo/feat; got {captured}" ) @@ -1523,20 +1532,30 @@ def __init__(self) -> None: self.captured: dict = {} def on_ask_user_request( - self, *, ask_id: str, prompt: str, choices: list, + self, + *, + ask_id: str, + prompt: str, + choices: list, ) -> None: # type: ignore[override] self.captured = { - "ask_id": ask_id, "prompt": prompt, "choices": choices, + "ask_id": ask_id, + "prompt": prompt, + "choices": choices, } - fake = _FakeWS([ - _json.dumps({ - "type": "ask_user_request", - "ask_id": "ask_42", - "prompt": "Deploy to prod?", - "choices": ["yes", "no"], - }), - ]) + fake = _FakeWS( + [ + _json.dumps( + { + "type": "ask_user_request", + "ask_id": "ask_42", + "prompt": "Deploy to prod?", + "choices": ["yes", "no"], + } + ), + ] + ) async def fake_connect(uri: str, **kw: object) -> _FakeWS: return fake @@ -1551,7 +1570,9 @@ async def fake_connect(uri: str, **kw: object) -> _FakeWS: await pilot.pause() assert app.captured == { - "ask_id": "ask_42", "prompt": "Deploy to prod?", "choices": ["yes", "no"], + "ask_id": "ask_42", + "prompt": "Deploy to prod?", + "choices": ["yes", "no"], } @@ -1572,14 +1593,18 @@ async def test_ask_user_request_mounts_modal_and_routes_pick_to_resolve_ask( from cothis.tui import AskUserModal, CothisApp - fake = _FakeWS([ - _json.dumps({ - "type": "ask_user_request", - "ask_id": "ask_99", - "prompt": "Continue?", - "choices": ["y", "n"], - }), - ]) + fake = _FakeWS( + [ + _json.dumps( + { + "type": "ask_user_request", + "ask_id": "ask_99", + "prompt": "Continue?", + "choices": ["y", "n"], + } + ), + ] + ) async def fake_connect(uri: str, **kw: object) -> _FakeWS: return fake @@ -1598,21 +1623,20 @@ async def fake_connect(uri: str, **kw: object) -> _FakeWS: f"expected AskUserModal on top; got {type(app.screen).__name__}" ) - yes_button = next( - b for b in modal.query(Button) if b.id == "choice-y" - ) + yes_button = next(b for b in modal.query(Button) if b.id == "choice-y") await pilot.click(yes_button) await pilot.pause() resolve_frames = [ - _json.loads(f) for f in fake.sent - if _json.loads(f).get("type") == "resolve_ask" + _json.loads(f) for f in fake.sent if _json.loads(f).get("type") == "resolve_ask" ] assert len(resolve_frames) == 1, ( f"expected 1 resolve_ask frame; got {resolve_frames}" ) assert resolve_frames[0] == { - "type": "resolve_ask", "ask_id": "ask_99", "value": "y", + "type": "resolve_ask", + "ask_id": "ask_99", + "value": "y", } @@ -1633,14 +1657,18 @@ async def test_ask_user_request_cancel_sends_resolve_ask_with_none( from cothis.tui import AskUserModal, CothisApp - fake = _FakeWS([ - _json.dumps({ - "type": "ask_user_request", - "ask_id": "ask_cancel", - "prompt": "Deploy?", - "choices": ["yes", "no"], - }), - ]) + fake = _FakeWS( + [ + _json.dumps( + { + "type": "ask_user_request", + "ask_id": "ask_cancel", + "prompt": "Deploy?", + "choices": ["yes", "no"], + } + ), + ] + ) async def fake_connect(uri: str, **kw: object) -> _FakeWS: return fake @@ -1657,21 +1685,20 @@ async def fake_connect(uri: str, **kw: object) -> _FakeWS: modal = app.screen assert isinstance(modal, AskUserModal) - cancel_button = next( - b for b in modal.query(Button) if b.id == "ask-cancel" - ) + cancel_button = next(b for b in modal.query(Button) if b.id == "ask-cancel") await pilot.click(cancel_button) await pilot.pause() resolve_frames = [ - _json.loads(f) for f in fake.sent - if _json.loads(f).get("type") == "resolve_ask" + _json.loads(f) for f in fake.sent if _json.loads(f).get("type") == "resolve_ask" ] assert len(resolve_frames) == 1, ( f"expected 1 resolve_ask frame; got {resolve_frames}" ) assert resolve_frames[0] == { - "type": "resolve_ask", "ask_id": "ask_cancel", "value": None, + "type": "resolve_ask", + "ask_id": "ask_cancel", + "value": None, } @@ -1717,7 +1744,8 @@ async def test_list_configurable_skills_returns_discovered_names( Skill(name="fs-read", description="d2", body="b2", source=_Path("/y")), ] monkeypatch.setattr( - "cothis.skills.discover_skills", lambda _cwd: fake_skills, + "cothis.skills.discover_skills", + lambda _cwd: fake_skills, ) app = CothisApp() @@ -1759,7 +1787,8 @@ async def test_config_menu_modal_renders_skill_names( Skill(name="fs-read", description="d2", body="b2", source=_Path("/y")), ] monkeypatch.setattr( - "cothis.skills.discover_skills", lambda _cwd: fake_skills, + "cothis.skills.discover_skills", + lambda _cwd: fake_skills, ) app = CothisApp() @@ -1797,7 +1826,8 @@ async def test_config_menu_modal_toggle_selects_and_dismisses( Skill(name="fs-read", description="d2", body="b2", source=_Path("/y")), ] monkeypatch.setattr( - "cothis.skills.discover_skills", lambda _cwd: fake_skills, + "cothis.skills.discover_skills", + lambda _cwd: fake_skills, ) captured: list = [] @@ -1815,16 +1845,12 @@ def on_dismiss(value: object) -> None: assert isinstance(modal, ConfigMenuModal) assert modal._selected == set() - git_button = next( - b for b in modal.query(Button) if b.id == "skill-git-commit" - ) + git_button = next(b for b in modal.query(Button) if b.id == "skill-git-commit") await pilot.click(git_button) await pilot.pause() assert "git-commit" in modal._selected - done_button = next( - b for b in modal.query(Button) if b.id == "menu-done" - ) + done_button = next(b for b in modal.query(Button) if b.id == "menu-done") await pilot.click(done_button) await pilot.pause() @@ -1832,24 +1858,24 @@ def on_dismiss(value: object) -> None: # --------------------------------------------------------------------- -# Active-session highlight (#230) — SessionList items gain -# ``active-session`` CSS class when their session becomes active. +# Active-session tracking (#230) — the status dock's ``session:`` cell +# mirrors the active session after a switch (the visible signal, since +# the shell has no permanent sidebar). # --------------------------------------------------------------------- @pytest.mark.asyncio -async def test_set_active_session_highlights_matching_list_item( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +async def test_set_active_session_updates_footer_session_cell( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: - """AC #230: the active session's ListItem gains ``active-session`` class. + """AC #230: ``set_active_session`` mirrors the id into the status dock. - Seeds two sessions, selects one, verifies only its ListItem has - the ``active-session`` class; the other doesn't. + Seeds two sessions, activates each, verifies the footer's + ``session:`` cell tracks the active session id (short form). """ - from textual.widgets import ListItem - from cothis.session import Session - from cothis.tui import CothisApp, SessionList + from cothis.tui import CothisApp db_path = tmp_path / "session.db" @@ -1871,24 +1897,17 @@ async def test_set_active_session_highlights_matching_list_item( app.refresh_session_list(db_path) await pilot.pause() - # Activate the first session. + # Activate the first session: the footer reactive mirrors the id. + # (The rendered ``session:`` cell is gated by attached-WS count — + # covered separately by test_footer_session_cell_tracks_attached_count.) app.set_active_session(sid1) await pilot.pause() - - items = list(app.query_one(SessionList).query(ListItem)) - assert len(items) == 2 - classes = {item.id: item.classes for item in items} - assert "active-session" in classes[f"s_{sid1}"] - assert "active-session" not in classes[f"s_{sid2}"] + assert app.footer_session == sid1 # Switch to the second session. app.set_active_session(sid2) await pilot.pause() - - items = list(app.query_one(SessionList).query(ListItem)) - classes = {item.id: item.classes for item in items} - assert "active-session" not in classes[f"s_{sid1}"] - assert "active-session" in classes[f"s_{sid2}"] + assert app.footer_session == sid2 # --------------------------------------------------------------------- @@ -1997,7 +2016,8 @@ async def fake_connect(uri: str, **kw: object) -> _FakeWS: await app.send_run_turn("hello from b") assert len(fake_b.sent) == 1 assert _json.loads(fake_b.sent[0]) == { - "type": "run_turn", "prompt": "hello from b", + "type": "run_turn", + "prompt": "hello from b", } assert fake_a.sent == [] @@ -2006,7 +2026,8 @@ async def fake_connect(uri: str, **kw: object) -> _FakeWS: await app.send_run_turn("hello from a") assert len(fake_a.sent) == 1 assert _json.loads(fake_a.sent[0]) == { - "type": "run_turn", "prompt": "hello from a", + "type": "run_turn", + "prompt": "hello from a", } await app.detach_session_ws("session-a") @@ -2036,10 +2057,15 @@ async def test_ask_user_modal_renders_prompt_and_choices() -> None: assert isinstance(modal, AskUserModal) labels = list(modal.query(Label)) - assert any("Deploy to prod?" in str(getattr(l, "_Static__content", "")) for l in labels) + assert any( + "Deploy to prod?" in str(getattr(l, "_Static__content", "")) for l in labels + ) buttons = list(modal.query(Button)) - button_labels = [b.label.plain if hasattr(b.label, "plain") else str(b.label) for b in buttons] + button_labels = [ + b.label.plain if hasattr(b.label, "plain") else str(b.label) + for b in buttons + ] assert "yes" in button_labels assert "no" in button_labels assert "Cancel" in button_labels @@ -2069,9 +2095,7 @@ def on_dismiss(value: str | None) -> None: modal = app.screen assert isinstance(modal, AskUserModal) - yes_button = next( - b for b in modal.query(Button) if b.id == "choice-y" - ) + yes_button = next(b for b in modal.query(Button) if b.id == "choice-y") await pilot.click(yes_button) await pilot.pause() @@ -2162,9 +2186,7 @@ def on_dismiss(value: str | None) -> None: # Click the second button (feature/y) — verifies index-based ID # routing works for non-first entries. - feature_button = next( - b for b in modal.query(Button) if b.id == "wt-1" - ) + feature_button = next(b for b in modal.query(Button) if b.id == "wt-1") await pilot.click(feature_button) await pilot.pause() @@ -2207,7 +2229,8 @@ def on_dismiss(value: str | None) -> None: @pytest.mark.asyncio async def test_worktree_picker_current_dir_dismisses_with_cwd( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """The ``Current directory`` button dismisses with ``str(Path.cwd())``. @@ -2239,9 +2262,7 @@ def on_dismiss(value: str | None) -> None: await pilot.pause() modal = app.screen - cwd_button = next( - b for b in modal.query(Button) if b.id == "worktree-cwd" - ) + cwd_button = next(b for b in modal.query(Button) if b.id == "worktree-cwd") await pilot.click(cwd_button) await pilot.pause() @@ -2299,7 +2320,8 @@ async def test_worktree_picker_empty_list_renders_only_cancel() -> None: def test_save_and_load_skill_selection_round_trip( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """AC #235: save → load round-trips the selected set.""" from cothis.skills import load_skill_selection, save_skill_selection @@ -2312,7 +2334,8 @@ def test_save_and_load_skill_selection_round_trip( def test_load_skill_selection_empty_when_file_missing( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """AC #235: no file → empty set (first run).""" from cothis.skills import load_skill_selection @@ -2322,7 +2345,8 @@ def test_load_skill_selection_empty_when_file_missing( def test_load_skill_selection_handles_corrupt_json( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """AC #235: corrupt JSON → empty set + no crash.""" from cothis.skills import _skill_selection_path, load_skill_selection @@ -2341,7 +2365,8 @@ def test_load_skill_selection_handles_corrupt_json( @pytest.mark.asyncio async def test_on_menu_open_persists_selection_on_dismiss( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """#415: on_menu_open's dismiss path saves the selection (production wiring).""" from cothis.skills import load_skill_selection @@ -2363,7 +2388,8 @@ async def test_on_menu_open_persists_selection_on_dismiss( @pytest.mark.asyncio async def test_config_menu_seeds_from_saved_selection( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """#415: reopening the menu shows the previously-saved selection.""" from cothis.skills import save_skill_selection @@ -2495,7 +2521,9 @@ async def test_thinking_then_text_mounts_collapsible_before_markdown() -> None: # Markdown nested inside the Collapsible is NOT a direct child, # so positions_md captures only the text-segment Markdown. children = list(view.children) - positions_col = [i for i, c in enumerate(children) if isinstance(c, Collapsible)] + positions_col = [ + i for i, c in enumerate(children) if isinstance(c, Collapsible) + ] positions_md = [i for i, c in enumerate(children) if isinstance(c, Markdown)] assert len(positions_col) == 1, ( f"expected one Collapsible direct child; got {positions_col}" @@ -2537,8 +2565,12 @@ async def test_tool_call_after_thinking_flushes_collapsible_above_card() -> None await pilot.pause() children = list(view.children) - positions_col = [i for i, c in enumerate(children) if isinstance(c, Collapsible)] - positions_card = [i for i, c in enumerate(children) if isinstance(c, ToolCallCard)] + positions_col = [ + i for i, c in enumerate(children) if isinstance(c, Collapsible) + ] + positions_card = [ + i for i, c in enumerate(children) if isinstance(c, ToolCallCard) + ] assert len(positions_col) == 1, ( f"expected one thinking Collapsible above the card; got {positions_col}" ) @@ -2759,9 +2791,9 @@ async def fake_connect(uri: str, **kw: object) -> _FakeWS: await pilot.press("escape") await pilot.pause() assert app.run_state == "interrupted" - assert any( - _json.loads(s) == {"type": "interrupt_turn"} for s in fake.sent - ), f"expected an interrupt_turn frame; sent={fake.sent}" + assert any(_json.loads(s) == {"type": "interrupt_turn"} for s in fake.sent), ( + f"expected an interrupt_turn frame; sent={fake.sent}" + ) await app.detach_ws() await pilot.pause() @@ -2809,9 +2841,7 @@ async def fake_connect(uri: str, **kw: object) -> _FakeWS: # …and the app never sent an interrupt_turn frame. assert not any( _json.loads(s).get("type") == "interrupt_turn" for s in fake.sent - ), ( - f"Esc should have dismissed the modal, not interrupted; sent={fake.sent}" - ) + ), f"Esc should have dismissed the modal, not interrupted; sent={fake.sent}" # run_state is unchanged — the interrupt action did not fire. assert app.run_state == "running" await app.detach_ws() @@ -2830,7 +2860,8 @@ async def fake_connect(uri: str, **kw: object) -> _FakeWS: def _seed_history_session( - db: Path, cwd: Path, + db: Path, + cwd: Path, ) -> tuple[str, Path]: """Seed a session with user text + assistant [text + tool_use] + tool_result. @@ -2946,7 +2977,8 @@ async def test_replay_missing_db_is_best_effort_no_crash(tmp_path: Path) -> None @pytest.mark.asyncio async def test_attach_session_ws_replay_is_opt_in( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, ) -> None: """``db_path=`` triggers replay on attach; omitting it leaves the view blank. @@ -2971,7 +3003,10 @@ async def fake_connect(uri: str, **kw: object) -> _FakeWS: async with app_with.run_test() as pilot: await pilot.pause() await app_with.attach_session_ws( - sid, "ws://fake/agent", "tok", db_path=db, + sid, + "ws://fake/agent", + "tok", + db_path=db, ) await pilot.pause() view = app_with.query_one(ConversationView) @@ -3025,9 +3060,7 @@ async def test_replay_leaves_view_state_clean_for_live_stream(tmp_path: Path) -> markdowns = list(view.query(Markdown)) # One new Markdown mounted for the live delta. assert len(markdowns) == replayed_markdowns + 1 - live_sources = [ - getattr(md, "_markdown", "") or "" for md in markdowns - ] + live_sources = [getattr(md, "_markdown", "") or "" for md in markdowns] assert any("live token" in src for src in live_sources) @@ -3044,11 +3077,13 @@ async def test_replay_multi_turn_history_preserves_dom_order(tmp_path: Path) -> sid = s.session_id s.append_message("user", [{"type": "text", "text": "first question"}]) s.append_message( - "assistant", [{"type": "text", "text": "first answer"}], + "assistant", + [{"type": "text", "text": "first answer"}], ) s.append_message("user", [{"type": "text", "text": "second question"}]) s.append_message( - "assistant", [{"type": "text", "text": "second answer"}], + "assistant", + [{"type": "text", "text": "second answer"}], ) s.close() @@ -3062,9 +3097,7 @@ async def test_replay_multi_turn_history_preserves_dom_order(tmp_path: Path) -> # Four Markdown segments in DOM order: u1, a1, u2, a2. markdowns = list(view.query(Markdown)) assert len(markdowns) == 4 - sources = [ - getattr(md, "_markdown", "") or "" for md in markdowns - ] + sources = [getattr(md, "_markdown", "") or "" for md in markdowns] assert "first question" in sources[0] assert "first answer" in sources[1] assert "second question" in sources[2] @@ -3072,9 +3105,7 @@ async def test_replay_multi_turn_history_preserves_dom_order(tmp_path: Path) -> # DOM order of immediate children matches message order. children = list(view.children) - positions = [ - i for i, c in enumerate(children) if isinstance(c, Markdown) - ] + positions = [i for i, c in enumerate(children) if isinstance(c, Markdown)] assert positions == sorted(positions) @@ -3130,7 +3161,9 @@ async def test_replay_renders_thinking_block_as_collapsible(tmp_path: Path) -> N # DOM order matches event order: reasoning precedes the answer text. children = list(view.children) - positions_col = [i for i, c in enumerate(children) if isinstance(c, Collapsible)] + positions_col = [ + i for i, c in enumerate(children) if isinstance(c, Collapsible) + ] positions_md = [i for i, c in enumerate(children) if isinstance(c, Markdown)] assert len(positions_col) == 1 and len(positions_md) == 2, ( f"unexpected child counts: col={positions_col}, md={positions_md}" @@ -3142,7 +3175,7 @@ async def test_replay_renders_thinking_block_as_collapsible(tmp_path: Path) -> N # --------------------------------------------------------------------- -# Layout: SessionList starts empty (no fake placeholder items on launch). +# Shell: no permanent session list (transient /sessions picker only). # The list is populated by ``refresh_session_list`` once storage is wired; # showing hardcoded "session-1"/"session-2" rows before that read as real # sessions, which was the "weird layout" complaint. @@ -3150,56 +3183,53 @@ async def test_replay_renders_thinking_block_as_collapsible(tmp_path: Path) -> N @pytest.mark.asyncio -async def test_session_list_starts_empty_on_launch() -> None: - """SessionList has zero items on launch (no placeholder rows).""" - from textual.widgets import ListItem +async def test_no_session_sidebar_pane_on_launch() -> None: + """The shell has no permanent session list pane (transient picker only). - from cothis.tui import CothisApp, SessionList + Session navigation is a ``/sessions`` modal, never a sidebar — so the + launch DOM contains zero ``ListView`` widgets and the transcript owns + the full viewport. + """ + from textual.widgets import ListView + + from cothis.tui import CothisApp app = CothisApp() async with app.run_test() as pilot: await pilot.pause() - items = list(app.query_one(SessionList).query(ListItem)) - assert items == [], ( - f"SessionList should start empty; got {len(items)} placeholder(s)" + assert list(app.query(ListView)) == [], ( + "no ListView pane should exist in the transcript shell" ) # --------------------------------------------------------------------- -# Layout: the SessionList sidebar auto-hides in single-session mode so -# ConversationView takes the full width (the "weird layout" complaint — -# a near-empty 24-col sidebar ate a quarter of the screen for one -# session). Visible only when switching among sessions is possible. +# Shell: no session sidebar at all — session navigation is a transient +# ``/sessions`` picker, so the transcript always owns the full viewport. # --------------------------------------------------------------------- @pytest.mark.asyncio -async def test_sidebar_hidden_on_launch_with_no_sessions() -> None: - """No sessions → the sidebar is hidden; ConversationView is full-width.""" - from cothis.tui import ConversationView, CothisApp, SessionList +async def test_transcript_full_width_on_launch_with_no_sessions() -> None: + """No sessions → the transcript spans the full viewport (no sidebar).""" + from cothis.tui import ConversationView, CothisApp app = CothisApp() - async with app.run_test() as pilot: + async with app.run_test(size=(100, 40)) as pilot: await pilot.pause() - session_list = app.query_one(SessionList) - assert session_list.display is False, ( - f"empty sidebar should be hidden; got display={session_list.display!r}" - ) - main = app.query_one("#main") conv = app.query_one(ConversationView) - assert conv.region.width == main.region.width, ( - f"conversation should span the full width when the sidebar is " - f"hidden; got conv={conv.region.width} vs main={main.region.width}" + assert conv.region.width == app.size.width, ( + f"transcript should span the full width; got {conv.region.width}" ) @pytest.mark.asyncio -async def test_sidebar_hidden_with_single_listed_session( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +async def test_single_listed_session_no_auto_picker( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: - """One session in the list → the sidebar stays hidden.""" + """One session in the index → no modal is pushed; the picker is transient.""" from cothis.session import Session - from cothis.tui import CothisApp, SessionList + from cothis.tui import CothisApp, SessionPickerModal db_path = tmp_path / "session.db" s = Session.new(db_path, cwd=tmp_path, model="m", flush_sync=True) @@ -3213,19 +3243,26 @@ async def test_sidebar_hidden_with_single_listed_session( await pilot.pause() app.refresh_session_list(db_path) await pilot.pause() - assert app.query_one(SessionList).display is False, ( - "single-session mode should hide the sidebar" - ) + assert len(app._session_rows) == 1 + # No modal auto-shown on refresh. + assert not isinstance(app.screen, SessionPickerModal) + # The picker is available on demand. + app.action_sessions() + await pilot.pause() + assert isinstance(app.screen, SessionPickerModal) @pytest.mark.asyncio -async def test_sidebar_shown_with_multiple_listed_sessions( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +async def test_multiple_listed_sessions_picker_lists_both( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: - """Two+ sessions in the list → the sidebar appears for switching.""" + """Two+ sessions in the index → the transient picker lists them all.""" + from textual.widgets import ListView + from cothis.session import Session from cothis.session.storage import SessionRow - from cothis.tui import CothisApp, SessionList + from cothis.tui import CothisApp, SessionPickerModal db_path = tmp_path / "session.db" cwd_a = tmp_path / "worktree-a" @@ -3257,21 +3294,25 @@ async def test_sidebar_shown_with_multiple_listed_sessions( await pilot.pause() app.refresh_session_list(db_path) await pilot.pause() - assert app.query_one(SessionList).display is True, ( - "multi-session mode should show the sidebar" - ) + assert len(app._session_rows) == 2 + app.action_sessions() + await pilot.pause() + modal = app.screen + assert isinstance(modal, SessionPickerModal) + lv = modal.query_one("#session-picker-list", ListView) + assert len(lv.children) == 2 @pytest.mark.asyncio -async def test_sidebar_tracks_attached_session_count( +async def test_footer_session_cell_tracks_attached_count( monkeypatch: pytest.MonkeyPatch, ) -> None: - """Sidebar shows once 2 sessions attach; hides again when one detaches. + """The status dock's ``session:`` cell follows the attached-WS count. - Mirrors the footer's ``session:`` cell boundary — the sidebar is the - same "can I switch?" signal, so it follows the attached-WS count. + The shell has no sidebar; the "can I switch?" signal is the footer's + ``session:`` cell, shown only when >1 session is attached. """ - from cothis.tui import CothisApp, SessionList + from cothis.tui import CothisApp fake_a = _FakeWS([]) fake_b = _FakeWS([]) @@ -3287,17 +3328,16 @@ async def fake_connect_async(uri: str, **kw: object) -> _FakeWS: app = CothisApp() async with app.run_test() as pilot: await pilot.pause() - session_list = app.query_one(SessionList) - assert session_list.display is False, "start hidden (no sessions)" + assert "session:" not in app._render_footer_str(), "no sessions → no cell" await app.attach_session_ws("sess-a", "ws://fake/agent-a", "tok") await pilot.pause() - assert session_list.display is False, "one session → still hidden" + assert "session:" not in app._render_footer_str(), "one session → no cell" await app.attach_session_ws("sess-b", "ws://fake/agent-b", "tok") await pilot.pause() - assert session_list.display is True, "two sessions → sidebar shows" + assert "session:sess-b" in app._render_footer_str(), "two sessions → cell" await app.detach_session_ws("sess-b") await pilot.pause() - assert session_list.display is False, "back to one → sidebar hides" + assert "session:" not in app._render_footer_str(), "back to one → no cell" # --------------------------------------------------------------------- @@ -3385,7 +3425,8 @@ async def fake_connect_async(uri: str, **kw: object) -> _FakeWS: # --------------------------------------------------------------------- # Slash-command session switching (``/session ``) — the ``/``-prefixed -# switch path that mirrors the left-pane SessionList click. +# switch path (the transcript shell's only session-switch surface besides +# the transient ``/sessions`` picker). # --------------------------------------------------------------------- @@ -3477,7 +3518,8 @@ async def fake_connect_async(uri: str, **kw: object) -> _FakeWS: @pytest.mark.asyncio async def test_slash_session_ambiguous_prefix_is_noop( - monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, ) -> None: """An ambiguous prefix logs + leaves the active session unchanged.""" import logging @@ -3511,8 +3553,7 @@ async def fake_connect_async(uri: str, **kw: object) -> _FakeWS: "ambiguous prefix must not switch the active session" ) assert any("matches 2" in r.getMessage() for r in caplog.records), ( - "expected an ambiguity log; " - f"got {[r.getMessage() for r in caplog.records]}" + f"expected an ambiguity log; got {[r.getMessage() for r in caplog.records]}" ) await app.detach_session_ws("abc111") @@ -3522,7 +3563,8 @@ async def fake_connect_async(uri: str, **kw: object) -> _FakeWS: @pytest.mark.asyncio async def test_slash_session_unmatched_with_attached_is_noop( - monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, ) -> None: """An unmatched id WITH sessions attached does not phantom-switch. @@ -3563,12 +3605,8 @@ async def fake_connect_async(uri: str, **kw: object) -> _FakeWS: "active session (no phantom switch)" ) assert any( - "matches no attached session" in r.getMessage() - for r in caplog.records - ), ( - "expected a no-match log; " - f"got {[r.getMessage() for r in caplog.records]}" - ) + "matches no attached session" in r.getMessage() for r in caplog.records + ), f"expected a no-match log; got {[r.getMessage() for r in caplog.records]}" await app.detach_session_ws("abc111") await app.detach_session_ws("abc222") @@ -3610,22 +3648,26 @@ async def test_unknown_slash_command_falls_through_to_prompt() -> None: await app.action_send_prompt() await pilot.pause() view = app.query_one(ConversationView) + from textual.widgets import Markdown + + boxes = [w for w in view.query(Markdown) if "user-message" in w.classes] # Fell through: echoed as a normal user prompt, input cleared. - assert "/notacommand hello" in view.renderable_str + assert boxes and "/notacommand hello" in boxes[-1]._markdown assert bar.text == "" # --------------------------------------------------------------------- # View-swap on session switch (#230) — switching the active session # clears ConversationView + replays the target session's history so the -# view matches the active session (both switch paths — SessionList click +# view matches the active session (both switch paths — picker selection # + ``/session`` — route through ``on_active_session_changed``). # --------------------------------------------------------------------- @pytest.mark.asyncio async def test_session_switch_swaps_view_to_target_history( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Switching the active session clears the old view + replays the target. @@ -3681,8 +3723,7 @@ def _md_sources(app: CothisApp) -> list[str]: f"expected beta-msg after switching to {sid2[:8]}; got {sources!r}" ) assert not any("alpha-msg" in s for s in sources), ( - "previous session content must be cleared on switch; " - f"got {sources!r}" + f"previous session content must be cleared on switch; got {sources!r}" ) diff --git a/tests/test_tui_pty.py b/tests/test_tui_pty.py index 1ed8240..2a8c1fe 100644 --- a/tests/test_tui_pty.py +++ b/tests/test_tui_pty.py @@ -110,16 +110,20 @@ def _strip_ansi(data: bytes) -> bytes: def test_tui_input_receives_keystrokes_over_real_pty() -> None: """Typing into the focused ``TextArea`` inserts characters over a real PTY. - Launch focus is ``SessionList`` (``CothisApp.on_mount``); two Tabs move - focus SessionList -> ConversationView -> TextArea. Typing then must - insert into the TextArea — pre-#375 the ``InputBar(Container)`` wrapper - dropped every keystroke on this exact path. + The composer input holds default focus at launch (``CothisApp.on_mount`` + → ``_refocus_input``), so keystrokes land in the prompt with no Tab + navigation. Typing must insert into the TextArea — pre-#375 the + ``InputBar(Container)`` wrapper dropped every keystroke on this exact + path; a focus regression (default focus moved off the input) fails + here because the marker would type into nothing. """ marker = "PTYMARKER375" proc, master = _spawn_tui_pty() try: _drain(master, deadline=3.0) # let the TUI finish its first render - os.write(master, b"\t\t") # focus the TextArea + # Input holds default focus — type directly, no Tab navigation. + # A short settle lets on_mount's focus assignment land even on + # slow runners (macOS CI), so keystrokes don't race the focus. time.sleep(0.4) _drain(master, deadline=0.5) os.write(master, marker.encode())