Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8463eb22ff | |||
| eb93e6d5f0 | |||
| cfee89ac1c | |||
| 7106af5c09 | |||
| ffd22fb587 | |||
| 2756f5f1dd | |||
| 24e4371ec7 |
@@ -0,0 +1,218 @@
|
||||
---
|
||||
contract_version: "2.1"
|
||||
target_module: "ratatoskr.tui"
|
||||
scope: "Design-brief §5 v1 entry point: reshape the TUI from vertical-stack single-pane to Horizontal two-column with `TabbedContent` on the right; first (and only v1) tab is `Tools`, which consumes `ToolStart` / `ToolResult` SSE events that previously rendered inline in the transcript. Pure in-place amendment to issue #4 + #12 — no new modules, no new files apart from this contract. The CLI (`ratatoskr.cli`) is unaffected: `--send` mode is non-interactive and keeps its current inline tool-event rendering. Substrate move only: persona / admin-events / bifrost-state / server-log panes stay deferred (blocked on remote-Worldtree topology + admin scope + opt-in flag). The TabbedContent shape makes them additive — when a blocker resolves the new pane plugs in as another TabPane sibling without further layout work."
|
||||
depends_on:
|
||||
- "textual"
|
||||
used_by: []
|
||||
language: "python"
|
||||
complexity: "medium"
|
||||
estimated_loc: 180
|
||||
confidence: 0.85
|
||||
assumptions:
|
||||
- "Textual's `TabbedContent(*titles, initial='')` + `TabPane(title, *children, id=...)` is the right primitive for the right-column tabs (verified API at textual.widgets._tabbed_content). One `TabPane(\"Tools\", tools_log, id=\"tools-tab\")` in v1; additional siblings get appended as Persona/AdminEvents/BifrostState/ServerLog land."
|
||||
- "Textual's `Horizontal` + `Vertical` containers compose the two-column split (verified at textual.containers). Width via CSS `width: 2fr` on the left container + `width: 1fr` on the right container gives the 2:1 chat-primary split."
|
||||
- "**The presenter contract amendment is small and tightly scoped**: `TuiPresenterState.render` gains a `tools_log: RichLog` parameter alongside the existing `log: RichLog` (main transcript) + `thinking_widget: Static`. `ToolStart` / `ToolResult` events route to `tools_log`; every other event (Text, Thinking, WorkerPhase, Done, Error, Cancelled, TextBoundary) keeps its existing routing to `log` + `thinking_widget`. The plain-label fallback path in `_plain_label` (issue #12 INV-009 render-exception recovery) keeps its current shape — only the routing target changes."
|
||||
- "**Tool events are routed, not duplicated**. The brief's §5 wording 'side pane (inline-from-SSE for v1)' factors tool events OUT of the main transcript. A consumer who wants to debug a tool-using turn now reads the Tools pane; the main transcript stays focused on assistant text. Trade-off: a fast-skim of the transcript no longer shows tool activity inline; if that hurts the debug ergonomics empirically, a follow-up issue can add a one-line `· tool_used name=...` breadcrumb to the transcript as a compromise. v1 commits to the cleaner split."
|
||||
- "**Demoted-prefix style stays consistent across panes**. ToolStart in the Tools pane renders as `· tool_start: name=foo args={...}` — the same `· ` ASCII prefix issue #12 INV-005 established for demoted telemetry in the main transcript. Pane separation handles the visual hierarchy; prefix style stays cross-pane consistent so the operator's mental model is portable."
|
||||
- "**Input field retains focus across tab switches** (design-brief §5 invariant: 'Tab key (Ctrl+1..5) jumps between tabs without losing focus on the input field'). INV-016 is the load-bearing invariant; the assumption about Textual's default behavior is just an implementation hint. If Textual's default `TabbedContent.active = ...` programmatic assignment preserves Input focus (current observed behavior), no extra code is needed. If a future Textual version regresses on this, the implementation MUST add `self.query_one('#prompt', Input).focus()` immediately after the `.active = ` assignment in `action_focus_tools` to satisfy INV-016. The test `test_ctrl_1_preserves_input_focus` is the regression guard; if it fails, the fix is the explicit `.focus()` call, not relaxing the invariant. Pre-existing INV-007 (Input always-focused except during error sub-states) is preserved verbatim."
|
||||
- "**Status footer gains a `current-pane-name` element** — design-brief §5 calls for it explicitly. v1 only has one tab so the indicator stable-renders \"Tools\". Wiring it as a separate Static (`id='pane-name'`) docked alongside identity + hint makes it trivially extend when more tabs land — the widget is in place; the value will become dynamic in the future multi-tab issue. **No tab-switch handler wiring lands in #13.** The `on_mount` flow populates `pane-name` once with the literal string \"Tools\" and never updates it. Adding event-handler plumbing in v1 (a `@on(TabbedContent.TabActivated)` handler, etc.) is out of scope — that's a deliberate deferral, not an implementer's call."
|
||||
- "**The existing `· thinking-current` widget keeps its position** — docked to the top of the left column (was docked top of the whole App; now docked top of the left Vertical container). Pre-amendment dock-fix from v0.2.1 stays; the scope of the dock just narrows from \"App\" to \"left column\" so it doesn't bleed into the right column's TabbedContent area."
|
||||
- "**Width split is fixed `2fr : 1fr` for v1**. User-resizable splits are textual-native (via `Splitter` or similar), but adding interactive resize is its own UX surface. v1 ships fixed; if the right pane proves cramped on narrow terminals operators will tell us. Out of scope."
|
||||
- "**TabbedContent's CSS classes**: the right column's TabbedContent + its tabbed-content wrappers (`#tabbed-content`, `.--tabs`, etc.) come with Textual's default styling. No custom CSS for tab strip in v1; if the visual feels wrong adjust later. The contract specifies the structure; the chrome stays Textual-default."
|
||||
- "**Test strategy**: existing TestStreamTurnWorker tests for Text/Thinking/Done/Error/Cancelled routing stay GREEN unchanged (they assert what shows in the transcript log; that still shows the same content). ToolStart/ToolResult tests get adjusted to assert routing to `tools_log` instead of `log`. New tests cover layout shape (Horizontal parent exists, TabbedContent with tools-tab on the right) + Ctrl+1 binding + tools_log writes for tool events."
|
||||
- "**Issue #12's INV-009 render-exception fallback path** stays correct: `_plain_label(event)` is still callable; the render method's except branch writes the labeled-string to the appropriate Widget (tools_log for tool events; log for everything else). The fallback writes to the same routed widget as a successful render — failure mode preserves the routing invariant."
|
||||
open_questions:
|
||||
- "Should the Tools tab show a count badge when new tool events arrive while the user is on a future Persona/AdminEvents tab? Draft: no for v1 — only one tab, so the question is moot. When persona/admin-events panes land, revisit: a small `[N]` badge on the tab header (`'Tools [3]'`) would help operators not miss tool activity that happens off-screen. Defer to a follow-up that touches multiple panes."
|
||||
- "Should the Tools pane support filter-by-tool-name (e.g., show only `kb_search` results)? Draft: no for v1 — flat scroll matches the design brief's posture. The volume of tool events per turn is small enough that scrollback handles the use case. Revisit if mimir-style heavy-tool agents produce visible-cluttering volume."
|
||||
- "Should the v1 Ctrl+1 binding be Ctrl+1 specifically, or `t` for 'tools' (no modifier)? Draft: Ctrl+1 — design brief specifies `Ctrl+1..5` as the family; matches the Ctrl-prefix discipline already used by Ctrl-C / Ctrl-D bindings. Plain-letter bindings would steal letter input from the Input field; Ctrl-prefixed is the standard escape."
|
||||
prd:
|
||||
issue: 13
|
||||
issue_url: "https://gitea.phasefinal.com/vh/ratatoskr/issues/13"
|
||||
body_sha256_16: "52c8f886a9cc986a"
|
||||
lock_in_comment_id: null
|
||||
lock_in_sha256_16: null
|
||||
lock_in_at: null
|
||||
pinned_at: "2026-05-24T02:28:28+00:00"
|
||||
dependencies:
|
||||
- issue: 4
|
||||
path: "src/ratatoskr/tui.py"
|
||||
reason: "In-place contract amendment: `RatatoskrApp.compose()` reshapes from vertical-stack to Horizontal 2-col + TabbedContent right; `RatatoskrApp.DEFAULT_CSS` reshapes to scope dock rules to the new left/right containers; `RatatoskrApp.__init__` is unchanged (state attributes carry over); `RatatoskrApp.on_mount` gains pane-name widget population + tools_log lookup; `RatatoskrApp.BINDINGS` gains `Ctrl+1` → `action_focus_tools`; existing `action_interrupt` / `action_quit` unchanged."
|
||||
- issue: 12
|
||||
path: "src/ratatoskr/tui.py"
|
||||
reason: "In-place contract amendment: `TuiPresenterState.render` signature widens to accept `tools_log: RichLog` alongside `log` + `thinking_widget`; ToolStart/ToolResult branches write to `tools_log` instead of `log`. `_stream_turn_worker` does the new lookup (`self.query_one('#tools-log', RichLog)`) and threads it through. All other event branches stay verbatim."
|
||||
---
|
||||
|
||||
# TUI layout reshape + Tools pane — §5 v1 entry point
|
||||
|
||||
## Context
|
||||
|
||||
`ratatoskr.tui` ships v0.x as a single-pane Textual app: Header / thinking-current widget / transcript RichLog / Input / identity + hint Statics / Footer, vertical-stacked via `dock: top` / `dock: bottom` CSS (v0.2.1 layout fix). Design-brief §5 commits the product to a multi-pane debug-observability dashboard. Most of §5's panes (Persona, AdminEvents, BifrostState widget, ServerLog) are blocked on substrate that isn't here — remote-Worldtree topology blocks file-tail-based panes; issue #11's `admin.events.read` scope blocks the admin surfaces.
|
||||
|
||||
The unblocked v1 entry point is **layout reshape + Tools pane together**: reshape the TUI into the Horizontal two-column shape the design brief specifies, with `TabbedContent` on the right populated by a single `Tools` tab that consumes the existing `ToolStart` / `ToolResult` SSE events. No new endpoints; no scope grants; no cross-repo coordination. The shape is the foundation; subsequent panes plug in additively.
|
||||
|
||||
## Data flow
|
||||
|
||||
**Input** (unchanged from issue #4):
|
||||
- `args: ParsedArgs`, `session_id: str`, `agent_id: str | None`, `client: httpx.AsyncClient`.
|
||||
- SSE event stream from `ratatoskr.sse_client.stream_turn`.
|
||||
|
||||
**Output** (unchanged):
|
||||
- Exit code via `App.exit(code)`.
|
||||
|
||||
**Internal routing change**:
|
||||
- `ToolStart` / `ToolResult` events route to `tools_log: RichLog` (Tools pane) instead of the main transcript `log: RichLog`.
|
||||
- All other event types (`Text`, `Thinking`, `WorkerPhase`, `TextBoundary`, `Done`, `Error`, `Cancelled`) keep their existing routing.
|
||||
|
||||
## Layout shape (post-amendment)
|
||||
|
||||
```
|
||||
RatatoskrApp(App[int]):
|
||||
compose():
|
||||
yield Header()
|
||||
yield Horizontal(
|
||||
Vertical(
|
||||
Static(id="thinking-current"), # dock: top of left column
|
||||
RichLog(id="transcript"), # height: 1fr (fills middle)
|
||||
Input(id="prompt"), # dock: bottom of left column
|
||||
id="left-column",
|
||||
),
|
||||
Vertical(
|
||||
TabbedContent(
|
||||
TabPane("Tools", RichLog(id="tools-log"), id="tools-tab"),
|
||||
# future: TabPane("Persona", …, id="persona-tab"), etc.
|
||||
id="side-panes",
|
||||
),
|
||||
id="right-column",
|
||||
),
|
||||
id="main-row",
|
||||
)
|
||||
yield Static(id="identity") # dock: bottom of App
|
||||
yield Static(id="pane-name") # dock: bottom of App (new in §5)
|
||||
yield Static(id="hint") # dock: bottom of App
|
||||
yield Footer()
|
||||
```
|
||||
|
||||
**DEFAULT_CSS reshape:**
|
||||
|
||||
```css
|
||||
#main-row { height: 1fr; }
|
||||
#left-column { width: 2fr; }
|
||||
#right-column { width: 1fr; }
|
||||
#thinking-current { dock: top; height: auto; }
|
||||
#transcript { height: 1fr; }
|
||||
#prompt { dock: bottom; }
|
||||
#identity { dock: bottom; height: 1; }
|
||||
#pane-name { dock: bottom; height: 1; }
|
||||
#hint { dock: bottom; height: 1; }
|
||||
```
|
||||
|
||||
Dock rules scope to the right container (left column for `thinking-current`/`prompt`; App for `identity`/`pane-name`/`hint`). The left-column `prompt` Input docks to the bottom of its column, not the App, so the right column's TabbedContent extends full height beside it.
|
||||
|
||||
## Presenter routing (amendment to issue #12)
|
||||
|
||||
```
|
||||
FN TuiPresenterState.render(
|
||||
event: Event,
|
||||
*,
|
||||
log: RichLog,
|
||||
thinking_widget: Static,
|
||||
tools_log: RichLog, # NEW (issue #13)
|
||||
raw: bool,
|
||||
) -> None
|
||||
```
|
||||
|
||||
Steps (only the ToolStart/ToolResult cases change; every other case keeps issue #12's behavior verbatim):
|
||||
|
||||
- `ToolStart` → write `· tool_start: name=<name> args=<args!r>` to `tools_log` (not `log`).
|
||||
- `ToolResult` → write `· tool_result: name=<name> duration_ms=<n> result=<r!r:.200>` to `tools_log` (not `log`).
|
||||
- All other events → unchanged routing per issue #12 INV-005.
|
||||
- Render-exception fallback (`_plain_label(event)`): write to `tools_log` if the event is `ToolStart`/`ToolResult`; write to `log` otherwise. Routing preservation under failure.
|
||||
|
||||
## Keybindings (amendment)
|
||||
|
||||
```
|
||||
BINDINGS: ClassVar[list[Binding]] = [
|
||||
Binding("ctrl+c", "interrupt", "Cancel / Exit", priority=True),
|
||||
Binding("ctrl+d", "quit", "Exit immediately", priority=True),
|
||||
Binding("ctrl+1", "focus_tools", "Tools tab", priority=False), # NEW
|
||||
]
|
||||
|
||||
def action_focus_tools(self) -> None:
|
||||
self.query_one(TabbedContent).active = "tools-tab"
|
||||
# Input focus is preserved by Textual's default behavior — TabbedContent
|
||||
# doesn't steal focus when `.active` is set programmatically.
|
||||
```
|
||||
|
||||
`Ctrl+1` is the v1 entry of the design-brief `Ctrl+1..5` family. `Ctrl+2..5` get added by subsequent issues as Persona/AdminEvents/BifrostState/ServerLog land. The binding does NOT steal Input focus — the test asserts `Input` keeps focus across the tab switch.
|
||||
|
||||
## Status footer (amendment)
|
||||
|
||||
New `Static(id="pane-name")` widget alongside the existing `identity` + `hint` widgets. v1 renders the literal string `"Tools"` set once at `on_mount`; the widget never updates after that. Dynamic updating + tab-switch handler wiring is **out of scope for #13** — it lands in the multi-tab follow-up that introduces the second TabPane. An implementer who adds a `@on(TabbedContent.TabActivated)` handler in this issue is out of spec.
|
||||
|
||||
## Invariants
|
||||
|
||||
- **INV-013**: Layout is `Horizontal` two-column. Left column width = 2fr; right column width = 1fr.
|
||||
- **INV-014**: `ToolStart` / `ToolResult` events route to `tools_log` (Tools pane), never to `log` (transcript).
|
||||
- **INV-015** *(amended v0.5.0)*: The transcript (`log`) is **content-only** — receives `Text`, `Done` (label + Markdown body + Rule), `Error`, `Cancelled`, and the user-prompt echo (`❯ <content>`). **All telemetry events (`Thinking` closed runs, `WorkerPhase`, `TextBoundary`) route to `debug_log` (Debug pane), NOT `log`.** Live thinking deltas continue to update `thinking_widget` per-delta. The pre-v0.5.0 shape (telemetry mixed into transcript) is retired under the project's no-backwards-compat rule.
|
||||
- **INV-016**: Input retains keyboard focus across `Ctrl+1` / `Ctrl+2` tab switches.
|
||||
- **INV-017** *(amended v0.5.0)*: `thinking-current` Static docks to the top of the **right column** (above `TabbedContent`), not the left column. Live thinking visibility persists across tab switches. v0.5.0 moves it from left → right so the left column is genuinely content-only.
|
||||
- **INV-018**: CLI mode (`ratatoskr.cli._amain`) is unaffected. CLI keeps inline `· tool_start: …` / `· tool_result: …` rendering on stderr per issue #12 INV-005.
|
||||
- **INV-019** *(amended v0.6.0)*: Three TabPanes in the right column: `Tools` (id `tools-tab`, contains `#tools-log`) + `Debug` (id `debug-tab`, contains `#debug-log`) + `Thinking` (id `thinking-tab`, contains `#thinking-log`). Ctrl+1/Ctrl+2/Ctrl+3 activate respective tabs. `pane-name` Static reflects active tab name dynamically.
|
||||
- **INV-020** *(amended v0.6.0)*: Render-exception fallback (INV-009) preserves routing per event class: `ToolStart` / `ToolResult` → `tools_log`; `Thinking` → `thinking_log`; `WorkerPhase` / `TextBoundary` → `debug_log`; everything else → `log`.
|
||||
- **INV-021** *(new v0.6.0)*: `Text` events do NOT route to `log` per-delta. They accumulate into `TuiPresenterState.text_buffer` and update a single `current_text` Static (docked above the prompt). On terminal event (`Done`/`Error`/`Cancelled`), `current_text` is cleared and (raw mode) accumulated text or (non-raw) post-Done `Markdown(response)` is written to `log`. The pre-v0.6.0 per-token RichLog spam is retired.
|
||||
- **INV-022** *(new v0.6.0)*: Closed thinking runs route to `thinking_log`, NOT `debug_log`. Each closed run writes three entries: `Rule(title=f"turn N · thinking #K start")`, `Markdown(content)`, `Rule(title=f"turn N · thinking #K end")` — the model's chain-of-thought is presented as rendered Markdown (model reasoning often has lists / code / structure) wrapped in operator-visible start/end markers. `thinking_run_index` increments per-run within a turn.
|
||||
- **INV-023** *(new v0.6.0)*: Turn-ID header `Rule(title=f"turn N")` is written to all four log panes (`log`, `tools_log`, `debug_log`, `thinking_log`) by `_stream_turn_worker` on the first event of each turn — enables cross-pane visual correlation during multi-turn debugging.
|
||||
- **INV-024** *(amended v0.6.1)*: `thinking-current` Static lives INSIDE the Thinking TabPane (docked bottom, below `thinking-log`) — co-located with closed thinking runs so the operator sees streaming + history in one pane. Pre-v0.6.1 it sat above the TabbedContent (right-column header) which created a top/bottom discontinuity; the co-located shape resolves that. Trade-off: live thinking is now visible only when the Thinking tab is active (Ctrl+3). Prefix `thinking… ` self-identifies the widget contents.
|
||||
|
||||
## TESTS (additions / changes to test_tui.py)
|
||||
|
||||
```
|
||||
- test_compose_has_horizontal_main_row: RatatoskrApp.compose() yields a Horizontal with id="main-row" containing left-column + right-column children.
|
||||
- test_compose_right_column_has_tabbed_content: query_one("#side-panes", TabbedContent) is non-None; one TabPane child with title="Tools" id="tools-tab".
|
||||
- test_compose_left_column_has_transcript_input: query_one("#left-column", Vertical) contains #transcript (RichLog) + #prompt (Input).
|
||||
- test_tools_log_present: query_one("#tools-log", RichLog) is non-None; lives inside the tools-tab TabPane.
|
||||
- test_pane_name_widget_renders_tools: query_one("#pane-name", Static).renderable == "Tools" (v1 static).
|
||||
- test_tool_start_routes_to_tools_log: stream_turn emits ToolStart → tools_log receives the line; transcript RichLog does NOT receive it.
|
||||
- test_tool_result_routes_to_tools_log: stream_turn emits ToolResult → tools_log receives the line; transcript does NOT receive it.
|
||||
- test_text_event_still_routes_to_transcript: stream_turn emits Text("hello") → transcript receives it; tools_log does NOT.
|
||||
- test_thinking_event_still_routes_to_thinking_widget: thinking deltas continue to update #thinking-current Static, not tools_log.
|
||||
- test_done_event_still_routes_to_transcript: Done event renders `[done] …` in transcript, not tools_log.
|
||||
- test_ctrl_1_activates_tools_tab: simulate Ctrl+1 → TabbedContent.active == "tools-tab".
|
||||
- test_ctrl_1_preserves_input_focus: simulate Ctrl+1 while Input is focused → Input is still focused afterwards.
|
||||
- test_plain_label_fallback_routes_tool_events_to_tools_log: simulate render exception on a ToolStart → tools_log gets the _plain_label fallback string; transcript doesn't.
|
||||
```
|
||||
|
||||
Existing tests that need adjustment (NOT rewrite):
|
||||
- Any test that asserted `log.write(...)` was called with a `tool_start: …` / `tool_result: …` string changes its target widget to `tools_log` instead.
|
||||
- TestAppMount tests gain `tools_log` widget lookup assertions.
|
||||
|
||||
## ERROR_ROUTING (unchanged)
|
||||
|
||||
All error routing from issues #4 / #6 / #7 / #12 stays verbatim. The tools_log routing change is internal to the presenter; error paths (`SseConnectFailed`, `SseConnectionDropped`, `MalformedSseId`, `MalformedSseData`, `TurnIdFlip`) all write their labeled lines to `log` (the main transcript). Reason: errors are turn-terminal and need to be visible in the operator's primary attention surface; routing them to the Tools pane would hide them behind a tab switch.
|
||||
|
||||
## Layout-spec snapshot (after v0.5.0)
|
||||
|
||||
```
|
||||
+────────────────────────────────+──────────────────────+
|
||||
| ❯ user-typed line | · thinking-current |
|
||||
| assistant streaming text... | ┌Tools─┬─Debug─────┐|
|
||||
| [done] turn_id=… duration=… | │ · tool_start:.. │|
|
||||
| …markdown render… | │ · tool_result.. │|
|
||||
| | │ │|
|
||||
| | │ │|
|
||||
| | │ │|
|
||||
| [prompt: type and press Enter]| └──────────────────┘|
|
||||
+────────────────────────────────+──────────────────────+
|
||||
| agent · …sess_id Tools Ctrl-C twice to exit |
|
||||
+───────────────────────────────────────────────────────+
|
||||
```
|
||||
|
||||
Left column is content-only (transcript + prompt). Right column hosts the
|
||||
live `thinking-current` Static at top + `TabbedContent` cycling between
|
||||
`Tools` (tool events) and `Debug` (thinking closed runs + worker_phase +
|
||||
text_boundary).
|
||||
|
||||
(Width split 2fr:1fr; tab strip is Textual-default; Header/Footer
|
||||
backgrounds explicitly set to `$surface` to override the Textual default
|
||||
`$primary`-blue tinting.)
|
||||
+29
-10
@@ -32,17 +32,32 @@ separate dev team rather than an in-tree Worldtree tool.
|
||||
|
||||
## Current state / in-flight
|
||||
|
||||
_As of 2026-05-24 (post-v0.3.0 startup agent picker):_
|
||||
_As of 2026-05-24 (post-v0.6.1 picker contrast + thinking inline):_
|
||||
|
||||
**Status: v0.3.0 shipped.** Eight core issues complete (`sse_client`
|
||||
**Status: v0.6.1 shipped.** Nine core issues complete (`sse_client`
|
||||
#1, `sessions` #2, `cli` #3, `tui` #4, `--end-user-id` #5, TUI
|
||||
startup error visibility #6, presenter contract semantics amendment
|
||||
#12, startup agent picker #8) + robustness fix #7 (MalformedSseData
|
||||
+ empty-skip) + v0.2.1 TUI layout fix. 227/227 tests GREEN; ruff
|
||||
clean.
|
||||
#12, startup agent picker #8, §5 layout reshape + Tools pane #13)
|
||||
+ robustness fix #7 (MalformedSseData + empty-skip) + v0.2.1 TUI
|
||||
layout fix. 236/236 tests GREEN; ruff clean.
|
||||
|
||||
**§5 v1 entry point shipped (issue #13).** TUI now Horizontal
|
||||
two-column: left = chat surface (transcript + thinking-current +
|
||||
prompt); right = TabbedContent with single Tools tab (RichLog
|
||||
receiving ToolStart/ToolResult events). Routing-not-duplication:
|
||||
tool events leave the main transcript entirely. Ctrl+1 activates
|
||||
Tools tab without losing Input focus (INV-016). New `pane-name`
|
||||
Static in the footer (static "Tools" v1; dynamic when more tabs
|
||||
land). CLI mode (--send) unaffected by design — INV-018.
|
||||
|
||||
Last commits on `main`:
|
||||
- v0.3.0 feat(sessions,cli,tui): issue #8 — startup agent picker
|
||||
- v0.6.1 style(tui): kill remaining blue + thinking-current into Thinking pane
|
||||
- `cfee89a` refactor(tui): streaming + turn headers + Thinking pane (v0.6.0)
|
||||
- `7106af5` style(tui): UI polish pass — terminal label colors, placeholders (v0.5.1)
|
||||
- `ffd22fb` refactor(tui): content-only main pane + Debug tab + chrome dark (v0.5.0)
|
||||
- `2756f5f` style(tui): apply Australis theme to TUI chrome + widgets (v0.4.1)
|
||||
- `24e4371` feat(tui): issue #13 — §5 layout reshape + Tools pane (v0.4.0)
|
||||
- `d30be12` feat(sessions,cli,tui): issue #8 — startup agent picker (v0.3.0)
|
||||
- `c85f6bd` fix(tui): anchor layout via dock so Input never moves (v0.2.1)
|
||||
- `3b9c610` feat(cli,tui): issue #12 — presenter contract semantics amendment (v0.2.0)
|
||||
- `8282156` snapshot: persistent-memory Heimdall scope-model foot-gun
|
||||
@@ -64,10 +79,14 @@ Last commits on `main`:
|
||||
"a lot better" interactively.
|
||||
|
||||
**Outstanding operator-side todos:**
|
||||
- **Interactive TUI picker eyeball** — `source env.sh && uv run
|
||||
ratatoskr --new` (no flags after) should show the picker; pick
|
||||
lofn; type a message; verify response streams cleanly. Auto-pick
|
||||
smoke confirmed the wiring; visual confirmation pending.
|
||||
- **Interactive §5 layout eyeball** — `source env.sh && uv run
|
||||
ratatoskr --new --agent mimir`, ask a tool-using question
|
||||
("search your KB for X"). Confirm: left column shows chat /
|
||||
thinking; right column's Tools tab shows tool_start +
|
||||
tool_result with `· ` prefix; Ctrl+1 doesn't break input focus;
|
||||
no width-clamp issues on the operator's terminal. Programmatic
|
||||
smoke confirmed all the routing + binding; visual confirmation
|
||||
pending.
|
||||
- **Post-v0.2.1 TUI multi-turn eyeball** — confirm thinking-run
|
||||
bouncing is gone across multiple turns; the layout fix has only
|
||||
been confirmed for a single turn so far.
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "ratatoskr"
|
||||
version = "0.3.0"
|
||||
version = "0.6.1"
|
||||
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
+474
-58
@@ -16,7 +16,19 @@ from typing import ClassVar, Literal
|
||||
import httpx
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.widgets import Footer, Header, Input, Label, ListItem, ListView, RichLog, Static
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.theme import Theme
|
||||
from textual.widgets import (
|
||||
Footer,
|
||||
Header,
|
||||
Input,
|
||||
ListItem,
|
||||
ListView,
|
||||
RichLog,
|
||||
Static,
|
||||
TabbedContent,
|
||||
TabPane,
|
||||
)
|
||||
|
||||
from ratatoskr.cli import USER_AGENT, ParsedArgs, _format_duration_ms, _format_usage
|
||||
from ratatoskr.sessions import (
|
||||
@@ -49,6 +61,65 @@ from ratatoskr.sse_client import (
|
||||
stream_turn,
|
||||
)
|
||||
|
||||
# ---- Australis theme (https://github.com/lkraven/australis) ------------------
|
||||
#
|
||||
# The Australis Dark color theme, inspired by the Southern Lights. 16 cool-tone
|
||||
# terminal colors with medium contrast. Preference order: blue > cyan > green
|
||||
# for primary surfaces; Dawn accents (red/yellow/magenta) used sparingly for
|
||||
# terminal-event labels (error/cancelled).
|
||||
#
|
||||
# Mapping to Textual's Theme semantic tokens:
|
||||
# primary = Aurora blue (#6388D8) — focus rings, active selection.
|
||||
# secondary = Aurora cyan (#00b1a8) — secondary highlights.
|
||||
# accent = Aurora bright cyan (#42dcd1) — bright accents (pane-name, prompt echo).
|
||||
# success = Aurora green (#16B866) — [done] label.
|
||||
# warning = Dawn yellow (#e1c631) — [cancelled] label.
|
||||
# error = Dawn red (#ff491a) — [error] label.
|
||||
# foreground = Ice white (#a9bcc3) — default text.
|
||||
# background = Ice black (#222531) — App background.
|
||||
# surface = Sea bright black (#373b46) — raised chrome (header/footer/input).
|
||||
# panel = Sea dark 30 (#414751) — borders, separators.
|
||||
|
||||
AUSTRALIS_THEME = Theme(
|
||||
name="australis",
|
||||
primary="#6388D8",
|
||||
secondary="#00b1a8",
|
||||
accent="#42dcd1",
|
||||
success="#16B866",
|
||||
warning="#e1c631",
|
||||
error="#ff491a",
|
||||
foreground="#a9bcc3",
|
||||
background="#222531",
|
||||
surface="#373b46",
|
||||
panel="#414751",
|
||||
dark=True,
|
||||
variables={
|
||||
# Sea contrast palette — usable via $au-dark-50 etc. in TCSS.
|
||||
"au-dark-30": "#414751",
|
||||
"au-dark-40": "#565f69",
|
||||
"au-dark-50": "#6e7882",
|
||||
"au-dark-60": "#86929d",
|
||||
"au-bright-70": "#9daeb6",
|
||||
"au-bright-80": "#b3cbcf",
|
||||
"au-bright-white": "#cce7ec",
|
||||
"au-bright-blue": "#a4c4ff",
|
||||
"au-bright-cyan": "#42dcd1",
|
||||
"au-bright-green": "#51e08a",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Direct hex constants for Rich Text styling (Done/Error/Cancelled labels +
|
||||
# transcript user-prompt echo). Themes set TCSS variables; Rich's RichText
|
||||
# style strings live outside the theme system, so we resolve to hex here.
|
||||
_AU_SUCCESS = "#16B866"
|
||||
_AU_ERROR = "#ff491a"
|
||||
_AU_WARNING = "#e1c631"
|
||||
_AU_USER_ECHO = "#42dcd1" # bright cyan — operator's voice
|
||||
_AU_DEMOTED = "#86929d" # dark 60 — demoted telemetry (was bare "dim")
|
||||
_AU_DEMOTED_FAINT = "#6e7882" # dark 50 — empty-state placeholder text
|
||||
|
||||
|
||||
# ---- Issue #12 presenter contract semantics amendment -------------------------
|
||||
#
|
||||
# TuiPresenterState replaces the stateless _render_event_to_log with a stateful
|
||||
@@ -107,6 +178,11 @@ class TuiPresenterState:
|
||||
|
||||
thinking_buffer: list[str] = field(default_factory=list)
|
||||
thinking_open: bool = False
|
||||
# v0.6.0: per-turn streaming text buffer. Text deltas accumulate here
|
||||
# and update `current_text` Static in place — no per-token RichLog spam.
|
||||
text_buffer: list[str] = field(default_factory=list)
|
||||
# v0.6.0: thinking-run counter for turn-scoped start/end markers.
|
||||
thinking_run_index: int = 0
|
||||
|
||||
def render(
|
||||
self,
|
||||
@@ -114,13 +190,26 @@ class TuiPresenterState:
|
||||
*,
|
||||
log: RichLog,
|
||||
thinking_widget: Static,
|
||||
current_text: Static,
|
||||
tools_log: RichLog,
|
||||
debug_log: RichLog,
|
||||
thinking_log: RichLog,
|
||||
raw: bool,
|
||||
) -> None:
|
||||
"""Render one Worldtree SSE event with the TUI hierarchy + coalescing.
|
||||
|
||||
Two-views-of-thinking decoupling: per-delta updates go to
|
||||
`thinking_widget`; one closed entry per run goes to `log`.
|
||||
Exceptions are caught at the presenter boundary (INV-009 fallback).
|
||||
v0.6.0 routing:
|
||||
- `log` (transcript) = content only: user-prompt echo (written
|
||||
outside the presenter), terminal labels, post-Done Markdown body.
|
||||
- `current_text` (Static below transcript) = live-streaming Text
|
||||
deltas accumulated into one growing line; cleared on terminal.
|
||||
- `tools_log` = ToolStart + ToolResult.
|
||||
- `debug_log` = WorkerPhase + TextBoundary.
|
||||
- `thinking_log` = closed thinking runs (Markdown + start/end
|
||||
Rule markers); `thinking_widget` continues to receive live
|
||||
per-delta updates.
|
||||
|
||||
Exceptions caught at the presenter boundary (INV-009 fallback).
|
||||
"""
|
||||
assert isinstance(
|
||||
event,
|
||||
@@ -132,8 +221,14 @@ class TuiPresenterState:
|
||||
from rich.text import Text as RichText
|
||||
|
||||
def _dim(s: str) -> RichText:
|
||||
"""Wrap a demoted-telemetry line in dim style for the RichLog."""
|
||||
return RichText(s, style="dim")
|
||||
"""Wrap a demoted-telemetry line in Australis dark-60 grey.
|
||||
|
||||
v0.4.1 retheme: was `style="dim"` (terminal-dim filter, varies by
|
||||
emulator); now explicit Australis Sea dark-60 (#86929d) so the
|
||||
shade renders consistently across terminals and stays anchored to
|
||||
the brand palette.
|
||||
"""
|
||||
return RichText(s, style=_AU_DEMOTED)
|
||||
|
||||
try:
|
||||
# Thinking events: accumulate into buffer, update widget per delta.
|
||||
@@ -143,69 +238,113 @@ class TuiPresenterState:
|
||||
self.thinking_open = True
|
||||
self.thinking_buffer.append(event.content)
|
||||
acc = "".join(self.thinking_buffer)
|
||||
display_text = ("…" + acc[-200:]) if len(acc) > 200 else acc
|
||||
thinking_widget.update(display_text)
|
||||
# v0.5.1 polish: prefix the live widget with "thinking… " so
|
||||
# operators recognize what the streaming content is (otherwise
|
||||
# the static-content under Header reads like uncontextualized
|
||||
# spillover). Truncate display to last 200 chars + ellipsis.
|
||||
tail = ("…" + acc[-200:]) if len(acc) > 200 else acc
|
||||
thinking_widget.update(f"thinking… {tail}")
|
||||
return
|
||||
# Non-thinking event: close any open thinking run (one RichLog entry).
|
||||
# Non-thinking event: close any open thinking run.
|
||||
# v0.6.0: closed thinking runs route to thinking_log (Thinking
|
||||
# pane) wrapped in `── turn N · thinking start/end ──` Rule
|
||||
# markers, with the content itself rendered as Markdown (model
|
||||
# reasoning often has lists, code, structure).
|
||||
if self.thinking_open:
|
||||
full_thinking = "".join(self.thinking_buffer)
|
||||
log.write(_dim(f"· thinking: {full_thinking}"))
|
||||
turn_id = event.sse_id.turn_id if hasattr(event, "sse_id") else (
|
||||
event.turn_id if hasattr(event, "turn_id") else "?"
|
||||
)
|
||||
self.thinking_run_index += 1
|
||||
from rich.markdown import Markdown
|
||||
from rich.rule import Rule
|
||||
thinking_log.write(Rule(
|
||||
title=f"turn {turn_id} · thinking #{self.thinking_run_index} start",
|
||||
style=_AU_DEMOTED,
|
||||
))
|
||||
thinking_log.write(Markdown(full_thinking))
|
||||
thinking_log.write(Rule(
|
||||
title=f"turn {turn_id} · thinking #{self.thinking_run_index} end",
|
||||
style=_AU_DEMOTED,
|
||||
))
|
||||
self.thinking_buffer.clear()
|
||||
self.thinking_open = False
|
||||
thinking_widget.update("")
|
||||
thinking_widget.display = False
|
||||
# Now render the non-thinking event itself.
|
||||
if isinstance(event, Text):
|
||||
# Streamed text content — no prefix, no demotion.
|
||||
log.write(event.content)
|
||||
# v0.6.0: streaming text accumulates into current_text Static
|
||||
# — one growing live line, NOT per-delta RichLog entries.
|
||||
self.text_buffer.append(event.content)
|
||||
current_text.update("".join(self.text_buffer))
|
||||
return
|
||||
if isinstance(event, (Done, Error, Cancelled)):
|
||||
# Terminal events: load-bearing label (no demotion).
|
||||
# Terminal event: clear the streaming Static first so the
|
||||
# live-preview band collapses. Then write the colored label
|
||||
# + (non-raw) Markdown body / (raw) accumulated plain text
|
||||
# to the transcript.
|
||||
accumulated = "".join(self.text_buffer)
|
||||
self.text_buffer.clear()
|
||||
current_text.update("")
|
||||
# Terminal labels tinted per outcome (Aurora green / Dawn red
|
||||
# / Dawn yellow) for at-a-glance scanning.
|
||||
if isinstance(event, Done):
|
||||
log.write(
|
||||
log.write(RichText(
|
||||
f"[done] turn_id={event.sse_id.turn_id} model={event.model} "
|
||||
f"duration={_format_duration_ms(event.duration_ms)} "
|
||||
f"usage {_format_usage(event.usage, arrow='→')}"
|
||||
)
|
||||
if not raw:
|
||||
f"usage {_format_usage(event.usage, arrow='→')}",
|
||||
style=_AU_SUCCESS,
|
||||
))
|
||||
if raw:
|
||||
# Raw mode: emit the accumulated streamed text verbatim
|
||||
# so the operator has a record after the Static clears.
|
||||
if accumulated:
|
||||
log.write(accumulated)
|
||||
else:
|
||||
from rich.markdown import Markdown
|
||||
from rich.rule import Rule
|
||||
|
||||
log.write(Rule())
|
||||
log.write(Rule(style=_AU_DEMOTED))
|
||||
log.write(Markdown(event.response))
|
||||
elif isinstance(event, Error):
|
||||
log.write(
|
||||
log.write(RichText(
|
||||
f"[error] turn_id={event.sse_id.turn_id} code={event.error_code} "
|
||||
f"message={event.message!r}"
|
||||
)
|
||||
f"message={event.message!r}",
|
||||
style=_AU_ERROR,
|
||||
))
|
||||
else: # Cancelled
|
||||
log.write(
|
||||
log.write(RichText(
|
||||
f"[cancelled] turn_id={event.turn_id} reason={event.reason!r} "
|
||||
f"partial_message_id={event.partial_message_id}"
|
||||
)
|
||||
f"partial_message_id={event.partial_message_id}",
|
||||
style=_AU_WARNING,
|
||||
))
|
||||
# Belt-and-braces (Volva F3): ensure widget cleared+hidden on EVERY
|
||||
# terminal event, even if thinking_open was False — per STEPS 5-6.
|
||||
thinking_widget.update("")
|
||||
thinking_widget.display = False
|
||||
return
|
||||
if isinstance(event, WorkerPhase):
|
||||
log.write(_dim(
|
||||
# v0.5.0: telemetry → Debug pane, not transcript.
|
||||
debug_log.write(_dim(
|
||||
f"· worker_phase: phase={event.phase} turn_id={event.turn_id}"
|
||||
))
|
||||
return
|
||||
if isinstance(event, ToolStart):
|
||||
log.write(_dim(
|
||||
# Issue #13 INV-014: tool events route to the Tools pane.
|
||||
tools_log.write(_dim(
|
||||
f"· tool_start: name={event.name} args={event.arguments!r}"
|
||||
))
|
||||
return
|
||||
if isinstance(event, ToolResult):
|
||||
log.write(_dim(
|
||||
# Issue #13 INV-014: tool events route to the Tools pane.
|
||||
tools_log.write(_dim(
|
||||
f"· tool_result: name={event.name} duration_ms={event.duration_ms} "
|
||||
f"result={event.result!r:.200}"
|
||||
))
|
||||
return
|
||||
if isinstance(event, TextBoundary):
|
||||
log.write(_dim(
|
||||
# v0.5.0: telemetry → Debug pane, not transcript.
|
||||
debug_log.write(_dim(
|
||||
f"· text_boundary: kind={event.kind} char_offset={event.char_offset}"
|
||||
))
|
||||
return
|
||||
@@ -213,8 +352,23 @@ class TuiPresenterState:
|
||||
# INV-009 + POST-007 fallback: write pre-amendment plain-label line for
|
||||
# the original event AND a render_error line with the class name only
|
||||
# (NO exception message — security clause). Volva F1 fix.
|
||||
log.write(_plain_label(event))
|
||||
log.write(f"[render_error] {type(exc).__name__}")
|
||||
#
|
||||
# v0.6.0 routing-under-failure preservation — fallback writes go
|
||||
# to the same destination the successful render would have used:
|
||||
# - ToolStart/ToolResult → tools_log
|
||||
# - Thinking → thinking_log
|
||||
# - WorkerPhase/TextBoundary → debug_log
|
||||
# - everything else → log
|
||||
if isinstance(event, (ToolStart, ToolResult)):
|
||||
target = tools_log
|
||||
elif isinstance(event, Thinking):
|
||||
target = thinking_log
|
||||
elif isinstance(event, (WorkerPhase, TextBoundary)):
|
||||
target = debug_log
|
||||
else:
|
||||
target = log
|
||||
target.write(_plain_label(event))
|
||||
target.write(f"[render_error] {type(exc).__name__}")
|
||||
|
||||
|
||||
class AgentPickerApp(App[str | None]):
|
||||
@@ -228,13 +382,62 @@ class AgentPickerApp(App[str | None]):
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
/* v0.6.1: kill Textual's $primary-blue tints everywhere — Header sub-
|
||||
widgets (HeaderIcon etc.) have their own $primary tinting that the
|
||||
parent `Header { background: $surface }` rule alone doesn't cover.
|
||||
Sub-selectors force the cool palette down to every level. */
|
||||
Header, HeaderIcon, HeaderTitle, HeaderClock {
|
||||
background: $surface;
|
||||
color: $au-bright-blue;
|
||||
}
|
||||
Footer {
|
||||
background: $surface;
|
||||
}
|
||||
/* v0.6.1: scrollbar uses Textual's $primary-tint by default. Force
|
||||
Australis Sea darks so the scrollbar gutter doesn't read as a blue
|
||||
strip. Applied to ListView (the scrollable widget here). */
|
||||
ListView {
|
||||
scrollbar-background: $background;
|
||||
scrollbar-background-hover: $background;
|
||||
scrollbar-background-active: $background;
|
||||
scrollbar-color: $au-dark-50;
|
||||
scrollbar-color-hover: $au-dark-60;
|
||||
scrollbar-color-active: $au-bright-cyan;
|
||||
}
|
||||
#picker-prompt {
|
||||
dock: top;
|
||||
height: 1;
|
||||
padding: 0 1;
|
||||
color: $au-bright-cyan;
|
||||
background: $surface;
|
||||
}
|
||||
#agent-list {
|
||||
height: 1fr;
|
||||
background: $background;
|
||||
}
|
||||
/* Multi-line agent items. Each ListItem is auto-height so the full
|
||||
description wraps below the agent_id/name line — no truncation. */
|
||||
#agent-list > ListItem {
|
||||
height: auto;
|
||||
padding: 1 1;
|
||||
background: $background;
|
||||
}
|
||||
/* v0.6.1: override Textual's default ListView:focus highlight, which
|
||||
defaults to $primary (Aurora blue) and made the picker unreadable.
|
||||
Both selectors needed — focused state has higher specificity in
|
||||
Textual's defaults. */
|
||||
ListView > ListItem.--highlight,
|
||||
ListView:focus > ListItem.--highlight {
|
||||
background: $au-dark-30;
|
||||
}
|
||||
/* Children of highlighted items keep their colors — the dark-30 bg
|
||||
provides enough contrast for bright-blue id + dark-60 desc text. */
|
||||
.agent-id-line {
|
||||
color: $au-bright-blue;
|
||||
text-style: bold;
|
||||
}
|
||||
.agent-desc {
|
||||
color: $au-bright-70;
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -251,13 +454,22 @@ class AgentPickerApp(App[str | None]):
|
||||
# [no_agents] before constructing the picker.
|
||||
assert agents
|
||||
self.agents = agents
|
||||
self.register_theme(AUSTRALIS_THEME)
|
||||
self.theme = "australis"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
yield Static("Pick an agent for the new session:", id="picker-prompt")
|
||||
# v0.6.0: each ListItem has two Static children — the id/name line
|
||||
# in bold blue + the wrapped description in muted dark-60. No
|
||||
# description truncation; tall items breathe so the operator can
|
||||
# actually read what each agent does.
|
||||
yield ListView(
|
||||
*[
|
||||
ListItem(Label(f"{a.agent_id} · {a.name} — {a.description}"))
|
||||
ListItem(
|
||||
Static(f"{a.agent_id} · {a.name}", classes="agent-id-line"),
|
||||
Static(a.description, classes="agent-desc"),
|
||||
)
|
||||
for a in self.agents
|
||||
],
|
||||
id="agent-list",
|
||||
@@ -281,36 +493,139 @@ class AgentPickerApp(App[str | None]):
|
||||
class RatatoskrApp(App[int]):
|
||||
"""Textual TUI shell — single chat pane."""
|
||||
|
||||
# Issue #12 follow-up: anchor layout so Input never moves.
|
||||
# Pre-fix: every widget was auto-stacked. RichLog grew with content,
|
||||
# thinking-current toggled display 0↔N rows per thinking-run — both pushed
|
||||
# Input around mid-turn. Fix: dock the chrome to the top/bottom edges and
|
||||
# let RichLog (the only `1fr` widget) absorb all layout reflows internally
|
||||
# via its scroll viewport, so screen-relative positions stay stable.
|
||||
# Issue #13 + v0.5.0 follow-up: Horizontal two-column layout per
|
||||
# design-brief §5. Left column (2fr) is the **content-only** chat
|
||||
# surface — assistant text, user prompt echo, [done]/[error]/[cancelled]
|
||||
# terminal labels, post-Done markdown render. Right column (1fr) houses
|
||||
# ALL telemetry: live thinking preview docked above TabbedContent;
|
||||
# tab strip carries Tools (ToolStart/ToolResult) + Debug (Thinking
|
||||
# closed runs + WorkerPhase + TextBoundary).
|
||||
#
|
||||
# v0.5.0 routing change: thinking-current Static moved from left column
|
||||
# to right column header so the left column is genuinely content-only;
|
||||
# closed thinking runs go to debug-log instead of transcript.
|
||||
#
|
||||
# v0.5.0 chrome fix: Header/Footer backgrounds explicitly set to $surface
|
||||
# (Sea bright-black #373b46) overriding Textual's default $primary-blue
|
||||
# tinting. TabbedContent active-tab tinting also softened.
|
||||
#
|
||||
# Australis theme variables ($primary/$accent/$au-dark-60/$au-bright-cyan/
|
||||
# etc.) carry colors so a future theme swap rebinds centrally.
|
||||
DEFAULT_CSS = """
|
||||
/* v0.6.1: kill Textual's default $primary-blue tinting on chrome —
|
||||
Header sub-widgets (HeaderIcon, HeaderTitle, HeaderClock) each carry
|
||||
their own $primary tint that the parent `Header { background }` rule
|
||||
doesn't override; sub-selectors force the cool palette down. */
|
||||
Header, HeaderIcon, HeaderTitle, HeaderClock {
|
||||
background: $surface;
|
||||
color: $au-bright-blue;
|
||||
}
|
||||
Footer {
|
||||
background: $surface;
|
||||
}
|
||||
/* v0.6.1: scrollbars default to $primary-tint blue. Force Sea darks
|
||||
on the scrollable widgets (RichLog instances). */
|
||||
RichLog {
|
||||
scrollbar-background: $background;
|
||||
scrollbar-background-hover: $background;
|
||||
scrollbar-background-active: $background;
|
||||
scrollbar-color: $au-dark-50;
|
||||
scrollbar-color-hover: $au-dark-60;
|
||||
scrollbar-color-active: $au-bright-cyan;
|
||||
}
|
||||
#main-row {
|
||||
height: 1fr;
|
||||
}
|
||||
#left-column {
|
||||
width: 2fr;
|
||||
border-right: solid $panel;
|
||||
}
|
||||
#right-column {
|
||||
width: 1fr;
|
||||
}
|
||||
/* v0.6.1: thinking-current Static moved INTO the Thinking pane (below
|
||||
thinking-log) so streaming + closed runs co-locate. Docked bottom of
|
||||
its TabPane so it acts as the live "tail" of the chronological log
|
||||
above. Empty (height:0) when no thinking is active. */
|
||||
#thinking-current {
|
||||
dock: top;
|
||||
dock: bottom;
|
||||
height: auto;
|
||||
color: $au-dark-60;
|
||||
padding: 0 1;
|
||||
text-style: italic;
|
||||
background: $background;
|
||||
}
|
||||
#transcript {
|
||||
height: 1fr;
|
||||
background: $background;
|
||||
padding: 0 1;
|
||||
}
|
||||
/* v0.6.0: streaming-text Static carries in-flight assistant tokens.
|
||||
Replaces per-token RichLog spam — one growing line that updates in
|
||||
place. Cleared on terminal event; final Markdown body lands in the
|
||||
transcript. */
|
||||
#current-text {
|
||||
dock: bottom;
|
||||
height: auto;
|
||||
background: $background;
|
||||
padding: 0 1;
|
||||
}
|
||||
#tools-log, #debug-log, #thinking-log {
|
||||
background: $background;
|
||||
padding: 0 1;
|
||||
}
|
||||
/* Tab strip + active-tab underline — kill blue, use Australis cyan. */
|
||||
#side-panes > ContentTabs {
|
||||
background: $surface;
|
||||
}
|
||||
#side-panes ContentTab.-active {
|
||||
color: $au-bright-cyan;
|
||||
text-style: bold;
|
||||
}
|
||||
#side-panes Underline > .underline--bar {
|
||||
color: $au-bright-cyan;
|
||||
}
|
||||
#prompt {
|
||||
dock: bottom;
|
||||
border: tall $panel;
|
||||
}
|
||||
/* v0.6.0: focused border uses Australis bright-cyan instead of $primary
|
||||
(Aurora blue) — kills the lingering blue tint the user flagged. */
|
||||
#prompt:focus {
|
||||
border: tall $au-bright-cyan;
|
||||
}
|
||||
/* Placeholder text in the Input — dimmer than typed content. */
|
||||
#prompt > .input--placeholder {
|
||||
color: $au-dark-50;
|
||||
}
|
||||
#identity {
|
||||
dock: bottom;
|
||||
height: 1;
|
||||
color: $au-bright-blue;
|
||||
padding: 0 1;
|
||||
}
|
||||
#pane-name {
|
||||
dock: bottom;
|
||||
height: 1;
|
||||
color: $au-bright-cyan;
|
||||
padding: 0 1;
|
||||
}
|
||||
#hint {
|
||||
dock: bottom;
|
||||
height: 1;
|
||||
color: $au-dark-60;
|
||||
padding: 0 1;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS: ClassVar[list[Binding]] = [
|
||||
Binding("ctrl+c", "interrupt", "Cancel / Exit", priority=True),
|
||||
Binding("ctrl+d", "quit", "Exit immediately", priority=True),
|
||||
# §5 keybinding family Ctrl+1..5 jumps between side panes
|
||||
# without losing Input focus (INV-016).
|
||||
Binding("ctrl+1", "focus_tools", "Tools tab", priority=False),
|
||||
Binding("ctrl+2", "focus_debug", "Debug tab", priority=False),
|
||||
Binding("ctrl+3", "focus_thinking", "Thinking tab", priority=False),
|
||||
]
|
||||
|
||||
HINT_IDLE = "Ctrl-C twice to exit"
|
||||
@@ -334,28 +649,51 @@ class RatatoskrApp(App[int]):
|
||||
self.active_turn_id: int | None = None
|
||||
self.stream_worker = None
|
||||
self.hint: str = self.HINT_IDLE
|
||||
self.register_theme(AUSTRALIS_THEME)
|
||||
self.theme = "australis"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
# Issue #12 follow-up: thinking-current sits at the TOP under Header (via
|
||||
# DEFAULT_CSS `dock: top`). Pre-fix it lived between hint and Footer in
|
||||
# the auto-stacked flow, so its display=True/False toggle per
|
||||
# thinking-run pushed Input + identity + hint up/down on every cycle.
|
||||
# Docking top + RichLog filling middle stabilises Input's screen
|
||||
# position; thinking-current grows/shrinks under Header where the
|
||||
# reflow doesn't affect anything else.
|
||||
yield Static("", id="thinking-current")
|
||||
# markup=False so labeled lines like "[cancel_failed] ..." render verbatim
|
||||
# (Rich would otherwise interpret square-bracket spans as style markup and
|
||||
# strip them). The post-Done markdown render uses Markdown() directly which
|
||||
# is a Rich Renderable and renders correctly without widget-level markup=True.
|
||||
yield RichLog(id="transcript", wrap=True, markup=False, highlight=False)
|
||||
yield Input(id="prompt", placeholder="Type a message and press Enter")
|
||||
# INV-002 + INV-003: visible identity + hint widgets (Footer-area rendering).
|
||||
# Textual's built-in Footer renders BINDINGS descriptions; these Static widgets
|
||||
# carry the session-identity and Ctrl-C-state strings the contract requires be
|
||||
# always-visible.
|
||||
# v0.6.0 layout: left column is content-only (transcript + streaming
|
||||
# text Static + prompt). Right column hosts thinking-current live
|
||||
# preview above TabbedContent cycling Tools / Debug / Thinking.
|
||||
#
|
||||
# The current-text Static buffers in-flight assistant tokens so
|
||||
# streaming doesn't spam the RichLog with one line per delta —
|
||||
# the operator sees a single growing live line, then on Done the
|
||||
# Static clears and the final Markdown body lands in the transcript.
|
||||
#
|
||||
# markup=False on RichLog so labeled lines render verbatim; the
|
||||
# post-Done Markdown() / Rule() renders are Rich Renderables and
|
||||
# work without widget-level markup=True.
|
||||
with Horizontal(id="main-row"):
|
||||
with Vertical(id="left-column"):
|
||||
yield RichLog(id="transcript", wrap=True, markup=False, highlight=False)
|
||||
yield Static("", id="current-text")
|
||||
yield Input(id="prompt", placeholder="Type a message and press Enter")
|
||||
with Vertical(id="right-column"):
|
||||
with TabbedContent(id="side-panes"):
|
||||
with TabPane("Tools", id="tools-tab"):
|
||||
yield RichLog(
|
||||
id="tools-log", wrap=True, markup=False, highlight=False
|
||||
)
|
||||
with TabPane("Debug", id="debug-tab"):
|
||||
yield RichLog(
|
||||
id="debug-log", wrap=True, markup=False, highlight=False
|
||||
)
|
||||
with TabPane("Thinking", id="thinking-tab"):
|
||||
yield RichLog(
|
||||
id="thinking-log", wrap=True, markup=False, highlight=False
|
||||
)
|
||||
# v0.6.1: live thinking lives INSIDE the Thinking
|
||||
# pane (docked bottom) — co-located with the closed
|
||||
# runs in thinking-log above. No more top/bottom
|
||||
# discontinuity across the right column.
|
||||
yield Static("", id="thinking-current")
|
||||
# INV-002 + INV-003: visible identity + hint widgets (Footer-area).
|
||||
# pane-name widget displays current side-pane name.
|
||||
yield Static("", id="identity")
|
||||
yield Static("Tools", id="pane-name")
|
||||
yield Static(self.HINT_IDLE, id="hint")
|
||||
yield Footer()
|
||||
|
||||
@@ -372,9 +710,48 @@ class RatatoskrApp(App[int]):
|
||||
self.query_one("#identity", Static).update(identity)
|
||||
# Issue #12: thinking widget hidden until a thinking event fires.
|
||||
self.query_one("#thinking-current", Static).display = False
|
||||
# v0.5.1 polish: empty-state placeholder lines so the operator sees
|
||||
# the pane is intentionally empty (not broken) before any turn fires.
|
||||
# Wrapped in Australis dark-50 italic so they read distinctly as
|
||||
# placeholder text, not real telemetry. Disappear naturally as the
|
||||
# log fills with real events (the placeholders scroll off the top).
|
||||
from rich.text import Text as RichText
|
||||
placeholder_style = f"{_AU_DEMOTED_FAINT} italic"
|
||||
self.query_one("#tools-log", RichLog).write(
|
||||
RichText("(no tool events yet — start a turn that uses tools)",
|
||||
style=placeholder_style)
|
||||
)
|
||||
self.query_one("#debug-log", RichLog).write(
|
||||
RichText("(waiting for worker_phase + text_boundary telemetry)",
|
||||
style=placeholder_style)
|
||||
)
|
||||
self.query_one("#thinking-log", RichLog).write(
|
||||
RichText("(no chain-of-thought captured yet — start a turn)",
|
||||
style=placeholder_style)
|
||||
)
|
||||
self.state = "idle"
|
||||
self._set_hint(self.HINT_IDLE)
|
||||
|
||||
def _write_turn_headers(self, turn_id: int) -> None:
|
||||
"""v0.6.0: Write `── turn N ──` Rule headers across every pane so
|
||||
operators can visually correlate sections during cross-pane
|
||||
debugging. Called from `_stream_turn_worker` on first event of
|
||||
each new turn (idempotent per turn via active_turn_id guard).
|
||||
"""
|
||||
from rich.rule import Rule
|
||||
|
||||
title = f"turn {turn_id}"
|
||||
rule = Rule(title=title, style=_AU_DEMOTED)
|
||||
try:
|
||||
self.query_one("#transcript", RichLog).write(rule)
|
||||
self.query_one("#tools-log", RichLog).write(rule)
|
||||
self.query_one("#debug-log", RichLog).write(rule)
|
||||
self.query_one("#thinking-log", RichLog).write(rule)
|
||||
except Exception:
|
||||
# Defensive: widget tree may be tearing down — never let a
|
||||
# turn-header write block the SSE consumer.
|
||||
pass
|
||||
|
||||
def _set_hint(self, hint: str) -> None:
|
||||
"""Set the hint state attribute AND update the visible Static widget."""
|
||||
self.hint = hint
|
||||
@@ -396,7 +773,10 @@ class RatatoskrApp(App[int]):
|
||||
content = event.input.value.strip()
|
||||
if not content:
|
||||
return
|
||||
log.write(f"❯ {content}") # noqa: RUF001 — intentional INV-006 prefix
|
||||
# v0.4.1 retheme: operator's voice gets Australis bright cyan so it
|
||||
# stands out against the default-foreground assistant text below it.
|
||||
from rich.text import Text as RichText
|
||||
log.write(RichText(f"❯ {content}", style=_AU_USER_ECHO)) # noqa: RUF001
|
||||
event.input.value = ""
|
||||
self.state = "streaming"
|
||||
self._set_hint(self.HINT_STREAMING)
|
||||
@@ -411,13 +791,28 @@ class RatatoskrApp(App[int]):
|
||||
assert content
|
||||
log = self.query_one("#transcript", RichLog)
|
||||
thinking_widget = self.query_one("#thinking-current", Static)
|
||||
current_text = self.query_one("#current-text", Static)
|
||||
tools_log = self.query_one("#tools-log", RichLog)
|
||||
debug_log = self.query_one("#debug-log", RichLog)
|
||||
thinking_log = self.query_one("#thinking-log", RichLog)
|
||||
presenter = TuiPresenterState()
|
||||
try:
|
||||
async for event in stream_turn(self.client, self.session_id, content):
|
||||
if self.active_turn_id is None:
|
||||
self.active_turn_id = event.sse_id.turn_id
|
||||
# v0.6.0: turn-ID headers across all panes so the
|
||||
# operator can visually correlate sections during
|
||||
# cross-pane debugging.
|
||||
self._write_turn_headers(self.active_turn_id)
|
||||
presenter.render(
|
||||
event, log=log, thinking_widget=thinking_widget, raw=self.args.raw
|
||||
event,
|
||||
log=log,
|
||||
thinking_widget=thinking_widget,
|
||||
current_text=current_text,
|
||||
tools_log=tools_log,
|
||||
debug_log=debug_log,
|
||||
thinking_log=thinking_log,
|
||||
raw=self.args.raw,
|
||||
)
|
||||
if isinstance(event, (Done, Error, Cancelled)):
|
||||
break
|
||||
@@ -468,6 +863,27 @@ class RatatoskrApp(App[int]):
|
||||
self.stream_worker.cancel()
|
||||
self.exit(0)
|
||||
|
||||
def action_focus_tools(self) -> None:
|
||||
"""Issue #13: Ctrl+1 activates the Tools tab. INV-016 preserves Input focus.
|
||||
|
||||
Current Textual behavior preserves Input focus when TabbedContent.active is
|
||||
set programmatically. If a future Textual regresses on that, add an
|
||||
explicit `self.query_one('#prompt', Input).focus()` after the assignment
|
||||
— `test_ctrl_1_preserves_input_focus` is the regression guard.
|
||||
"""
|
||||
self.query_one("#side-panes", TabbedContent).active = "tools-tab"
|
||||
self.query_one("#pane-name", Static).update("Tools")
|
||||
|
||||
def action_focus_debug(self) -> None:
|
||||
"""v0.5.0: Ctrl+2 activates the Debug tab. INV-016 preserves Input focus."""
|
||||
self.query_one("#side-panes", TabbedContent).active = "debug-tab"
|
||||
self.query_one("#pane-name", Static).update("Debug")
|
||||
|
||||
def action_focus_thinking(self) -> None:
|
||||
"""v0.6.0: Ctrl+3 activates the Thinking tab. INV-016 preserves Input focus."""
|
||||
self.query_one("#side-panes", TabbedContent).active = "thinking-tab"
|
||||
self.query_one("#pane-name", Static).update("Thinking")
|
||||
|
||||
|
||||
def run_tui(args: ParsedArgs) -> int:
|
||||
"""Sync entry point — delegates to the async resolve-then-run flow.
|
||||
|
||||
+643
-241
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user