Clears the two ✗ FAIL (missing STEPS) the v2.1 parser surfaced. #3: faithful STEPS for CliPresenterState.render, _format_duration_ms, _format_usage (the two formatters also gain PRE/POST from their real asserts). render STEPS enumerate AffectUpdate + AwaitingLlmFirstToken as demoted telemetry (Worldtree #204/#201), extending POST-005 beyond the issue #12 set. #4: refresh the TUI presenter contract from the abandoned single-RichLog double-display model to the shipped four-pane live-Markdown model (v0.5.0-v0.14.0 + Worldtree #201/#204). Rewrites TuiPresenterState.render and _stream_turn_worker (signature, POSTs, STEPS, TESTS), INV-005, the [performance] constraint, the COMPOSE sketch, the CLASS block (BRIEF/PROPERTIES/INV-WIRE-002), the resolved open_question, and the _cancel_via_sse call site. Verified against src/ratatoskr/tui.py and the real test names in tests/test_tui.py. Both contracts: 0 validation errors (pre-existing multi-tracer warnings on _run_turn / action_interrupt left untouched).
This commit is contained in:
@@ -390,6 +390,30 @@ POST: [POST-004 side_effect] for Done/Error/Cancelled: if text_written_since_new
|
||||
POST: [POST-005 side_effect] for demoted telemetry (WorkerPhase, TextBoundary, ToolStart, ToolResult): write `. <label>: <fields>\n` to stderr
|
||||
ERROR_ROUTING:
|
||||
(none at this level — pure dispatch over the typed union)
|
||||
STEPS:
|
||||
1. [setup, flexibility=prescriptive] Validate event is one of the Event union variants per PRE-001.
|
||||
2. [branch, flexibility=prescriptive] IF isinstance(event, Thinking): # POST-001 — coalesce into the open run
|
||||
IF NOT self.thinking_open: WRITE ". thinking: " to stderr; SET self.thinking_open=True
|
||||
WRITE event.content to stderr; FLUSH; APPEND event.content to self.thinking_buffer
|
||||
RETURN
|
||||
3. [branch, flexibility=prescriptive] IF self.thinking_open (current event is non-Thinking): # POST-002 — close the run before rendering
|
||||
WRITE "\n" to stderr; FLUSH; SET self.thinking_open=False; CLEAR self.thinking_buffer
|
||||
4. [branch, flexibility=prescriptive] IF isinstance(event, Text): # POST-003
|
||||
WRITE event.content to stdout; FLUSH
|
||||
SET self.text_written_since_newline = not event.content.endswith("\n") # Volva F4 — only flag a mid-line cursor
|
||||
RETURN
|
||||
5. [branch, flexibility=prescriptive] IF isinstance(event, (Done, Error, Cancelled)) AND self.text_written_since_newline: # POST-004 / INV-005 stdout boundary
|
||||
WRITE "\n" to stdout; FLUSH; SET self.text_written_since_newline=False
|
||||
6. [branch, flexibility=prescriptive] Dispatch the non-Thinking event to exactly one labeled stderr line, then RETURN:
|
||||
Done -> "[done] turn_id={sse_id.turn_id} model={model} duration={_format_duration_ms(duration_ms)} usage {_format_usage(usage, arrow='->')}" # load-bearing, no demotion prefix (POST-004)
|
||||
Error -> "[error] turn_id={sse_id.turn_id} code={error_code} message={message!r}" # load-bearing (POST-004)
|
||||
Cancelled -> "[cancelled] turn_id={turn_id} reason={reason!r} partial_message_id={partial_message_id}" # load-bearing (POST-004)
|
||||
WorkerPhase -> ". worker_phase: phase={phase} turn_id={turn_id}" # demoted (POST-005)
|
||||
ToolStart -> ". tool_start: name={name} args={arguments!r}" # demoted (POST-005)
|
||||
ToolResult -> ". tool_result: name={name} duration_ms={duration_ms} result={result!r:.200}" # demoted, 200-char cap (POST-005)
|
||||
TextBoundary -> ". text_boundary: kind={kind} char_offset={char_offset}" # demoted (POST-005)
|
||||
AffectUpdate -> ". affect_update: status={status} turn_id={turn_id} [dominant_emotion={...}]" # Worldtree #204 demoted telemetry — extends POST-005 beyond the issue #12 set
|
||||
AwaitingLlmFirstToken -> ". awaiting_llm_first_token: turn_id={turn_id} elapsed={secs:.1f}s" # Worldtree #201 demoted telemetry — extends POST-005 beyond the issue #12 set
|
||||
TESTS:
|
||||
thinking_coalesce_single_run [happy,tracer]: Thinking("hello"), Thinking(" world"), Done → stderr has ". thinking: hello world\n" then "[done] ..."; no demotion prefix on [done]
|
||||
thinking_closes_on_first_non_thinking_event [happy]: Thinking, WorkerPhase → ". thinking: ...\n" then ". worker_phase: ..."
|
||||
@@ -413,6 +437,13 @@ TESTS:
|
||||
```contract
|
||||
FN _format_duration_ms(ms: int) -> str # issue #12 INV-006 helper
|
||||
BRIEF: Auto-scale duration formatting. ms<1000 → "{ms}ms"; ms<60_000 → "{s:.1f}s"; else "{m:.1f}m". Locale-blind.
|
||||
PRE: [PRE-001 hard] ms is a non-negative int -- assert isinstance(ms, int) and ms >= 0
|
||||
POST: [POST-001 return_value] returns a unit-suffixed string: "{ms}ms" below 1s, "{s:.1f}s" below 1m, else "{m:.1f}m"
|
||||
STEPS:
|
||||
1. [setup, flexibility=prescriptive] Validate input per PRE-001 -- assert isinstance(ms, int) and ms >= 0
|
||||
2. [branch, flexibility=prescriptive] IF ms < 1000: RETURN f"{ms}ms"
|
||||
3. [branch, flexibility=prescriptive] IF ms < 60_000: RETURN f"{ms / 1000:.1f}s"
|
||||
4. [sequential, flexibility=prescriptive] RETURN f"{ms / 60_000:.1f}m" # minutes fallback
|
||||
TESTS:
|
||||
subsecond: 347 → "347ms"
|
||||
exact_one_second: 1000 → "1.0s"
|
||||
@@ -425,6 +456,12 @@ TESTS:
|
||||
```contract
|
||||
FN _format_usage(usage: dict, *, arrow: str) -> str # issue #12 INV-007 helper
|
||||
BRIEF: Natural-language usage formatting. arrow="->" for CLI (ASCII), arrow="→" for TUI (Unicode).
|
||||
PRE: [PRE-001 hard] usage carries the four token keys -- assert all(k in usage for k in ("prompt_tokens", "completion_tokens", "total_tokens", "cached_input_tokens"))
|
||||
POST: [POST-001 return_value] returns "{p} in {arrow} {c} out ({t} total, {ci} cached)" with the four counts substituted and the caller-supplied arrow glyph
|
||||
STEPS:
|
||||
1. [setup, flexibility=prescriptive] Validate input per PRE-001 -- assert all(k in usage for k in ("prompt_tokens", "completion_tokens", "total_tokens", "cached_input_tokens"))
|
||||
2. [sequential, flexibility=prescriptive] Bind p=usage["prompt_tokens"], c=usage["completion_tokens"], t=usage["total_tokens"], ci=usage["cached_input_tokens"]
|
||||
3. [sequential, flexibility=prescriptive] RETURN f"{p} in {arrow} {c} out ({t} total, {ci} cached)"
|
||||
TESTS:
|
||||
ascii_arrow: arrow="->" → "6756 in -> 126 out (6882 total, 0 cached)"
|
||||
unicode_arrow: arrow="→" → "6756 in → 126 out (6882 total, 0 cached)"
|
||||
|
||||
@@ -20,7 +20,7 @@ assumptions:
|
||||
- "`httpx.AsyncClient(base_url=server_url, headers={'Authorization': f'Bearer {api_key}'})` is opened inside the App lifecycle (on_mount) and closed in on_unmount. The TUI owns its client; it does not share a client with `_amain` (the TUI path bypasses `_amain` entirely)."
|
||||
- "`App.run_test()` provides a headless `Pilot` that drives the app from pytest. Pilot supports `pilot.press(...)` for key simulation and `pilot.pause()` to let pending tasks resolve. Widget queries via `app.query_one(...)` work in test mode."
|
||||
open_questions:
|
||||
- "Streaming-markdown partial rendering: streaming raw text mid-turn then re-rendering as Markdown on Done is the cleanest UX, but requires RichLog line-replacement (uncertain support) OR a separate `Static` for the active turn + a 'commit' on Done. Draft: stream raw text into RichLog; on Done, append a separator + the full markdown render below (acknowledging a small redundancy). If empirically ugly, refactor to Static-then-commit in a follow-up — same shape as design-brief §6's `--no-stream-formatting` punt."
|
||||
- "RESOLVED (v0.9.0): streaming-markdown partial rendering. Shipped the Static-then-commit pattern — `Text` deltas accumulate in `text_chunk_buffer` and re-render `Markdown(buffer)` in place into a single response `Static`; no post-Done re-render, no double-display. The issue #12 draft's stream-raw-then-re-render-on-`Done` approach (and its `#current-text` dock-bottom Static) was dropped because the dock-bottom growth visually overlapped the transcript. See INV-005."
|
||||
- "Textual `BINDINGS` priority for `ctrl+c` vs `Input` widget focus: when `Input` is focused, does `ctrl+c` reach the app's binding or get consumed by the input widget? Draft: declare the binding with `priority=True` to ensure the app sees it regardless of focus. If `priority=True` interferes with input editing, fall back to a custom `Input` subclass that surfaces ctrl+c."
|
||||
- "Should the TUI persist transcript across restarts? Per design-brief §8d ('reconnect, not resume-across-process') the answer is no — fresh transcript every launch. Confirming this is in scope of the shell contract (deferred), not punted."
|
||||
prd:
|
||||
@@ -87,7 +87,7 @@ The shell is the load-bearing primary surface. Together with `--send`, it makes
|
||||
- After the `Cancelled` terminal event arrives (or `Done`/`Error`), state returns to **idle** and footer hint resets.
|
||||
- **Note on the idle-hint discrepancy**: the idle-state hint reads `"Ctrl-C twice to exit"` but a single Ctrl-C from idle DOES exit. This is intentional per design-brief §8c's "The footer-hint state transition is load-bearing — the dev needs to see that the next Ctrl-C will exit, otherwise they hit it again expecting another cancel and lose their session." The hint is conservative-by-design — it pre-warns the dev about the *worst-case* (streaming→cancel→exit) flow rather than the literal idle case (one press exits). Implementers MUST use the literal string `"Ctrl-C twice to exit"` (NOT something more accurate like `"Ctrl-C to exit"`); changing it would diverge from the design-brief's locked UX.
|
||||
- **INV-004 [hard]**: Ctrl-D is bound to `app.exit(0)` unconditionally — immediate exit regardless of state. Abandons any in-flight turn (server-side stall watchdog handles the orphan per spec).
|
||||
- **INV-005 [hard]**: Markdown rendering on agent output is default-on; `--raw` is the opt-out. With markdown enabled, `Text` event deltas stream as raw text appended to the RichLog as they arrive (no mid-stream markdown attempt — partial markdown like `**hel` would render ugly), and on `Done` a separator + the full markdown-rendered assistant message is appended below the streamed deltas. **This means the assistant's response visibly appears TWICE in the transcript by design — once as the streamed raw deltas, once as the post-Done markdown render — separated by a horizontal-rule separator.** This is the v1 accepted trade-off for streaming-visibility-without-mid-stream-markdown-ugliness; the cleaner Static-then-commit pattern (streaming into a replaceable widget, then committing the markdown version in place) is documented in `open_questions:` as the follow-up if the double-display proves empirically noisy. Implementers MUST NOT attempt the Static-then-commit pattern in this shell — it's deferred. With `--raw`, only the streamed deltas appear; no post-Done re-render; no double-display.
|
||||
- **INV-005 [hard]**: Markdown rendering on agent output is default-on; `--raw` is the opt-out. `Text` event deltas accumulate in the presenter's `text_chunk_buffer` and render LIVE as `Markdown(buffer)` into a single response `Static` (CSS class `.response-md`) mounted in the transcript scroll — the first delta mounts the widget, each subsequent delta updates it in place. There is NO post-Done re-render and NO double-display: the streamed-then-committed Markdown is the one and only rendering of the response. (v0.9.0 shipped exactly the Static-then-commit pattern the issue #12 draft had deferred; the earlier stream-raw-then-re-render-on-`Done` double-display, and its `#current-text` dock-bottom Static, were removed because the dock-bottom growth visually overlapped the transcript.) With `--raw`, the same widget holds the plain accumulated text instead of a `Markdown` Renderable — still live, still single-display, no Markdown wrapping.
|
||||
- **INV-006 [hard]**: User-prompt echo in the transcript MUST visibly distinguish user input from assistant output. Format: `❯ <content>` for user lines (with a literal `❯` prefix); assistant lines have no prefix. The prefix is also a screen-reader-friendly affordance.
|
||||
- **INV-007 [hard]**: One `httpx.AsyncClient` per app lifetime — opened by `run_tui`'s `async with` BEFORE `App.run_async()` is entered and closed by the same `async with` AFTER `App.run_async()` returns (per issue #6 INV-002). The App is a consumer of an externally-owned client; it MUST NOT call `self.client.aclose()`. The client is NOT recreated per turn (would burn the TCP connection pool).
|
||||
- **INV-008 [hard]**: Mid-session network/protocol errors (`SseConnectionDropped`, `SseConnectFailed`, `MalformedSseId`, `MalformedSseData` (issue #7), `TurnIdFlip`) during a streaming turn render as error lines in the transcript and return the app to **idle** state — they do NOT exit the app. Only initial session-create errors exit (per Data flow exit codes).
|
||||
@@ -104,12 +104,12 @@ The shell is the load-bearing primary surface. Together with `--send`, it makes
|
||||
- **`/admin/events` SSE consumption** — admin observability surface lands with the AdminEvents pane issue.
|
||||
- **`reconnect_turn` mid-session** — if a stream drops mid-turn, the TUI renders the error and returns to idle. In-process reconnect with `Last-Event-ID` resume is a separate issue (the underlying `sse_client.reconnect_turn` is implemented; the TUI doesn't invoke it yet).
|
||||
- **Bifrost-binding consumer support** — not a Ratatoskr concern (per design-brief §6 negative clauses).
|
||||
- **`--quiet` / `--no-stream-formatting`** — deferred per design-brief §6. Add only if streaming text + post-Done markdown render proves empirically noisy.
|
||||
- **`--quiet` / `--no-stream-formatting`** — deferred per design-brief §6. Add only if the live Markdown stream proves empirically noisy.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **[compatibility]** Module must work against the spec pin (`55101e909abcd2219833266b6f905c5bc956e0f0`, Worldtree v0.19.0). The TUI is insulated from wire-level changes through `sse_client` + `sessions`.
|
||||
- **[performance]** Streaming MUST NOT buffer the turn before rendering. `Text` deltas write to RichLog as they arrive. The post-Done markdown render reads the accumulated `Done.response` field from the terminal event — no client-side re-aggregation from individual deltas.
|
||||
- **[performance]** Streaming MUST NOT block on the full turn before rendering. `Text` deltas append to the presenter's `text_chunk_buffer` and re-render the response `Static` in place on each delta (live Markdown) — the transcript updates as tokens arrive. The displayed response is built delta-by-delta; `Done.response` is observable but is NOT the source of the rendered output.
|
||||
- **[security]** TUI does not log `Authorization` header, `--api-key` value, or full event bodies. Persistence is per-launch (no disk writes); transcript content is in-memory only.
|
||||
- **[style]** Async-native. Textual's worker pattern (`self.run_worker(coro, exclusive=True)`) drives the stream loop; no manual thread management. `App[int]` for typed exit codes. ruff line-length=100 (per pyproject).
|
||||
|
||||
@@ -231,9 +231,14 @@ PROPERTIES:
|
||||
BINDINGS:
|
||||
- ("ctrl+c", "interrupt", "Cancel / Exit") # priority=True so Input doesn't consume it; see open_questions
|
||||
- ("ctrl+d", "quit", "Exit immediately")
|
||||
COMPOSE shape (declarative — implementer chooses CSS file vs inline):
|
||||
COMPOSE shape (declarative — implementer chooses CSS file vs inline; exact tab/CSS layout lives in tui.py.compose):
|
||||
Header()
|
||||
RichLog(id="transcript", wrap=True, markup=False, highlight=False) # markup=False: bracketed labels like [cancel_failed] render verbatim instead of being interpreted-and-stripped as Rich style spans. The post-Done markdown render uses Markdown() Renderable which renders regardless of widget-level markup.
|
||||
Horizontal:
|
||||
VerticalScroll(id="transcript-scroll") # chat content: per-turn Static widgets mounted dynamically by the presenter — prompt echo, live-Markdown response (.response-md), tinted terminal labels, awaiting-token indicator. No single RichLog; wire-error labels mount as error-label Statics here.
|
||||
TabbedContent (right column; Ctrl+1..3 switch tabs):
|
||||
RichLog(id="tools-log", markup=False) # ToolStart / ToolResult
|
||||
RichLog(id="debug-log", markup=False) # per-event audit line + WorkerPhase + TextBoundary + turn-summary
|
||||
RichLog(id="thinking-log", markup=False) # coalesced Thinking deltas, Rule(start)/Rule(end) per run
|
||||
Input(id="prompt", placeholder="Type a message and press Enter")
|
||||
Static("", id="identity") # INV-002: visible session-identity strip; rendered by on_mount
|
||||
Static(HINT_IDLE, id="hint") # INV-003: visible Ctrl-C state hint; updated on state transitions
|
||||
@@ -299,39 +304,34 @@ TESTS:
|
||||
|
||||
```contract
|
||||
FN RatatoskrApp._stream_turn_worker(self, content: str) -> None
|
||||
BRIEF: Worker coroutine spawned by `on_input_submitted`. Drives `stream_turn`, renders each event into the RichLog via a freshly-constructed `TuiPresenterState` instance (issue #12 amendment: was `_render_event_to_log`), captures `active_turn_id` from the first event for the Ctrl-C cancel path, and transitions state back to "idle" after the terminal event (or on a mid-session error).
|
||||
BRIEF: Worker coroutine spawned by `on_input_submitted` (exclusive). Queries the four panes, constructs a fresh `TuiPresenterState`, drives `stream_turn`, and renders each event through `presenter.render`. Captures `active_turn_id` + writes the turn headers on the first event (for the Ctrl-C cancel path), breaks on the terminal event, mounts wire-error labels as `error-label` Statics into the transcript scroll, and a `finally` always transitions state back to "idle". v0.9.0: rendering is live (the presenter streams Markdown in place) — there is NO post-Done re-render here.
|
||||
PRE: [PRE-001 hard] self.state == "streaming" (set by on_input_submitted before spawn) -- assert self.state == "streaming"
|
||||
PRE: [PRE-002 hard] self.client is not None (set in on_mount) -- assert self.client is not None
|
||||
PRE: [PRE-003 hard] content is non-empty (caller validated in on_input_submitted) -- assert content
|
||||
POST: [POST-001 state_change] after terminal event OR error, self.state == "idle"; self.active_turn_id is None; footer hint reset to "Ctrl-C twice to exit"
|
||||
POST: [POST-002 side_effect] each event passed through TuiPresenterState.render exactly once (until terminal OR until cancel-induced abort) (issue #12 amendment: was _render_event_to_log)
|
||||
POST: [POST-003 side_effect] for Done events with NOT args.raw: a separator line + the markdown-rendered Done.response appended to RichLog (INV-005)
|
||||
POST: [POST-004 state_change] active_turn_id is set to event.sse_id.turn_id on the FIRST yielded event (for cancel_turn use by action_interrupt)
|
||||
POST: [POST-001 state_change] the `finally` always transitions to "idle": self.state == "idle"; self.active_turn_id is None; footer hint reset to HINT_IDLE — on terminal event, mid-session wire error, OR cancellation
|
||||
POST: [POST-002 side_effect] each event is passed through TuiPresenterState.render exactly once (four panes + the on_persona_snapshot callback threaded), until the terminal event OR a cancel-induced abort
|
||||
POST: [POST-003 state_change] on the FIRST yielded event: active_turn_id is set to event.sse_id.turn_id AND _write_turn_headers(active_turn_id) mounts the turn header (active_turn_id is read by action_interrupt for cancel_turn)
|
||||
POST: [POST-004 side_effect] no post-Done Markdown re-render — the presenter renders Markdown live during Text streaming (v0.9.0); the worker only breaks on the terminal event after the presenter has mounted the tinted label
|
||||
ERROR_ROUTING:
|
||||
SseConnectFailed | SseConnectionDropped | MalformedSseId | MalformedSseData | TurnIdFlip:
|
||||
local_handling: append `[<label>] <details>` to RichLog (mirror cli.py's error labels)
|
||||
flow_control: abort (the iteration aborts; finally-block restores state)
|
||||
state_recovery: state → idle; footer hint reset; active_turn_id cleared. (INV-008: mid-session errors do NOT exit the app.)
|
||||
local_handling: audit the failure, then mount `[<label>] <details>` as an `error-label` Static into the transcript scroll (mirrors cli.py's error labels)
|
||||
flow_control: abort (the iteration aborts; the finally-block restores state)
|
||||
state_recovery: finally → state idle; active_turn_id cleared; hint reset. (INV-008: mid-session wire errors do NOT exit the app.)
|
||||
asyncio.CancelledError (from action_interrupt force-exit OR Worker.cancel()):
|
||||
local_handling: none — propagate to let Textual's worker manager clean up
|
||||
flow_control: abort
|
||||
state_recovery: state → idle; active_turn_id cleared. (cancel_task was already spawned by action_interrupt.)
|
||||
state_recovery: finally → state idle; active_turn_id cleared; hint reset. (cancel_task was already spawned by action_interrupt.)
|
||||
STEPS:
|
||||
1. [setup, flexibility=prescriptive] Validate inputs per PRE-001..PRE-003
|
||||
2. [loop, flexibility=prescriptive] TRY: async for event in stream_turn(self.client, self.session_id, content):
|
||||
IF self.active_turn_id is None: SET self.active_turn_id = event.sse_id.turn_id # POST-004
|
||||
presenter.render(event, log=self.query_one("#transcript", RichLog), thinking_widget=self.query_one("#thinking-current", Static), raw=self.args.raw) # issue #12: state-based rendering
|
||||
IF isinstance(event, Done):
|
||||
IF NOT self.args.raw:
|
||||
Append a horizontal-rule separator to RichLog
|
||||
Render Markdown(event.response) into RichLog # INV-005 post-Done markdown render
|
||||
BREAK (terminal; iteration done)
|
||||
IF isinstance(event, (Error, Cancelled)):
|
||||
BREAK (terminal)
|
||||
CATCH SseConnectFailed | SseConnectionDropped | MalformedSseId | TurnIdFlip as exc:
|
||||
Append `[<label>] <details>` to RichLog per cli.py's error-label format
|
||||
3. [cleanup, flexibility=prescriptive] FINALLY:
|
||||
SET self.state = "idle"; self.active_turn_id = None; reset footer hint to "Ctrl-C twice to exit"
|
||||
2. [setup, flexibility=prescriptive] Query the four panes — transcript=#transcript-scroll (VerticalScroll), tools_log=#tools-log, debug_log=#debug-log, thinking_log=#thinking-log — and construct presenter = TuiPresenterState()
|
||||
3. [loop, flexibility=prescriptive] TRY: async for event in stream_turn(self.client, self.session_id, content):
|
||||
IF self.active_turn_id is None: SET self.active_turn_id = event.sse_id.turn_id; CALL self._write_turn_headers(self.active_turn_id) # POST-003
|
||||
CALL presenter.render(event, transcript=transcript, tools_log=tools_log, debug_log=debug_log, thinking_log=thinking_log, raw=self.args.raw, on_persona_snapshot=self._update_persona_surfaces) # POST-002
|
||||
IF isinstance(event, (Done, Error, Cancelled)): BREAK # terminal; presenter already rendered the live Markdown + tinted label (POST-004 — no re-render)
|
||||
CATCH SseConnectFailed | SseConnectionDropped | MalformedSseId | MalformedSseData | TurnIdFlip as exc:
|
||||
AUDIT the failure; mount `[<label>] <details>` as an error-label Static into transcript
|
||||
4. [cleanup, flexibility=prescriptive] FINALLY:
|
||||
CALL self._transition("idle", "worker_finally"); SET self.active_turn_id = None; CALL self._set_hint(self.HINT_IDLE)
|
||||
TESTS:
|
||||
happy_text_done_renders_markdown [happy,tracer]: mock yields text("hello") + done(response="hello"); after Pilot.pause(), RichLog contains "hello" (the streamed delta) AND below it a separator + the markdown render of "hello"; state → idle
|
||||
raw_flag_skips_markdown_render [trace]: --raw; mock yields text + done; RichLog has the streamed delta but NO separator + markdown re-render
|
||||
@@ -345,40 +345,84 @@ TESTS:
|
||||
```
|
||||
|
||||
```contract
|
||||
CLASS TuiPresenterState # issue #12 amendment
|
||||
BRIEF: Stateful per-turn presenter for TUI mode. Replaces the stateless `_render_event_to_log` (removed). Owns `thinking_buffer`, `thinking_open`; coalesces thinking-event deltas into per-delta live updates on the dedicated `Static(id="thinking-current")` widget AND one closed RichLog entry per run (two-views-of-thinking decoupling); demotes telemetry events with a `· ` dim prefix on RichLog; Done renders a load-bearing label + Markdown (when not raw); render exceptions degrade to a plain-labeled fallback + `[render_error] <type>` line (NO exception message per INV-009 security).
|
||||
CLASS TuiPresenterState # issue #12 amendment; refreshed to the four-pane live-Markdown model (v0.5.0–v0.14.0 + Worldtree #201/#204)
|
||||
BRIEF: Stateful per-turn presenter for TUI mode. Replaces the stateless `_render_event_to_log` (removed). Routes each event across four panes (transcript / tools_log / debug_log / thinking_log): Thinking deltas coalesce by `\n` into `thinking_log` wrapped in Rule(start)/Rule(end) per run; Text deltas accumulate in `text_chunk_buffer` and render live as `Markdown(buffer)` into a single in-place-updated response `Static` (no post-Done re-render); demoted telemetry gets a `· ` dim prefix (WorkerPhase/TextBoundary → debug_log, Tool* → tools_log); terminal events mount a tinted label + write a turn-summary to debug_log; AffectUpdate fires the persona callback; AwaitingLlmFirstToken mounts/updates a heartbeat indicator; render exceptions degrade to a plain-labeled fallback + `[render_error] <type>` line (NO exception message per INV-009 security).
|
||||
PROPERTIES:
|
||||
thinking_buffer: list[str]
|
||||
thinking_open: bool
|
||||
thinking_run_index: int
|
||||
thinking_chunk_buffer: str
|
||||
text_chunk_buffer: str
|
||||
current_response_widget: object # the live response Static; None between turns
|
||||
text_delta_count: int
|
||||
text_byte_count: int
|
||||
thinking_delta_count: int
|
||||
thinking_byte_count: int
|
||||
turn_start_ts: float
|
||||
awaiting_widget: object # the awaiting-token indicator Static; None when closed
|
||||
heartbeat_count: int
|
||||
INV-WIRE-001: One instance per `_stream_turn_worker` invocation (issue #12 INV-008).
|
||||
INV-WIRE-002: Two-views-of-thinking decoupling (issue #12 INV-004): per-delta updates → thinking-current Static; closed run → RichLog entry.
|
||||
INV-WIRE-002: Thinking is single-view (v0.7.1+): deltas coalesce by `\n` into `thinking_log` (RichLog), each run wrapped in Rule(start)/Rule(end). The issue #12 two-views `#thinking-current` Static was removed.
|
||||
```
|
||||
|
||||
```contract
|
||||
FN TuiPresenterState.render(self, event: Event, *, log: RichLog, thinking_widget: Static, raw: bool) -> None # issue #12 amendment
|
||||
BRIEF: Render one event into the TUI with editorial hierarchy + coalescing per issue #12 INV-001..INV-007 + render-exception fallback per INV-009. Unicode allowed in TUI output (e.g., `· ` U+00B7 prefix, `→` U+2192 arrow in usage). Decoupling: thinking deltas go to `thinking_widget` per-delta; one closed RichLog entry per thinking-run.
|
||||
PRE: [PRE-001 hard] event is an instance of one of the Event union variants
|
||||
POST: [POST-001 side_effect] for Thinking: open run (display=True, thinking_open=True) on first delta; append to buffer; update widget with last ~200 chars (… prefix when truncated)
|
||||
POST: [POST-002 side_effect] for non-Thinking when thinking_open: write ONE RichLog entry `· thinking: <full>`; clear buffer; thinking_open=False; widget cleared + display=False; THEN render the new event
|
||||
POST: [POST-003 side_effect] for Text: write content to RichLog (no prefix, no demotion)
|
||||
POST: [POST-004 side_effect] for Done: write `[done] turn_id=... model=... duration={autoscale} usage={p} in → {c} out ({t} total, {ci} cached)`; if NOT raw, append Rule() + Markdown(event.response) per issue #4 INV-005
|
||||
POST: [POST-005 side_effect] for Error/Cancelled: write the load-bearing label (no demotion); ensure widget cleared+hidden
|
||||
POST: [POST-006 side_effect] for demoted telemetry (WorkerPhase, TextBoundary, ToolStart, ToolResult): write `· <label>: <fields>` to RichLog
|
||||
POST: [POST-007 exception] never propagates; on internal exception, write the plain labeled fallback + `[render_error] <ExceptionClassName>` (NO exception message — INV-009 security clause)
|
||||
FN TuiPresenterState.render(self, event: Event, *, transcript: VerticalScroll, tools_log: RichLog, debug_log: RichLog, thinking_log: RichLog, raw: bool, on_persona_snapshot: object = None) -> None # issue #12 amendment; refreshed to the four-pane model (v0.5.0–v0.14.0 + Worldtree #201/#204)
|
||||
BRIEF: Render one Worldtree SSE event into the four-pane TUI with editorial hierarchy, thinking/text coalescing, live Markdown, persona + heartbeat surfaces, and an INV-009 render-exception fallback. Unicode allowed (`·` U+00B7 demotion prefix, `→` U+2192 usage arrow). Pane routing — `transcript` (VerticalScroll) = chat content (live-Markdown response Static, tinted terminal labels, awaiting-token indicator); `thinking_log` (RichLog) = coalesced Thinking deltas wrapped in Rule(start)/Rule(end); `tools_log` (RichLog) = ToolStart + ToolResult; `debug_log` (RichLog) = per-event audit line + WorkerPhase + TextBoundary + turn-summary. Optional `on_persona_snapshot` callback fires when AffectUpdate carries a snapshot (issue #13 / Worldtree #204). Supersedes the issue #12 single-`log`/`thinking_widget` model and the post-Done Markdown re-render (both removed at v0.5.0/v0.9.0).
|
||||
PRE: [PRE-001 hard] event is an instance of one of the Event union variants -- assert isinstance(event, (WorkerPhase, Thinking, Text, TextBoundary, ToolStart, ToolResult, Done, Error, Cancelled, AffectUpdate, AwaitingLlmFirstToken))
|
||||
POST: [POST-001 side_effect] audit bookkeeping (v0.10.0): Text increments text_delta_count/text_byte_count, Thinking increments thinking_delta_count/thinking_byte_count (each sets turn_start_ts on its first delta) — neither emits a per-delta audit line (token-rate spam control); every other event sets turn_start_ts if unset AND writes one dimmed `_audit_line(event)` to debug_log
|
||||
POST: [POST-002 side_effect] for AffectUpdate (Worldtree #204): audit line per POST-001, then IF snapshot is not None AND on_persona_snapshot is provided, invoke on_persona_snapshot(snapshot) with callback exceptions swallowed (persona surface failure must not break the stream); RETURN
|
||||
POST: [POST-003 side_effect] for AwaitingLlmFirstToken (Worldtree #201): heartbeat_count++; first heartbeat mounts a dimmed "awaiting first token · {s:.1f}s" Static into transcript, subsequent heartbeats update it in place; widget-op exceptions swallowed; scroll_end; RETURN
|
||||
POST: [POST-004 side_effect] gap-close: any non-heartbeat event past the heartbeat branch removes the awaiting indicator if still mounted (awaiting_widget → None)
|
||||
POST: [POST-005 side_effect] for Thinking: open the run on first delta (thinking_run_index++, write Rule("turn {turn_id} · thinking #{idx} start") to thinking_log, thinking_open=True); accumulate content into thinking_chunk_buffer; flush each complete `\n`-terminated line to thinking_log (skip blank lines), retain the tail; RETURN
|
||||
POST: [POST-006 side_effect] for non-Thinking when thinking_open: flush the buffered tail to thinking_log, write Rule("turn {turn_id} · thinking #{idx} end"), thinking_open=False; THEN render the new event
|
||||
POST: [POST-007 side_effect] for Text: append content to text_chunk_buffer; render `text_chunk_buffer if raw else Markdown(text_chunk_buffer)` — first Text delta mounts a `.response-md` Static into transcript, subsequent deltas update it in place (live Markdown, no post-Done re-render); scroll_end; RETURN
|
||||
POST: [POST-008 side_effect] for Done/Error/Cancelled: write a dimmed turn-summary (turn_id, text_deltas/bytes, thinking_deltas/bytes, heartbeats, elapsed_ms) to debug_log; clear text_chunk_buffer + current_response_widget; mount a tinted terminal-label Static into transcript — Done = success-tinted `[done] turn_id=... model=... duration={_format_duration_ms} usage {_format_usage(arrow='→')}`, Error = error-tinted `[error] turn_id=... code=... message=...!r`, Cancelled = warning-tinted `[cancelled] turn_id=... reason=...!r partial_message_id=...`; scroll_end; RETURN
|
||||
POST: [POST-009 side_effect] for demoted telemetry: WorkerPhase + TextBoundary → dimmed `· <label>: <fields>` to debug_log; ToolStart + ToolResult → dimmed `· <label>: <fields>` to tools_log (ToolResult result truncated to 200 chars) per issue #13 INV-014; RETURN
|
||||
POST: [POST-010 exception] never propagates; on any internal exception, write `_plain_label(event)` + `[render_error] <ExceptionClassName>` (NO exception message — INV-009 security clause) to the event's pane (tools_log for Tool*; thinking_log for Thinking; debug_log for WorkerPhase/TextBoundary; else mount Statics into transcript)
|
||||
ERROR_ROUTING:
|
||||
Exception (any internal render failure — widget op, formatting, persona callback):
|
||||
local_handling: write `_plain_label(event)` + `[render_error] {type(exc).__name__}` (no message — INV-009 security clause) to the event's pane (tools_log for Tool*; thinking_log for Thinking; debug_log for WorkerPhase/TextBoundary; else mount Statics into transcript)
|
||||
flow_control: skip (swallow — render never propagates)
|
||||
state_recovery: none (the next event renders against fresh state)
|
||||
STEPS:
|
||||
1. [setup, flexibility=prescriptive] Validate event ∈ Event union per PRE-001.
|
||||
2. [setup, flexibility=prescriptive] Enter the render try-block — steps 3..11 run inside it; step 12 is the INV-009 fallback.
|
||||
3. [branch, flexibility=prescriptive] Audit bookkeeping (POST-001):
|
||||
IF Text: set turn_start_ts on first delta; text_delta_count++; text_byte_count += len(content)
|
||||
ELIF Thinking: set turn_start_ts on first delta; thinking_delta_count++; thinking_byte_count += len(content)
|
||||
ELSE: set turn_start_ts if unset; WRITE _dim(_audit_line(event)) to debug_log
|
||||
4. [branch, flexibility=prescriptive] IF AffectUpdate (POST-002): IF snapshot is not None AND on_persona_snapshot is not None: TRY on_persona_snapshot(snapshot) / swallow Exception; RETURN
|
||||
5. [branch, flexibility=prescriptive] IF AwaitingLlmFirstToken (POST-003): heartbeat_count++; secs = elapsed_ms_since_building_prompt / 1000; mount-or-update a dimmed "awaiting first token · {secs:.1f}s" Static in transcript (swallow widget Exception); scroll_end; RETURN
|
||||
6. [branch, flexibility=prescriptive] Gap-close (POST-004): IF awaiting_widget is not None: remove it (swallow Exception); SET awaiting_widget=None
|
||||
7. [branch, flexibility=prescriptive] IF Thinking (POST-005): IF NOT thinking_open: thinking_run_index++; WRITE Rule(start) to thinking_log; thinking_open=True. APPEND content to thinking_chunk_buffer; WHILE "\n" in buffer: partition on "\n", WRITE non-empty line to thinking_log, keep the remainder. RETURN
|
||||
8. [branch, flexibility=prescriptive] Close open thinking run (POST-006): IF thinking_open: IF buffer non-empty: WRITE buffer tail to thinking_log, clear buffer. WRITE Rule(end) to thinking_log; thinking_open=False
|
||||
9. [branch, flexibility=prescriptive] IF Text (POST-007): APPEND content to text_chunk_buffer; rendered = buffer if raw else Markdown(buffer); IF current_response_widget is None: mount Static(rendered, classes="response-md") in transcript; ELSE: current_response_widget.update(rendered); scroll_end; RETURN
|
||||
10. [branch, flexibility=prescriptive] IF Done|Error|Cancelled (POST-008): elapsed_ms = int((monotonic()-turn_start_ts)*1000) if turn_start_ts else 0; WRITE dimmed turn-summary to debug_log; clear text_chunk_buffer + current_response_widget; mount the tinted terminal-label Static (Done=success / Error=error / Cancelled=warning) in transcript with the documented label text; scroll_end; RETURN
|
||||
11. [branch, flexibility=prescriptive] Demoted telemetry (POST-009), then RETURN: WorkerPhase → debug_log `· worker_phase: ...`; ToolStart → tools_log `· tool_start: ...`; ToolResult → tools_log `· tool_result: ... result={result!r:.200}`; TextBoundary → debug_log `· text_boundary: ...`
|
||||
12. [error_handler, flexibility=prescriptive] EXCEPT Exception as exc (POST-010 / INV-009): WRITE _plain_label(event) + "[render_error] {type(exc).__name__}" (no message) to the event's pane per ERROR_ROUTING
|
||||
TESTS:
|
||||
thinking_coalesce_single_widget_update [happy,tracer]: 3 Thinking events → widget.update called 3× with cumulative content; RichLog has 0 thinking entries yet
|
||||
thinking_closes_one_richlog_entry [happy]: 2× Thinking + WorkerPhase → ONE closed thinking entry + worker_phase entry; widget cleared+hidden
|
||||
thinking_widget_truncation [trace]: 500-char buffer → widget shows "…" + last 200
|
||||
thinking_widget_visibility_lifecycle [trace]: hidden initially; visible during run; hidden after closing event
|
||||
multiple_thinking_runs_each_get_richlog_entry [scenario]: Thinking → Text → Thinking → Done → TWO closed thinking entries
|
||||
cancelled_mid_thinking_closes [scenario]: Thinking → Cancelled → ONE closed thinking entry + [cancelled]; widget hidden
|
||||
done_renders_markdown_after_label [happy]: Text + Done(response=...) with NOT raw → [done] line, Rule, Markdown
|
||||
raw_flag_skips_markdown [trace]: raw=True → no Rule, no Markdown
|
||||
worker_phase_demoted [trace]: "· worker_phase:" prefix (not "[worker_phase]")
|
||||
tool_start_demoted [trace]: "· tool_start:" prefix
|
||||
text_no_prefix [trace]: Text → no demotion prefix
|
||||
render_exception_fallback [adversarial]: widget.update raises → fallback line + `[render_error] <ClassName>` (NO message); state does not propagate
|
||||
state_reset_per_worker [trace]: fresh TuiPresenterState() starts with no thinking open
|
||||
text_then_done_mounts_widget_and_finalizes [happy,tracer]: Text + Done (NOT raw) → live Markdown `.response-md` widget mounted; on Done the widget ref clears + a success-tinted [done] label mounts; no post-Done re-render (no double-print)
|
||||
thinking_coalesces_until_newline [happy]: Thinking deltas buffer; only complete `\n`-terminated lines flush to thinking_log
|
||||
thinking_flushes_on_newline [happy]: a Thinking delta containing `\n` flushes the completed line and retains the tail for the next delta
|
||||
thinking_closes_to_thinking_log [happy]: 2× Thinking + WorkerPhase → tail flushed + Rule(end) closes the run in thinking_log; thinking_open=False
|
||||
multiple_thinking_runs_each_get_thinking_log_section [scenario]: Thinking → Text → Thinking → Done → TWO Rule-wrapped thinking sections
|
||||
cancelled_mid_thinking_closes [scenario]: Thinking → Cancelled → run closes with Rule(end); warning-tinted [cancelled] label mounted
|
||||
text_first_delta_mounts_response_widget [happy]: first Text delta mounts a `.response-md` Static in transcript holding Markdown(buffer)
|
||||
text_subsequent_deltas_update_in_place [trace]: later Text deltas update the same widget (live Markdown), no new mount
|
||||
raw_flag_skips_markdown [trace]: raw=True → response widget holds plain str, no Markdown wrapping
|
||||
worker_phase_demoted_to_debug_log [trace]: WorkerPhase → dimmed `· worker_phase:` in debug_log, not transcript
|
||||
tool_start_routes_to_tools_log [trace]: ToolStart → `· tool_start:` in tools_log (issue #13 INV-014)
|
||||
tool_result_routes_to_tools_log [trace]: ToolResult → `· tool_result: ... result=<≤200 chars>` in tools_log
|
||||
worker_phase_emits_audit_line [trace]: a non-Text/Thinking event writes one dimmed `_audit_line` to debug_log
|
||||
tool_start_emits_audit_line [trace]: ToolStart writes an audit line to debug_log in addition to the tools_log routing
|
||||
text_delta_counted_not_per_event_audit_line [trace]: Text deltas increment counters but emit NO per-delta audit line (token-rate spam control)
|
||||
done_emits_turn_summary_line [trace]: Done writes a dimmed turn-summary (text/thinking delta+byte counts, heartbeats, elapsed_ms) to debug_log before clearing counters
|
||||
affect_update_routes_to_audit_only [scenario]: AffectUpdate(snapshot) → audit line + on_persona_snapshot(snapshot) callback; no transcript mount
|
||||
affect_update_scheduled_has_no_pad_detail [trace]: AffectUpdate(status="scheduled", snapshot=None) → audit line only; callback skipped
|
||||
awaiting_llm_first_token_mounts_indicator [scenario]: first AwaitingLlmFirstToken mounts a dimmed "awaiting first token · {s}s" Static in transcript
|
||||
awaiting_subsequent_heartbeats_update_in_place [trace]: later heartbeats update the same indicator in place; heartbeat_count grows
|
||||
awaiting_indicator_removed_when_gap_closes [scenario]: the first non-heartbeat event removes the awaiting indicator (gap closed)
|
||||
render_exception_fallback [adversarial]: an internal render failure writes `_plain_label` + `[render_error] <ClassName>` (NO message) to the event's pane; never propagates (INV-009)
|
||||
state_reset_per_worker [trace]: a fresh TuiPresenterState() starts with thinking_open=False and zeroed counters
|
||||
duration_format_seconds [trace]: Done(duration_ms=5467) → "duration=5.5s"
|
||||
usage_format_unicode_arrow [trace]: Done → "usage ... in → ... out (...)" (Unicode arrow, not ASCII)
|
||||
```
|
||||
@@ -408,7 +452,7 @@ STEPS:
|
||||
RETURN
|
||||
SET self.state = "cancelling"
|
||||
update footer hint to "Press Ctrl-C again to exit"
|
||||
self.run_worker(_cancel_via_sse(self.client, self.session_id, self.active_turn_id, log=self.query_one("#transcript", RichLog)))
|
||||
self.run_worker(_cancel_via_sse(self.client, self.session_id, self.active_turn_id, transcript=self.query_one("#transcript-scroll", VerticalScroll), audit=self._audit))
|
||||
ELIF self.state == "cancelling":
|
||||
# Second Ctrl-C — force exit, abandon drain
|
||||
IF self.stream_worker is not None: self.stream_worker.cancel()
|
||||
|
||||
Reference in New Issue
Block a user