10 Commits
Author SHA1 Message Date
vh 7106af5c09 style(tui): UI polish pass (v0.5.1)
Cosmetic refinements on top of v0.5.0's content-only main pane. No
behavior change; ships as a patch bump.

## Color signal — terminal labels tinted per outcome

The transcript's [done]/[error]/[cancelled] labels were plain
foreground (Australis #a9bcc3 white), which made them slow to scan
against the surrounding assistant text. Now tinted per outcome:

- [done]      → Aurora green   (#16B866 / $success)
- [error]     → Dawn red       (#ff491a / $error)
- [cancelled] → Dawn yellow    (#e1c631 / $warning)

The post-Done Rule() separator is also tinted to Australis dark-60
(#86929d) so the streamed-text → markdown-body boundary reads as
chrome, not a content artifact.

## Empty-state placeholders

Tools and Debug panes were stark-empty before any turn fired — easy
to misread as "the pane is broken." Now show placeholder lines on
mount in Sea dark-50 italic:

  Tools tab: (no tool events yet — start a turn that uses tools)
  Debug tab: (waiting for telemetry — start a turn)

The placeholders scroll off naturally as real events fill the panes.

## Live thinking widget self-explains

The thinking-current Static at the top of the right column used to
just display raw thinking content with no context — an operator
glancing at the screen mid-stream might not realize they were
looking at LLM chain-of-thought. Now prefixed with "thinking… " so
the widget self-identifies.

## Spacing + chrome

- Transcript / tools-log / debug-log: 1-cell horizontal padding so
  content doesn't hug the column border.
- thinking-current: italic text-style on top of the dark-60 color,
  so the live-preview band is visually distinct from solid-colored
  log content.
- Active tab in TabbedContent: Aurora bright-cyan label + bold
  text-style, so the eye lands on the currently selected pane.
- Input placeholder text: tinted to Sea dark-50 so it reads as
  placeholder, not content.

## Test impact

3 new tests added (test_done_label_styled_success,
test_empty_state_placeholders_present, plus the polish hits
test_thinking_widget_truncation / test_thinking_coalesce updated for
the "thinking… " prefix). 4 existing tests that checked
`isinstance(w, str) and w.startswith("[done]")` updated to use the
_text_of helper (terminal labels are now RichText, not str).
_spy_writes helper widened to accept positional args after Textual's
internal deferred-render path started passing them positionally
post-Resize.

241/241 GREEN; ruff clean. Live smoke against personal Worldtree
confirmed: Done line renders in Aurora green #16B866 verbatim;
both placeholder lines appear in dark-50; thinking widget shows
"thinking… <content>" during a turn.

Patch bump (v0.5.0 → v0.5.1) per SemVer etiquette: purely cosmetic;
no signature change; no caller-visible behavioral shift.
2026-05-24 15:06:10 -07:00
vh ffd22fb587 refactor(tui): content-only main pane + Debug tab + dark chrome (v0.5.0)
Two operator-driven changes off v0.4.1:

1. **Main pane is content-only.** Pre-v0.5.0 the transcript mixed
   assistant text with telemetry (Thinking closed runs, WorkerPhase,
   TextBoundary) — only tool events were factored out per #13. The
   transcript now receives ONLY: user-prompt echo, assistant Text
   deltas, [done]/[error]/[cancelled] terminal labels, and the
   post-Done Markdown render. All telemetry routes to a new Debug
   tab in the right column.

2. **Chrome no longer blue.** Textual's default Header / Footer /
   active-tab styling tints with `$primary` (Aurora blue under
   Australis), which read as garish on dark terminals. Header,
   Footer, and the TabbedContent tab strip get explicit
   `background: $surface` (Sea bright-black #373b46) so the chrome
   sits cool and unobtrusive against the Ice black background.

## Layout reshape

```
LEFT COLUMN (content only):           RIGHT COLUMN (telemetry):
  transcript (RichLog, 1fr)             thinking-current (Static, dock top)
  prompt (Input, dock bottom)           TabbedContent:
                                          Tools  (tool_start, tool_result)
                                          Debug  (thinking, worker_phase,
                                                  text_boundary)
```

The thinking-current live-preview Static moves from left → right
column so the left column is genuinely content-only. Live thinking
visibility now persists across tab switches (it docks above the
TabbedContent, not inside any tab).

## Presenter routing (TuiPresenterState.render)

Signature widens with `debug_log: RichLog`. Routing matrix:

  Text                       → log (transcript)
  Done / Error / Cancelled   → log (transcript) [terminal labels]
  ToolStart / ToolResult     → tools_log (Tools tab)
  Thinking (closed run)      → debug_log (Debug tab)
  WorkerPhase                → debug_log (Debug tab)
  TextBoundary               → debug_log (Debug tab)
  Thinking (per-delta)       → thinking_widget (live preview)

INV-009 render-exception fallback preserves routing per event class
(new INV-020) — ToolStart/Result falls back to tools_log;
Thinking/WorkerPhase/TextBoundary to debug_log; everything else to log.

## Keybindings

- Ctrl+1 → Tools tab (existing, unchanged)
- Ctrl+2 → Debug tab (NEW)

`pane-name` footer widget updates dynamically as the operator
switches tabs ("Tools" ↔ "Debug"). This was previously deferred to
"the multi-tab issue" per the Volva contract-review amendment;
multi-tab now exists, so the dynamic update lands here.

## Contract amendments

docs/contracts/issues/13.contract.md amended in-place:
- INV-015 amended: transcript is content-only; telemetry routes to
  debug_log. Old routing (telemetry in transcript) retired under the
  no-backwards-compat rule.
- INV-017 amended: thinking-current docks to right column (was left).
- INV-019 new: two TabPanes (Tools + Debug), Ctrl+1/Ctrl+2 bindings,
  dynamic pane-name update.
- INV-020 new: render-exception fallback preserves per-event-class
  routing.
- Layout-spec snapshot ASCII diagram updated.

Drift-check clean.

## Tests

239/239 GREEN (+3 new: debug_tab_exists, ctrl_2_activates_debug_tab,
pane_name_updates_on_tab_switch). 6 existing tests adjusted for the
new routing (test_thinking_closes_one_debuglog_entry,
test_multiple_thinking_runs_each_get_debuglog_entry,
test_render_exception_fallback,
test_cancelled_mid_thinking_closes,
test_worker_phase_demoted_to_debug_log,
test_left_column_content_only). ruff clean.

Live smoke against personal Worldtree: mimir KB-search turn
populated tools_log with 11 lines of tool events (search_library +
read_note); debug_log with 20 lines of worker_phase + thinking
content; transcript stayed content-only with `❯ user-prompt`
(Aurora bright-cyan) + assistant text deltas. Routing matrix
holds end-to-end. (Diagnostic note: RichLog.lines is the rendered-
output buffer; inactive TabPane content shows lines=0 until the
tab activates and renders. Internal write store is correct — this
is a Textual rendering quirk, not a routing bug.)

Minor bump (v0.4.1 → v0.5.0) per SemVer etiquette: visible routing
surface change for operators; transcript and Debug tab contents
look different from yesterday's v0.4.1.
2026-05-24 14:20:35 -07:00
vh 2756f5f1dd style(tui): apply Australis theme to TUI chrome + widgets (v0.4.1)
Retheme the Textual TUI under the Australis Dark colorscheme
(github.com/lkraven/australis) — Aurora blue/cyan/green primary,
Ice neutrals (#222531 bg, #a9bcc3 fg, #cce7ec highlight), Sea
darks for chrome separators, Dawn accents reserved for terminal-
event labels (red/yellow). 16-color cool-tone palette with medium
contrast.

Mechanism: Textual `Theme` API. AUSTRALIS_THEME defined at module
scope mapped to semantic tokens (primary/secondary/accent/success/
warning/error/foreground/background/surface/panel) plus a Sea
variables block (`au-dark-30..60`, `au-bright-70/80/white`, plus
Aurora bright variants). Both `RatatoskrApp` and `AgentPickerApp`
register the theme in `__init__` and set `self.theme = "australis"`.

Per-widget styling via DEFAULT_CSS theme variables (no hex
sprinkled in CSS):

- `#identity` → `$au-bright-blue` (Aurora bright-blue).
- `#pane-name` → `$au-bright-cyan` (current-pane indicator).
- `#hint` → `$au-dark-60` (subtle Ctrl-C state line).
- `#prompt` border → `$panel` unfocused / `$primary` focused
  (focus highlight in Aurora blue).
- `#left-column` gets a `border-right: solid $panel` separator.
- `#thinking-current` color → `$au-dark-60` (matches demoted style).
- `#transcript` + `#tools-log` background → `$background`.
- Agent picker's highlighted ListItem → `$primary` bg +
  `$au-bright-white` fg.

Rich Text styling (where Textual's theme system doesn't apply):

- `_dim()` helper for demoted telemetry now uses explicit
  `#86929d` (Sea dark-60) instead of the terminal-dim filter
  `"dim"`. Renders consistently across emulators and stays
  anchored to the brand palette.
- User-prompt echo (`❯ <content>` in transcript) wrapped in
  `RichText` styled with `#42dcd1` (Aurora bright-cyan) — calls
  out the operator's voice in the primary palette.

CLI mode (`--send`) is unaffected by design — raw stdout has no
theme concept. The retheme is TUI-only.

236/236 tests GREEN; ruff clean. Live smoke against personal
Worldtree: `app.theme == "australis"`, all widget colors resolve
to expected Australis hex values (identity → `#a4c4ff`,
pane-name → `#42dcd1`, hint → `#86929d`).

One existing test adjusted: `test_worker_phase_demoted` previously
asserted `style == "dim"`; now asserts non-empty style (the demoted
style is now an explicit Australis hex, not the terminal "dim"
sentinel). Behavior-equivalent — the demotion intent is preserved.

Patch bump (v0.4.0 → v0.4.1) per SemVer etiquette: cosmetic
refinement of just-shipped surface, no public-API change.
2026-05-24 13:49:36 -07:00
vh 24e4371ec7 feat(tui): issue #13 — §5 layout reshape + Tools pane (v0.4.0)
Reshape the TUI from vertical-stack single-pane to Horizontal
two-column with TabbedContent on the right; v1 has a single Tools
tab that consumes ToolStart/ToolResult SSE events previously
rendered inline in the transcript. Foundation for the rest of
design-brief §5; subsequent panes (Persona/AdminEvents/BifrostState/
ServerLog) plug in as sibling TabPanes when their substrate
blockers resolve.

Three coupled pieces, all in-place amendments to issues #4 + #12:

- **Layout**: compose() yields Horizontal#main-row containing
  Vertical#left-column (transcript + thinking-current + prompt) and
  Vertical#right-column (TabbedContent#side-panes with
  TabPane#tools-tab → RichLog#tools-log). Width split 2fr:1fr. CSS
  dock rules narrow to per-container scope so thinking-current
  toggling doesn't reflow the right column.
- **Tools pane**: TuiPresenterState.render() signature widens with
  tools_log: RichLog. ToolStart/ToolResult route there per INV-014;
  every other event keeps its issue-#12 routing. Plain-label
  fallback under render-exception preserves routing (INV-009).
- **Ctrl+1 binding + pane-name widget**: BINDINGS gains
  Binding("ctrl+1", "focus_tools") which programmatically sets
  TabbedContent.active; Textual's default preserves Input focus per
  INV-016 (test asserts; regression path documented).
  Static#pane-name in the footer renders "Tools" v1 (static — no
  tab-switch handler wiring lands in #13 per amendment-2 from
  Volva paraphrase review).

CLI mode (--send) is unaffected by design per INV-018 — non-
interactive, no tabs concept; CLI keeps inline tool-event rendering.

Contract: docs/contracts/issues/13.contract.md (drift-check clean,
two amendments applied from Volva contract-paraphrase pass).

Tests: +9 net (TestLayoutShape × 7 + TestTuiPresenterState routing
× 3, minus 1 deprecated test_tool_start_demoted superseded by
test_tool_start_routes_to_tools_log). 236 total GREEN; ruff clean.

Live smoke against personal Worldtree's mimir: tool-using turn
(KB search) populated tools_log with tool_start + tool_result for
search_library + read_note; transcript stayed chat-only with
worker_phase + thinking. Routing-not-duplication confirmed
end-to-end.
2026-05-23 21:41:16 -07:00
vh d30be12deb feat(sessions,cli,tui): issue #8 — startup agent picker (v0.3.0)
Adds GET /agents fetch + ListView picker for bare `--new` (TUI mode
without --agent). Three in-place amendments:

- ratatoskr.sessions: new `list_agents()` + `AgentInfo` frozen
  dataclass with omit-when-null/empty defaults mirroring SessionInfo's
  INV-001/INV-002 origin-conditional pattern. Non-200 responses raise
  the existing SessionApiFailed (no new exception).
- ratatoskr.cli: `_parse_args` softens `--agent` from absolute to
  mode-conditional — required for `--send --new`, optional for bare
  `--new`, forbidden with `--session` (unchanged INV-004).
- ratatoskr.tui: new `AgentPickerApp(App[str | None])` — separate
  Textual App (not Screen-within-RatatoskrApp) so list_agents errors
  land on real stderr before any alt-screen opens (preserves issue
  #6's INV-001). `_resolve_then_run` gains a pre-create branch:
  fetch agents → empty list → exit 13; non-200 → exit 20; network
  error → exit 21; picker dismissed → exit 0; otherwise thread chosen
  agent_id into create_session.

Contract: docs/contracts/issues/8.contract.md (drift-check clean).

Tests: +18 (227 total, was 209). Live smoke against personal Worldtree
(:8081) returned 12 agents; programmatic picker drive auto-picked lofn
and created a real session with `end_user_id="ratatoskr-tui"`.
2026-05-23 17:58:22 -07:00
vh 3b9c610587 feat(cli,tui): issue #12 — presenter contract semantics amendment (v0.2.0)
Replaces the stateless _render_event / _render_event_to_log helpers with
stateful per-turn presenters (CliPresenterState / TuiPresenterState).
Coalesces thinking-event deltas into a single growing display per run;
demotes telemetry events with editorial hierarchy; formats duration +
usage for human reading. Headline behavior change: a 50-token thinking
phase now renders as ONE coalesced growing line in CLI (or one closed
RichLog entry + per-delta live Static widget in TUI), not 50 lines of
[thinking] spam.

Editorial promotion line (issue #12 INV-002):
- Load-bearing (no demotion prefix): Text, Done, Error, Cancelled
- Demoted telemetry (`. ` ASCII prefix in CLI; dim `· ` in TUI):
  WorkerPhase, Thinking, TextBoundary, ToolStart, ToolResult

Stateful coalescing:
- Thinking deltas accumulate into thinking_buffer; first non-thinking
  event closes the run with a single \n boundary in CLI / one closed
  dim RichLog entry in TUI.
- TUI adds a dedicated Static(id="thinking-current") widget that shows
  the last ~200 chars of the active run, mirroring per-delta updates.
  Two-views-of-thinking decoupling per INV-004: chronological RichLog +
  always-visible widget.
- CLI INV-005: when stdout text was streamed mid-line,
  text_written_since_newline triggers a stdout flush + \n before the
  next stderr terminal label — guarantees [done] / [error] / [cancelled]
  land on their own line in a TTY without breaking pipe-to-file
  scripted consumers.

Formatting helpers (issue #12 INV-006 / INV-007):
- _format_duration_ms — autoscale `347ms` / `5.5s` / `1.2m`
- _format_usage — natural-language `6756 in -> 126 out (6882 total, 0
  cached)` with arrow="->" CLI / "→" TUI

Cross-frontier design pass (eitri-smithy-dev, althing
01KSBE52YZR5E3SPTKA672JE43) returned 16-of-16 confirmed decisions + 4
material divergences applied:
- ASCII `. ` prefix in CLI (`·` is U+00B7, not ASCII)
- RichLog one-closed-entry-per-run + Static per-delta updates (not
  inline-mirror as initially proposed)
- presenter-state object instead of pure-function rendering
- Framed as "contract semantics amendment", not "polish"

Volva paraphrase round (5 prose-precision fixes applied to
12.contract.md): INV-001 "growing display" semantics; single hide
mechanism for the Static widget (Textual reactive `display: bool`);
[render_error] security clause (type-only, no exception message);
text_written_since_newline `\n`-terminated text corner case;
[create_session] integration path (bypasses state.render — not an SSE
Event variant).

Volva code-review round (5 findings applied):
- F1 drift: render-exception fallback now writes BOTH a plain-label
  fallback line for the original event AND the `[render_error] <type>`
  line (was missing the fallback half).
- F2 drift: dim Rich style applied to all demoted-telemetry RichLog
  writes via `rich.text.Text(..., style="dim")` (was plain str).
- F3 drift: belt-and-braces widget clear+hide on EVERY terminal event
  (Done/Error/Cancelled), even when thinking_open was False.
- F4 precision: _format_usage gains PRE-001 assertion on the four
  expected usage keys.
- F5 precision: _run_turn signature amended in issue #3 contract to
  document the new `state: CliPresenterState | None = None` test-
  injection kwarg.

[create_session] lifecycle line demoted to `. create_session:` (written
directly by _amain; bypasses state.render since it's not a wire-level
SSE Event variant). Pre-amendment _render_event / _render_event_to_log
and their test classes removed under the no-backwards-compat rule.

Issues #3 and #4 contracts amended in-place: #3 (CliPresenterState
CLASS + FN block + helper FN blocks + _run_turn signature + _amain
create_session demotion); #4 (TuiPresenterState CLASS + FN block +
compose Static widget + _stream_turn_worker state construction).

209 tests GREEN; ruff clean. Bumps v0.1.0 → v0.2.0 (minor — output
shape change breaks pre-amendment grep patterns like `[thinking] '`;
no public API surface change beyond the rendering contract).

Persistent-memory commit-along: captures the issue #12 decision,
forward direction (require end_user_id for every access — declined
worldtree-dev's requires_end_user_id offer because we'll send it
universally), and the Heimdall scope-model foot-gun note (the
"per-Tier-1-agent scope add" diagnosis was a phantom ask resolved by
worldtree-dev's correction; agent.call:* baseline covers all Tier 1).
2026-05-23 16:13:55 -07:00
vh 804c2df6eb feat(sessions,cli,tui): issues #5 + #6 + worldtree-dev consumer-API follow-up
Issue #6 (TUI startup error visibility): restructure run_tui lifecycle so
pre-App.run() failures land on real stderr instead of getting eaten by
the alt-screen teardown. New _resolve_then_run async helper opens the
AsyncClient via async-with, does pre-flight session resolution, routes
AgentNotFound / SessionApiFailed / network errors to sys.stderr (verbatim
same labels + exit codes as cli._amain), then constructs RatatoskrApp
with pre-resolved state and awaits app.run_async(). RatatoskrApp.__init__
signature widens to (args, *, session_id, agent_id, client) — all three
required. on_mount narrows to identity-widget population; on_unmount
becomes a no-op (client lifetime owned by run_tui's async-with).

Issue #5 (--end-user-id for per-end-user agents): sessions.create_session
gains keyword-only end_user_id kwarg with PRE-003 non-empty assertion;
ParsedArgs.end_user_id field added (default None); --end-user-id flag
with non-empty validation; _amain + _resolve_then_run thread it to their
create_session calls. RATATOSKR_END_USER_ID env-var fallback
(flag > env > None) per the post-2026-05-23 amendment; env.sh (gitignored)
ships "ratatoskr-tui" as project-stable partition default.

Worldtree-dev consumer-API follow-up (althing 01KSBARG2B8M): User-Agent
header added (ratatoskr/<version> (vh@phasefinal.com), version pulled via
importlib.metadata) to both AsyncClient constructions so server logs can
distinguish ratatoskr traffic from other consumers.

Volva code-review (2 rounds on #6) found 8 test-precision gaps + 1 PRE
assertion drift, all Category 1 fixed: missing PRE-001 at
_resolve_then_run entry; Rule separator assertions on markdown render;
RichLog-write spy on empty submit; input-cleared + no-new-worker on
cancelling busy; worker.cancel observation on three force-exit paths;
on_unmount-no-close focused test (the prior client-lifetime test patched
run_async so on_unmount was never exercised); happy --new resolve test
verifying POST count + identity propagation.

Issues #2/#3/#4/#5 contracts amended in-place to reflect:
- create_session widened (PRE-003, body construction step, body shape POST)
- ParsedArgs description + _parse_args STEPS + _amain create_session call
  + new TESTS for end_user_id + env-var fallback
- _resolve_then_run STEPS + new TEST entries; on_mount narrowed;
  INV-007 amended for new client ownership
- Post-#6 adjustment note on issue #5 (_resolve_then_run replaces
  on_mount as the threading site since #6 moved session resolution out
  of the alt-screen)

188 tests GREEN; ruff clean. Bumps to v0.1.0 — first minor release, the
load-bearing reason is RatatoskrApp.__init__'s breaking signature change
(additive end_user_id alone wouldn't have triggered a minor pre-v1.x).

Files Gitea issues #9 (spec-pin refresh v0.19.0 → v0.22.1), #10 (track
Worldtree #196 subject:{type,id} migration), #11 (AdminEvents pane auth
prerequisite admin.events.read). Infra-ops pinged via althing for
agents.call:lofn scope add (broker pattern; they forwarded to
worldtree-dev because personal Worldtree exposes no public
scope-mutation endpoint).
2026-05-23 14:34:53 -07:00
vh c713208585 feat(sse_client,cli,tui): implement issue #7 — empty-data skip + MalformedSseData
Bundles initial TDD impl + Volva-code-review F1/F3 amendments.

sse_client.py:
- New MalformedSseData(raw) exception; truncates raw to 200 chars at
  __init__ (mirrors MalformedSseId.raw[:64] precedent).
- _iter_events gains `if sse.data == '': continue` BEFORE
  _parse_sse_id. Empty-data frames are silently skipped per issue #7
  INV-001 (keepalive semantics). Empty-data + bad-id is still a
  keepalive; intentional ordering, don't reorder.
- _iter_events json.loads(sse.data) now wrapped — JSONDecodeError →
  MalformedSseData(raw=sse.data).

cli.py:
- Imports MalformedSseData; _run_turn ERROR_ROUTING gains the case →
  stderr `[malformed_sse_data] raw={exc.raw!r}` + exit 22 (protocol-
  failure bucket, same as MalformedSseId/TurnIdFlip).

tui.py:
- Imports MalformedSseData; _stream_turn_worker ERROR_ROUTING gains
  the case → transcript label; finally block restores state→idle
  per INV-008 (mid-session errors don't exit the app).

Tests (6 new):
- test_sse_client.py: empty_data_skipped (tracer — 4 frames in, 3
  events out), malformed_data_raises, whitespace_data_raises,
  malformed_data_truncation, AND empty_data_skip_preserves_last_seen_sse_id
  (F1 from Volva code-review — drop-after-empty probes internal
  last_sse_id non-advancement via SseConnectionDropped.last_seen_sse_id).
- test_cli.py: malformed_sse_data (tightened to assert exact
  `[malformed_sse_data] raw='not-json'` shape per F3),
  malformed_sse_data_truncation (5000-char payload — verifies
  truncation carries through presenter rendering, F3).
- test_tui.py: malformed_sse_data_returns_to_idle (state→idle per
  INV-008; app does NOT exit).

Smoke validation (2026-05-22): the original crashing prompt
("what about system 1 and system 2 framing?") now completes cleanly
end-to-end. mimir streamed 3193 tokens (50 seconds, 374980-token
context), `[done] turn_id=96 duration_ms=50436`. Empty-data frames
somewhere in the stream silently skipped; no crash.

172/172 tests GREEN; ruff clean; all 5 issue contracts (#1, #3, #4,
#5, #7) drift-check clean.

Persistent-memory updated per the commit-along rule: status reflects
v0+#7 milestone; new dated decisions for #5/#6/#7 filing + #7
implementation; foot-gun entry for unguarded json.loads(sse.data).
2026-05-22 16:41:38 -07:00
vh 942e33898c fix(tui): address Volva code-vs-contract drift (issue #4)
Volva code-review surfaced 8 findings against the TDD-passing
TUI shell. All 8 addressed.

Drift fixes (code):
- Primary: INV-002 + INV-003 require visible Footer-area rendering
  of session-identity + Ctrl-C state hint. Implementation stored
  the strings in `self.sub_title` (which lands in the Header, not
  Footer) and `self.hint` (a plain attribute, never rendered). Fixed
  by adding two `Static` widgets (id="identity" and id="hint") in
  compose; the `_set_hint()` helper mirrors state into the widget on
  every state transition. Same-model TDD missed this because tests
  asserted internal state, not visible widget content.
- Reverted `_stream_turn_worker(content, log)` to single-param
  `(content)` per the contract FN signature. The widened signature
  was a TDD-time workaround for a NoMatches-during-worker
  execution; root cause was test timing (added `await pilot.pause()`
  before the polling loop in `_submit_and_wait`).
- Restored `exclusive=True` on `self.run_worker(...)` per the
  contract STEP 6 spec.
- Added missing `isinstance(args, ParsedArgs)` PRE assertion to
  `run_tui`. Required hoisting `from ratatoskr.cli import
  ParsedArgs` out of TYPE_CHECKING — runtime import is fine (no
  circular dependency: cli lazy-imports tui inside main; tui
  imports cli unconditionally at module load).
- Added missing union-type PRE assertion to `_render_event_to_log`.

Contract amendments (precision):
- COMPOSE shape: RichLog `markup=False, highlight=False` (was True,
  True). Explanatory comment in-line: bracketed labels like
  [cancel_failed] would otherwise be interpreted+stripped as Rich
  style spans; the post-Done Markdown rendering still works via
  Markdown() Renderable.
- INV-002 reworded: identity rendered via dedicated
  Static(id="identity") widget composed adjacent to Footer (Textual's
  built-in Footer renders BINDINGS descriptions; a sibling Static
  carries custom content in the same visual region).
- on_mount POST-003 amended to allow `agent_id is None` when
  --session is used without --agent (matches INV-002 carve-out;
  GET /sessions/{id} agent lookup is out of scope for this shell).
- run_tui happy_returns_zero_on_quit test description clarified:
  App.run() is sync and can't be driven by Pilot, so run_tui's
  wrapping behavior is tested via monkeypatch; the piloted Ctrl-D
  exit path is covered separately by TestActionQuit.

Test fixes:
- footer_identity_visible_first_frame, footer_hint_flips_to_cancel,
  streaming_first_ctrl_c_cancels: now query the Static(#identity) /
  Static(#hint) widgets via `widget.render()` instead of asserting
  on `app.sub_title` / `app.hint` internal state. The internal
  state still exists (mirror), but the load-bearing assertion is
  on visible widget content.

Meta-note from Volva: "TDD pass caught most stream/session/error
mechanics, but tested internal state where the contract required
visible Footer behavior, so same-model TDD would plausibly miss the
primary drift." Calibration shape continues across all four issues:
the post-TDD cross-model review consistently catches assert-boundary
+ observability-shape gaps the test-author's hypotheses don't cover
(#1: 4 findings, #2: 3, #3: 5, #4: 8).

164/164 tests GREEN; ruff clean; both contract drift checks clean.
2026-05-21 00:46:06 -07:00
vh dd89239c34 feat(tui): implement issue #4 contract via TDD; amend cli for TUI dispatch
47 contract-listed tests authored + GREEN (43 tui + 4 issue-#3
amendments). 164/164 tests GREEN suite-wide; ruff clean.

Vertical-slice ordering: _render_event_to_log → _cancel_via_sse →
CLI amendments → RatatoskrApp class + on_mount + on_unmount →
on_input_submitted → _stream_turn_worker → action_interrupt +
action_quit → run_tui.

Two in-flight contract amendments caught during TDD:
- PRE-002 of run_tui was `(args.session_id is None) != args.new` —
  backwards (fails when --session is set + new=False). Corrected to
  `bool(args.session_id) != bool(args.new)`.
- RichLog created with markup=False (contract drafted markup=True).
  Rich interprets `[xxx]` as style markup and strips it, which would
  break every labeled stderr-style line ([cancel_failed], [done],
  [error], etc.). The post-Done Markdown rendering still works
  because rich.markdown.Markdown is a Renderable and doesn't need
  widget-level markup.

Implementation notes:
- _stream_turn_worker takes the log widget as a parameter passed
  from on_input_submitted. Querying #transcript from inside a
  Textual worker context fails with NoMatches; capturing the
  reference once at handler-time and threading it through the
  worker sidesteps the issue.
- _spy_writes(monkeypatch) test helper records every RichLog.write
  call. RichLog's `.lines` Strip buffer isn't populated
  synchronously after .write() returns, which makes
  post-app-shutdown inspection unreliable; a write-spy gives
  deterministic verification.
- SIGINT-mid-stream tests use custom httpx.AsyncByteStream
  subclasses with asyncio.Event gates to make timing deterministic
  without sleep-based polling — the cancel-respx-mock sets the
  gate event when its endpoint is observed, releasing the next
  SSE chunk.
- _submit_and_wait test helper needs `await pilot.pause()` BEFORE
  the polling loop so the Input.Submitted message has a chance to
  dispatch. Discovered via debug-print trace; tracked in the test
  helper.

CLI amendments (per issue #4 in-place amendment of #3 contract):
- ParsedArgs.send_content: str | None (was str)
- ParsedArgs.raw: bool added
- _parse_args: --send default=None; empty-string still rejected;
  --raw added
- main: branches on args.send_content — None → lazy
  `from ratatoskr.tui import run_tui` + run_tui(args); else
  asyncio.run(_amain(args)). Lazy import preserves issue #3 INV-001.

Persistent-memory updated per the commit-along rule: tui module
landed, recent-decisions entries for #4 (contract + Volva + TDD),
next natural moves rotated to Volva code-review + manual smoke
against the personal Worldtree (key landed in env.sh per
infra-ops's earlier delivery).
2026-05-21 00:31:40 -07:00