diff --git a/crates/maestro-daemon/src/projection.rs b/crates/maestro-daemon/src/projection.rs index 1f31897..f320863 100644 --- a/crates/maestro-daemon/src/projection.rs +++ b/crates/maestro-daemon/src/projection.rs @@ -2,6 +2,7 @@ use std::collections::{HashMap, HashSet}; +use chrono::{DateTime, Utc}; use maestro_acp::schema::{ContentChunk, SessionNotification, SessionUpdate, ToolCall}; use maestro_protocol::{ ActivityState, ReducedState, TranscriptDelta, TranscriptRow, TranscriptRowKind, @@ -37,6 +38,8 @@ pub struct Projection { pending_tool_updates: HashMap>, state: ReducedState, active_turn: Option, + active_turn_started_at: Option>, + active_turn_context_tokens: Option, turn_failed: bool, next_turn: u64, turn_tool_calls: HashSet, @@ -132,9 +135,15 @@ impl Projection { self.next_turn += 1; let turn = self.next_turn; self.active_turn = Some(turn); + let started_at = Utc::now(); + let started_context_tokens = usage_tokens(self.state.usage.as_ref()); + self.active_turn_started_at = Some(started_at); + self.active_turn_context_tokens = started_context_tokens; self.turn_failed = false; self.state.turn.responding = true; self.state.turn.thinking = true; + self.state.turn.started_at = Some(started_at); + self.state.turn.started_context_tokens = started_context_tokens; self.turn_tool_calls.clear(); self.state.turn.active_tool_calls.clear(); ( @@ -163,10 +172,44 @@ impl Projection { self.turn_failed = lifecycle == TurnLifecycle::Failure; self.state.turn.responding = false; self.state.turn.thinking = false; + let finished_at = Utc::now(); + let started_at = self.active_turn_started_at.take(); + let started_context_tokens = self.active_turn_context_tokens.take(); + self.state.turn.started_at = None; + self.state.turn.started_context_tokens = None; self.turn_tool_calls.clear(); self.state.turn.active_tool_calls.clear(); + let context_tokens = usage_tokens(self.state.usage.as_ref()); + let context_delta = context_tokens + .zip(started_context_tokens) + .map(|(end, start)| i128::from(end) - i128::from(start)); + let elapsed_ms = started_at.map_or(0, |started| { + finished_at + .signed_duration_since(started) + .num_milliseconds() + .max(0) + }); + let outcome = match lifecycle { + TurnLifecycle::Finish => "completed", + TurnLifecycle::Cancel => "cancelled", + TurnLifecycle::Failure => "failed", + TurnLifecycle::Begin => unreachable!(), + }; + let row = self.append( + format!("turn_meta:{}", uuid::Uuid::new_v4()), + TranscriptRowKind::Notice, + serde_json::json!({ + "maestroTurnMeta": { + "elapsedMs": elapsed_ms, + "contextTokens": context_tokens, + "contextDelta": context_delta, + "outcome": outcome, + "model": self.state.model, + } + }), + ); Some(ProjectionUpdate { - transcript: None, + transcript: Some(row), state: self.state(), }) } @@ -413,6 +456,10 @@ impl Projection { } } +fn usage_tokens(usage: Option<&Value>) -> Option { + usage?.get("used")?.as_u64() +} + fn to_value(value: &impl serde::Serialize) -> Value { serde_json::to_value(value).expect("ACP schema values serialize") } @@ -550,9 +597,10 @@ mod tests { ); let rows = projection.snapshot(4).rows; - assert_eq!(rows.len(), 2); + assert_eq!(rows.len(), 3); assert_eq!(rows[0].data.pointer("/content/text").unwrap(), "pong"); - assert_eq!(rows[1].data.pointer("/content/text").unwrap(), "next"); + assert!(rows[1].data.get("maestroTurnMeta").is_some()); + assert_eq!(rows[2].data.pointer("/content/text").unwrap(), "next"); } #[test] @@ -797,6 +845,38 @@ mod tests { } } + #[test] + fn completed_turn_keeps_duration_and_context_delta_in_transcript() { + let mut projection = Projection::default(); + projection.apply( + 1, + &event(serde_json::json!({ + "sessionUpdate":"usage_update", "used":10_000, "size":200_000 + })), + ); + let (turn, begun) = projection.begin_turn(); + assert!(begun.state.turn.started_at.is_some()); + assert_eq!(begun.state.turn.started_context_tokens, Some(10_000)); + projection.apply( + 2, + &event(serde_json::json!({ + "sessionUpdate":"usage_update", "used":12_500, "size":200_000 + })), + ); + + let finished = projection + .turn_lifecycle(TurnLifecycle::Finish, turn) + .expect("active turn finishes"); + let TranscriptDelta::Append { row } = finished.transcript.unwrap() else { + panic!("turn footer"); + }; + assert_eq!(row.kind, TranscriptRowKind::Notice); + assert_eq!(row.data["maestroTurnMeta"]["contextDelta"], 2_500); + assert_eq!(row.data["maestroTurnMeta"]["contextTokens"], 12_500); + assert_eq!(row.data["maestroTurnMeta"]["outcome"], "completed"); + assert!(finished.state.turn.started_at.is_none()); + } + fn terminal_turn_drops_unfinished_tools( terminal: TurnLifecycle, unfinished_id: &str, @@ -815,7 +895,9 @@ mod tests { projection.turn_lifecycle(terminal, turn); assert!(projection.state().turn.active_tool_calls.is_empty()); - assert_eq!(projection.snapshot(1).rows.len(), 1); + let rows = projection.snapshot(1).rows; + assert_eq!(rows.len(), 2); + assert!(rows[1].data.get("maestroTurnMeta").is_some()); projection.begin_turn(); projection.apply( diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index 99635bd..1b5670c 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -657,6 +657,7 @@ impl SessionManager { let Some(projected) = stream.projection.turn_lifecycle(lifecycle, turn) else { return Ok(false); }; + let projected = persist_turn_update(&mut stream, projected); let _ = stream.projected_live.send(projected); drop(stream); sync_structured_activity(&self.inner, id).await; @@ -774,6 +775,7 @@ impl SessionManager { .expect( "newly claimed turn remains current while stream is locked", ); + let projected = persist_turn_update(&mut stream, projected); let _ = stream.projected_live.send(projected); drop(stream); sync_structured_activity(&self.inner, id).await; @@ -819,6 +821,7 @@ impl SessionManager { } cancel_pending_approvals(&mut stream); if let Some(projected) = stream.projection.turn_lifecycle(lifecycle, turn) { + let projected = persist_turn_update(&mut stream, projected); let _ = stream.projected_live.send(projected); } drop(stream); @@ -935,6 +938,7 @@ impl SessionManager { .projection .turn_lifecycle(TurnLifecycle::Cancel, turn) .expect("active turn remains current while stream is locked"); + let projected = persist_turn_update(&mut stream, projected); let _ = stream.projected_live.send(projected); drop(stream); sync_structured_activity(&self.inner, id).await; @@ -2190,10 +2194,31 @@ fn start_acp_event_pump( fn apply_persisted_event(projection: &mut Projection, event: &LoggedEvent) -> ProjectionUpdate { match &event.event { PersistedEvent::Notification(notification) => projection.apply(event.seq, notification), - PersistedEvent::User { row } | PersistedEvent::Approval { row } => { - projection.restore_row(row.clone()) + PersistedEvent::User { row } + | PersistedEvent::Approval { row } + | PersistedEvent::Turn { row } => projection.restore_row(row.clone()), + } +} + +fn persist_turn_update( + stream: &mut AcpEventStream, + mut update: ProjectionUpdate, +) -> ProjectionUpdate { + let Some(TranscriptDelta::Append { row }) = update.transcript.as_ref() else { + return update; + }; + let event = PersistedEvent::Turn { row: row.clone() }; + match stream.log.append(&event) { + Ok(seq) => { + let _ = stream.live.send(LoggedEvent { seq, event }); + } + Err(error) => { + tracing::error!(%error, "turn metadata history append failed"); + stream.projection.rollback_row(&row.id); + update.transcript = None; } } + update } async fn sync_structured_activity(inner: &Arc, id: Uuid) { diff --git a/crates/maestro-daemon/src/sidecar.rs b/crates/maestro-daemon/src/sidecar.rs index fb8c3e8..169847a 100644 --- a/crates/maestro-daemon/src/sidecar.rs +++ b/crates/maestro-daemon/src/sidecar.rs @@ -269,6 +269,9 @@ pub enum PersistedEvent { Approval { row: TranscriptRow, }, + Turn { + row: TranscriptRow, + }, #[serde(skip)] Notification(Box), } diff --git a/crates/maestro-daemon/tests/acp.rs b/crates/maestro-daemon/tests/acp.rs index 8e7b595..2012977 100644 --- a/crates/maestro-daemon/tests/acp.rs +++ b/crates/maestro-daemon/tests/acp.rs @@ -349,9 +349,27 @@ async fn projection_snapshot_and_live_stream_share_one_fold() { .await .expect("finish lifecycle broadcast") .expect("projection broadcast"); - assert_eq!(finish.transcript, None); + let Some(TranscriptDelta::Append { row }) = finish.transcript else { + panic!("finish should append durable turn metadata"); + }; + assert_eq!(row.kind, TranscriptRowKind::Notice); + assert_eq!(row.data["maestroTurnMeta"]["outcome"], "completed"); assert_eq!(finish.state.turn, Default::default()); + let recovered = fx + .manager + .read_acp_projection_replay(session.id, EventCursor::Since(0), 10) + .await + .expect("turn replay") + .expect("structured session"); + assert!(recovered.iter().any(|event| { + matches!( + &event.transcript, + Some(TranscriptDelta::Append { row }) + if row.data["maestroTurnMeta"]["outcome"] == "completed" + ) + })); + fx.manager.kill(session.id, false).await.expect("kill"); } diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 340f345..b707af0 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -824,6 +824,15 @@ pub struct TurnState { pub thinking: bool, #[serde(default)] pub active_tool_calls: Vec, + /// Wall-clock start of the active prompt. This lets reconnecting clients + /// keep one continuous elapsed-time display instead of restarting at zero. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub started_at: Option>, + /// Context-window usage when the active prompt began. ACP exposes context + /// occupancy, not provider billing-token accounting, so clients must label + /// deltas as context tokens rather than tokens billed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub started_context_tokens: Option, } /// A whole-session view derived from ACP notifications. diff --git a/docs/STRUCTURED-VIEW.md b/docs/STRUCTURED-VIEW.md index db6902e..d8600a1 100644 --- a/docs/STRUCTURED-VIEW.md +++ b/docs/STRUCTURED-VIEW.md @@ -113,7 +113,7 @@ crate. | `ToolDiff` | The same tool-call machinery, when the tool's content includes a `Diff` block. | Same `tool:` identity as `ToolCall`; the *kind* flips between the two as the tool's own content changes, not the row. | | `Approval` | A daemon-originated permission request (`RequestPermissionRequest`), serialized without its nonce. | `approval:` — minted fresh per request, unrelated to the ACP request's own id. | | `Plan` | A `plan` update — the agent's current step list. | The fixed id `"plan"`. Each new plan **replaces** the row wholesale; entries are not diffed against the previous plan. | -| `Notice` | The raw JSON of any `session/update` variant the ACP dependency already decodes but the projection does not special-case. | `notice:`. | +| `Notice` | The raw JSON of any `session/update` variant the ACP dependency already decodes but the projection does not special-case. Maestro also uses a tagged `maestroTurnMeta` notice as the durable footer for a completed, cancelled, or failed turn. | `notice:` for ACP updates; `turn_meta:` for a turn footer. | `Notice` covers known-but-unspecialized `SessionUpdate` variants: ones the `agent-client-protocol` dependency's schema already deserializes (it @@ -147,7 +147,10 @@ choices, not daemon ones. TUI renderers can never disagree about it. - Turn lifecycle: when a turn begins, and whether an ACP response, a cancel, or a failure ends it (`TurnState.responding` / `.thinking` / - `.active_tool_calls` are derived, not renderer-computed). + `.active_tool_calls` / `.started_at` are derived, not renderer-computed). + Ending a turn appends a durable `maestroTurnMeta` footer with its elapsed + time, outcome, model, and context-window usage/delta. ACP does not expose + provider billing-token counts, so these are explicitly context tokens. - Approval nonce minting, secrecy, and the single-active-approval rule (see below) — a client never sees an ACP permission request before the daemon has already turned it into an `Approval` row and a nonce. @@ -177,7 +180,9 @@ choices, not daemon ones. the caret. Completing a command replaces only that token. The client omits those state-only notices, along with `session_info_update`, from the visible transcript. -- How `responding` vs. `thinking` are distinguished visually, if at all. +- How live `responding`, `thinking`, tool activity, elapsed time, and context + usage are drawn. The web client keeps these in a compact strip above the + composer and renders each durable turn footer inline with the transcript. - How the operator is asked to resolve an `Approval` row — the daemon only exposes the three-way decision (`AllowOnce` / `AllowForSession` / `Deny`), never the UI that solicits it. @@ -209,7 +214,8 @@ every row, once appended, is only ever patched or left alone. The event log's own line position is the cursor: 1-based, shared by ACP notifications and daemon-generated rows alike (`sidecar.rs`'s `PersistedEvent` envelope is what lets `User` and `Approval` rows share a cursor -sequence with raw ACP JSON without pretending to be ACP traffic). Two ways to +sequence with raw ACP JSON without pretending to be ACP traffic; the envelope +also carries durable `Turn` footers). Two ways to read it: - **`WS /api/structured/:id`** — on connect, sends a `TranscriptSnapshot` and diff --git a/web/src/structured.test.ts b/web/src/structured.test.ts index 1b663bd..4ebbbdc 100644 --- a/web/src/structured.test.ts +++ b/web/src/structured.test.ts @@ -182,7 +182,9 @@ describe("structured connection: data flow", () => { }, }); - expect(conn.el.querySelector(".structured-config-summary")?.textContent).toBe("Opus · High"); + expect(conn.el.querySelector(".structured-config-trigger")?.getAttribute("aria-label")).toBe( + "Session settings. Model: Opus. Effort: High", + ); (conn.el.querySelector(".structured-config-trigger") as HTMLButtonElement).click(); const selects = conn.el.querySelectorAll(".structured-config-select"); expect(selects).toHaveLength(2); @@ -587,6 +589,41 @@ describe("structured connection: turn/composer gating", () => { conn.dispose(); }); + it("shows live thinking time, tool activity, and context usage", () => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } }); + const activity = conn.el.querySelector(".structured-activity")!; + ws.emit({ + type: "reduced_state", + state: { + turn: { + responding: true, + thinking: true, + active_tool_calls: [], + started_at: new Date().toISOString(), + started_context_tokens: 10_000, + }, + usage: { used: 12_400, size: 200_000 }, + }, + }); + expect(activity.hidden).toBe(false); + expect(activity.textContent).toContain("Thinking · 0s · 12.4k context / 200k context"); + + ws.emit({ + type: "reduced_state", + state: { + turn: { responding: true, thinking: false, active_tool_calls: ["bash-1", "read-1"] }, + usage: { used: 12_800, size: 200_000 }, + }, + }); + expect(activity.textContent).toContain("Running 2 tools"); + ws.emit({ type: "reduced_state", state: idleState }); + expect(activity.hidden).toBe(true); + conn.dispose(); + }); + it("sends a prompt on Enter, clears the draft, and restores it on failure", async () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); diff --git a/web/src/structured.ts b/web/src/structured.ts index a23aad4..d4e7bc0 100644 --- a/web/src/structured.ts +++ b/web/src/structured.ts @@ -18,7 +18,12 @@ import type { CatalogStore } from "./events.js"; import { paneTitleAttr, paneTitleHtml, type AttachInfo } from "./paneTitle.js"; import type { PaneConnection } from "./pane.js"; import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js"; -import { buildToolGroup, buildTranscriptRow } from "./structuredRender.js"; +import { + buildToolGroup, + buildTranscriptRow, + formatContextTokens, + formatElapsed, +} from "./structuredRender.js"; import type { AcpSessionConfigChoice, AcpSessionConfigOption, @@ -80,6 +85,10 @@ export function createStructuredConnection(
+
@@ -103,11 +114,12 @@ export function createStructuredConnection( const stateEl = el.querySelector(".attach-state") as HTMLSpanElement; const configEl = el.querySelector(".structured-config") as HTMLDivElement; const configTrigger = el.querySelector(".structured-config-trigger") as HTMLButtonElement; - const configSummary = el.querySelector(".structured-config-summary") as HTMLSpanElement; const configMenu = el.querySelector(".structured-config-menu") as HTMLDivElement; const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement; const scrollEl = el.querySelector(".structured-scroll") as HTMLDivElement; const rowsEl = el.querySelector(".structured-rows") as HTMLDivElement; + const activityEl = el.querySelector(".structured-activity") as HTMLDivElement; + const activityText = el.querySelector(".structured-activity-text") as HTMLSpanElement; const composerForm = el.querySelector(".structured-composer") as HTMLFormElement; const commandMenu = el.querySelector(".structured-command-menu") as HTMLDivElement; const input = el.querySelector(".structured-input") as HTMLTextAreaElement; @@ -147,6 +159,8 @@ export function createStructuredConnection( let socketGeneration = 0; let stateSocketGeneration = 0; let disposed = false; + let optimisticTurnStartedAt: number | null = null; + let activityTimer: number | null = null; // Within 64px, wheel and touch imprecision should still count as following. const FOLLOW_BOTTOM_THRESHOLD_PX = 64; @@ -420,8 +434,9 @@ export function createStructuredConnection( const bRank = rank(b.category); return (aRank < 0 ? 99 : aRank) - (bRank < 0 ? 99 : bRank); }); - configSummary.textContent = ordered.map(currentConfigLabel).join(" · "); - configTrigger.title = ordered.map((option) => `${option.name}: ${currentConfigLabel(option)}`).join("\n"); + const summary = ordered.map((option) => `${option.name}: ${currentConfigLabel(option)}`).join("\n"); + configTrigger.title = summary; + configTrigger.setAttribute("aria-label", `Session settings. ${summary.replaceAll("\n", ". ")}`); configTrigger.disabled = configBusy; configMenu.replaceChildren(); for (const option of ordered) { @@ -467,6 +482,39 @@ export function createStructuredConnection( sendBtn.disabled = active; cancelBtn.hidden = !state.turn.responding && !state.turn.thinking; if (active) closeCommandMenu(); + renderActivity(); + } + + function liveElapsedMs(): number { + const authoritative = state.turn.started_at ? Date.parse(state.turn.started_at) : Number.NaN; + const startedAt = Number.isFinite(authoritative) ? authoritative : optimisticTurnStartedAt; + return startedAt === null ? 0 : Math.max(0, Date.now() - startedAt); + } + + function renderActivity(): void { + const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null; + activityEl.hidden = !active; + if (!active) { + optimisticTurnStartedAt = null; + if (activityTimer !== null) window.clearInterval(activityTimer); + activityTimer = null; + return; + } + const tools = state.turn.active_tool_calls.length; + const label = awaitingBeginGeneration !== null + ? "Sending" + : state.turn.thinking + ? "Thinking" + : tools > 0 + ? `Running ${tools} ${tools === 1 ? "tool" : "tools"}` + : "Responding"; + const parts = [label, formatElapsed(liveElapsedMs())]; + if (typeof state.usage?.used === "number") { + const usage = formatContextTokens(state.usage.used); + parts.push(typeof state.usage.size === "number" ? `${usage} / ${formatContextTokens(state.usage.size)}` : usage); + } + activityText.textContent = parts.join(" · "); + if (activityTimer === null) activityTimer = window.setInterval(renderActivity, 1_000); } function reconcileAwaitingBegin(): void { @@ -699,6 +747,7 @@ export function createStructuredConnection( const generation = ++submissionGeneration; const submissionSocketGeneration = socketGeneration; awaitingBeginGeneration = generation; + optimisticTurnStartedAt = Date.now(); acceptedSubmission = null; input.value = ""; autoResize(); @@ -884,6 +933,7 @@ export function createStructuredConnection( dispose() { if (disposed) return; disposed = true; + if (activityTimer !== null) window.clearInterval(activityTimer); mountToken++; scrollWorkActive = false; cancelScrollFrame(); diff --git a/web/src/structuredRender.test.ts b/web/src/structuredRender.test.ts index 7c8d768..7fce155 100644 --- a/web/src/structuredRender.test.ts +++ b/web/src/structuredRender.test.ts @@ -332,6 +332,24 @@ describe("buildTranscriptRow: approval", () => { }); describe("buildTranscriptRow: notice / unknown kinds", () => { + it("renders durable per-turn duration and context usage", () => { + const el = buildTranscriptRow( + row("notice", { + maestroTurnMeta: { + elapsedMs: 72_000, + contextTokens: 18_400, + contextDelta: 2_350, + outcome: "completed", + model: "opus", + }, + }, "turn:1"), + )!; + + expect(el.classList.contains("st-turn-meta")).toBe(true); + expect(el.textContent).toContain("1m 12s · +2.4k context tokens · opus"); + expect(el.getAttribute("aria-label")).toBe("Copy turn details"); + }); + it("renders notice and unknown row kinds safely", () => { expect(buildTranscriptRow(row("notice", { sessionUpdate: "future" }))?.textContent).toBe( "[notice]", diff --git a/web/src/structuredRender.ts b/web/src/structuredRender.ts index 801aded..14a66ba 100644 --- a/web/src/structuredRender.ts +++ b/web/src/structuredRender.ts @@ -12,6 +12,7 @@ import type { AcpToolCallLike, StructuredApprovalDecision, TranscriptRow, + TurnMeta, } from "./types.js"; // --------------------------------------------------------------------------- @@ -405,6 +406,54 @@ function buildPlainRow(row: TranscriptRow): HTMLElement { return wrap; } +function compactNumber(value: number): string { + if (Math.abs(value) < 1_000) return String(value); + if (Math.abs(value) < 1_000_000) return `${(value / 1_000).toFixed(value % 1_000 === 0 ? 0 : 1)}k`; + return `${(value / 1_000_000).toFixed(value % 1_000_000 === 0 ? 0 : 1)}m`; +} + +export function formatElapsed(milliseconds: number): string { + const seconds = Math.max(0, Math.floor(milliseconds / 1_000)); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainder = seconds % 60; + return remainder === 0 ? `${minutes}m` : `${minutes}m ${remainder}s`; +} + +export function formatContextTokens(value: number): string { + return `${compactNumber(value)} context`; +} + +function buildTurnMetaRow(meta: TurnMeta): HTMLElement { + const wrap = el("div", `st-turn-meta st-turn-meta-${meta.outcome}`); + wrap.setAttribute("aria-label", `Turn ${meta.outcome}`); + const parts = [formatElapsed(meta.elapsedMs)]; + if (typeof meta.contextDelta === "number") { + const sign = meta.contextDelta > 0 ? "+" : ""; + parts.push(`${sign}${compactNumber(meta.contextDelta)} context tokens`); + } else if (typeof meta.contextTokens === "number") { + parts.push(formatContextTokens(meta.contextTokens)); + } + if (meta.model) parts.push(meta.model); + if (meta.outcome !== "completed") parts.push(meta.outcome); + const dot = el("span", "st-turn-meta-dot"); + dot.setAttribute("aria-hidden", "true"); + const text = el("span"); + text.textContent = parts.join(" · "); + wrap.append(dot, text); + makeCopyable(wrap, text.textContent, "turn details"); + return wrap; +} + +function turnMeta(row: TranscriptRow): TurnMeta | null { + if (row.kind !== "notice" || !row.data || typeof row.data !== "object") return null; + const value = (row.data as { maestroTurnMeta?: unknown }).maestroTurnMeta; + if (!value || typeof value !== "object") return null; + const meta = value as Partial; + if (typeof meta.elapsedMs !== "number" || typeof meta.outcome !== "string") return null; + return meta as TurnMeta; +} + function isStateOnlyNotice(row: TranscriptRow): boolean { if (row.kind !== "notice" || !row.data || typeof row.data !== "object") return false; const update = (row.data as { sessionUpdate?: string }).sessionUpdate; @@ -555,7 +604,7 @@ export function buildTranscriptRow( return null; case "notice": if (isStateOnlyNotice(row)) return null; - built = buildPlainRow(row); + built = turnMeta(row) ? buildTurnMetaRow(turnMeta(row)!) : buildPlainRow(row); break; default: built = buildPlainRow(row); diff --git a/web/src/styles.css b/web/src/styles.css index db67bdf..e0ee2fe 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -2175,25 +2175,32 @@ input.leaf-rename:focus { .structured-config { position: relative; - flex: 0 1 210px; - min-width: 0; + flex: 0 0 var(--control-h); } .structured-config-trigger { - width: 100%; + width: var(--control-h); height: var(--control-h); - display: flex; + display: inline-flex; + justify-content: center; align-items: center; - gap: var(--sp-2); - padding: 0 var(--sp-2); + padding: 0; border: 1px solid var(--border); border-radius: var(--r-2); background: var(--surface-2); color: var(--muted); - font: 500 var(--fs-11) / 1 var(--font-sans); cursor: pointer; } +.structured-config-trigger svg { + width: 18px; + height: 18px; + fill: none; + stroke: currentColor; + stroke-width: 1.75; + stroke-linecap: round; +} + .structured-config-trigger:hover, .structured-config-trigger[aria-expanded="true"] { color: var(--ink); @@ -2208,26 +2215,6 @@ input.leaf-rename:focus { outline-offset: 2px; } -.structured-config-summary { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.structured-config-caret { - width: 6px; - height: 6px; - flex: 0 0 auto; - border-right: 1.5px solid currentColor; - border-bottom: 1.5px solid currentColor; - transform: translateY(-1px) rotate(45deg); -} - -.structured-config-trigger[aria-expanded="true"] .structured-config-caret { - transform: translateY(2px) rotate(225deg); -} - .structured-config-menu { position: absolute; z-index: 20; @@ -2321,6 +2308,41 @@ input.leaf-rename:focus { min-width: 0; } +.structured-activity { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: var(--sp-2); + min-height: 30px; + padding: var(--sp-1) var(--sp-3); + border-top: 1px solid var(--border); + color: var(--muted); + background: var(--surface-2); + font-size: var(--fs-11); + font-variant-numeric: tabular-nums; +} + +.structured-activity[hidden] { + display: none; +} + +.structured-activity-pulse { + position: relative; + width: 7px; + height: 7px; + flex: 0 0 auto; + border-radius: 50%; + background: var(--live); + box-shadow: 0 0 0 0 var(--live-glow); + animation: structured-thinking 1.6s ease-out infinite; +} + +@keyframes structured-thinking { + 50% { + box-shadow: 0 0 0 5px transparent; + } +} + /* every transcript row carries its own layout; none of them are cards */ .st-row { display: flex; @@ -2387,6 +2409,40 @@ input.leaf-rename:focus { font-size: var(--fs-12); } +.st-turn-meta { + align-self: flex-start; + display: flex; + align-items: center; + gap: var(--sp-2); + margin: 0 var(--sp-3) var(--sp-2); + padding: var(--sp-1) var(--sp-2); + border-radius: var(--r-1); + color: var(--faint); + font-size: var(--fs-11); + font-variant-numeric: tabular-nums; +} + +.st-turn-meta:hover { + background: var(--surface-2); + color: var(--muted); +} + +.st-turn-meta-dot { + width: 5px; + height: 5px; + flex: 0 0 auto; + border-radius: 50%; + background: var(--live); +} + +.st-turn-meta-cancelled .st-turn-meta-dot { + background: var(--warn); +} + +.st-turn-meta-failed .st-turn-meta-dot { + background: var(--danger); +} + .st-copyable { position: relative; border-radius: var(--r-1); @@ -2806,12 +2862,8 @@ input.leaf-rename:focus { } @media (max-width: 520px) { - .structured-config { - flex-basis: 132px; - } - .structured-config-menu { - right: calc(-1 * (var(--control-h) + var(--sp-3))); + right: calc(-1 * (var(--control-h) + var(--sp-2))); } } @@ -2912,6 +2964,10 @@ input.leaf-rename:focus { .st-tool-group-glyph { transition-duration: 0.01ms; } + + .structured-activity-pulse { + animation: none; + } } @media (hover: none) { diff --git a/web/src/types.ts b/web/src/types.ts index 4acbdc6..e135ce0 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -422,6 +422,8 @@ export interface TurnState { responding: boolean; thinking: boolean; active_tool_calls: string[]; + started_at?: string | null; + started_context_tokens?: number | null; } /** A whole-session view derived from ACP notifications. `plan`/`usage`/ @@ -571,6 +573,17 @@ export interface AcpUsage { cost?: { amount: number; currency: string } | null; } +/** Daemon-owned durable footer appended when one prompt turn settles. ACP + * only reports context occupancy, so `contextDelta` is not a billing-token + * count and may be negative when the provider compacts the context. */ +export interface TurnMeta { + elapsedMs: number; + contextTokens?: number | null; + contextDelta?: number | null; + outcome: "completed" | "cancelled" | "failed" | string; + model?: string | null; +} + export interface AcpPermissionOption { optionId: string; name: string;