Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain
parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
`## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script.

## [Unreleased]

### TUI

- **In-flight tool rows show elapsed time.** Ordinary pending calls (MCP,
search, shell) tick a live clock the same way Task rows already do, so a
slow-but-alive call is distinguishable from a hung turn.

- **The stall notice comes down the moment activity resumes.** It is a live
diagnosis, not a sticky banner: a tool finishing or the turn settling
clears it on that paint, even if the monitor tick has already been
cancelled.

## [0.2.102] - 2026-08-22

### Permissions
Expand Down
8 changes: 7 additions & 1 deletion docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,9 @@ instant a tool batch resolves and `awaitingResponse` flips back to true).
That wait has no signal to tell "still coming" from "never coming" apart, so
it is never auto-aborted no matter how long it runs; it still surfaces via
the notice, keeping the operator in control of whether to give up on it.
The notice is a live diagnosis, not a sticky banner: it comes down on the
same paint as the activity that ends the silence, including when the turn
settles before the next monitor tick.

An idle session animates nothing at all: the monitor tick stops entirely
rather than repainting an unchanging frame.
Expand Down Expand Up @@ -211,7 +214,10 @@ operator-preferred Amp/Codex-style lines:

`runtime-bridge` paints each `task` call as a stream row and rewrites it in
place via `syncAgentProgress` / `agentProgress` (elapsed clock, current tool,
stall marker). There is no standing FLEET board and no dual-rail agents chrome:
stall marker). Ordinary in-flight tool rows get the same elapsed clock
(`syncToolElapsed`) without the current-tool suffix, so a slow MCP or
network call is distinguishable from a hung turn. There is no standing
FLEET board and no dual-rail agents chrome:
`formatChromeZones` always returns both zones null (`task` and `agents`), and
geometry is stack-only (`layoutMode: "stack"`, `railWidth: 0`). Checklist and
agents strips are parked pending rebuild; Alt+T / direct `setChromeZones` may
Expand Down
95 changes: 95 additions & 0 deletions src/tui/runtime-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -944,6 +944,101 @@ describe("syncAgentProgress", () => {
})
})

describe("in-flight tool row elapsed time", () => {
test("an ordinary pending call's row grows a live clock, then loses it to the answer", async () => {
await withTestRenderer(
async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 80, rows: 24 },
wireKeys: false,
run: "busy",
})
let nowMs = 0
let tick: (() => void) | undefined
const bridge = attachSessionBridge(shell, createRecordingPort(), {
now: () => nowMs,
schedule: (fn) => {
tick = fn
return () => {
tick = undefined
}
},
})
try {
bridge.handle({ type: "inference.start", data: {} })
bridge.handle({
type: "inference.tool_call.end",
data: { name: "run_shell", callId: "c1", arguments: "sleep 30" },
})
const index = streamRowCount(shell) - 1
expect(shell.streamLog[index]!.stat).toBeUndefined()

nowMs = 65_000
tick?.()
expect(shell.streamLog[index]!.stat).toBe("1:05")

bridge.handle({
type: "tool.done",
data: { result: { callId: "c1", name: "run_shell", content: "ok", isError: false } },
})
// The elapsed clock was scaffolding for the wait, not a fact worth
// keeping — the answer's own addendum takes the row over.
expect(shell.streamLog[index]!.stat).not.toBe("1:05")
} finally {
bridge.dispose()
shell.dispose()
}
},
{ width: 80, height: 24 },
)
})

test("a diff call keeps its own +/- stat instead of an elapsed clock", async () => {
await withTestRenderer(
async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 80, rows: 24 },
wireKeys: false,
run: "busy",
})
let nowMs = 0
let tick: (() => void) | undefined
const bridge = attachSessionBridge(shell, createRecordingPort(), {
now: () => nowMs,
schedule: (fn) => {
tick = fn
return () => {
tick = undefined
}
},
})
try {
bridge.handle({ type: "inference.start", data: {} })
bridge.handle({
type: "inference.tool_call.end",
data: {
name: "write_file",
callId: "c1",
arguments: JSON.stringify({ path: "a.txt", content: "hi\n" }),
},
})
const index = streamRowCount(shell) - 1
const before = shell.streamLog[index]!.stat
expect(before).toContain("+")

nowMs = 65_000
tick?.()
expect(shell.streamLog[index]!.stat).toBe(before)
} finally {
bridge.dispose()
shell.dispose()
}
},
{ width: 80, height: 24 },
)
})
})

describe("task checklist calls stay out of the transcript", () => {
test("a manage_tasks call and its result paint no rows", async () => {
await withTestRenderer(
Expand Down
81 changes: 72 additions & 9 deletions src/tui/runtime-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import type { StreamRow } from "./stream.js"
import { advanceRevealChars, flattenReasoningText, type Thought } from "./thinking.js"
import {
agentProgress,
clockLabel,
fleetProgress,
type AgentProgressSession,
} from "./agent-progress.js"
Expand Down Expand Up @@ -356,6 +357,12 @@ type BridgeBag = {
now: () => number
/** Transcript row each in-flight call occupies, so its result can resolve it. */
toolRows: Map<string, number>
/**
* When each in-flight ordinary tool call started, so its row can carry a
* live elapsed clock instead of sitting on a static pending mark for the
* length of a slow call — the one case a healthy turn reads as dead.
*/
toolCallStartedAt: Map<string, number>
/** Row of the newest in-flight call, for results that carry no call id. */
lastToolRow: number
/**
Expand Down Expand Up @@ -564,7 +571,15 @@ function applyToolCall(
} else {
appendStreamRow(shell, row)
}
if (event.callId !== undefined) bag.toolRows.set(event.callId, index)
if (event.callId !== undefined) {
bag.toolRows.set(event.callId, index)
// A diff call's row already carries a "+n/-n" stat — that is the fact
// worth keeping, not an elapsed clock, so only ordinary calls (no stat of
// their own) pick up the live timer.
if (row.stat === undefined) {
bag.toolCallStartedAt.set(event.callId, bag.now())
}
}
if (event.callId !== undefined && event.name === TASK_TOOL_NAME) {
bag.taskCallIds.add(event.callId)
}
Expand All @@ -590,13 +605,20 @@ function applyToolResult(
})
const tracked =
event.callId !== undefined ? bag.toolRows.get(event.callId) : undefined
// The elapsed clock was scaffolding for the wait, not a fact about the
// call — clear it before the merge so it never crowds out the answer's own
// addendum (e.g. "3 lines") the way a diff's own +/- count is allowed to.
const clockOwned =
event.callId !== undefined && bag.toolCallStartedAt.has(event.callId)
if (event.callId !== undefined) {
bag.toolRows.delete(event.callId)
bag.toolCallStartedAt.delete(event.callId)
bag.taskCallIds.delete(event.callId)
}
if (bag.toolRows.size === 0) shell.inFlightTool = null
const index = tracked ?? bag.lastToolRow
const call = streamRowAt(shell, index)
const rawCall = streamRowAt(shell, index)
const call = clockOwned && rawCall !== undefined ? omitStat(rawCall) : rawCall
if (call === undefined || call.pending !== true) {
appendStreamRow(shell, result)
return
Expand Down Expand Up @@ -642,6 +664,40 @@ function syncAgentProgress(
}
}

/** Drop `stat` entirely rather than set it `undefined` (exactOptionalPropertyTypes). */
function omitStat(row: StreamRow): StreamRow {
const { stat: _stat, ...rest } = row
return rest
}

/**
* Refresh every plain in-flight tool call's row with how long it has been
* running. A `task` dispatch already gets this (and more) from
* `syncAgentProgress`, so those calls are skipped here rather than double
* painted. Without a live clock an ordinary call's row sits on a static
* pending mark for however long the tool takes — indistinguishable from a
* hung turn once that stretches past a few seconds.
*/
function syncToolElapsed(shell: AppShell, bag: BridgeBag, nowMs: number): void {
if (bag.toolCallStartedAt.size === 0) return
for (const [callId, startedAt] of bag.toolCallStartedAt) {
if (bag.taskCallIds.has(callId)) continue
const index = bag.toolRows.get(callId)
if (index === undefined) {
bag.toolCallStartedAt.delete(callId)
continue
}
const row = streamRowAt(shell, index)
if (row === undefined || row.pending !== true) {
bag.toolCallStartedAt.delete(callId)
continue
}
const stat = clockLabel(nowMs - startedAt)
if (row.stat === stat) continue
replaceStreamRowAt(shell, index, { ...row, stat })
}
}

/**
* Retract everything the failed attempt painted, then forget the row
* bookkeeping that pointed into it — a rolled-back tool call has no row left
Expand All @@ -655,6 +711,7 @@ function rollbackAttempt(shell: AppShell, bag: BridgeBag): void {
for (const [callId, index] of [...bag.toolRows]) {
if (index >= boundary) {
bag.toolRows.delete(callId)
bag.toolCallStartedAt.delete(callId)
bag.taskCallIds.delete(callId)
}
}
Expand Down Expand Up @@ -802,6 +859,7 @@ export function attachSessionBridge(
quotaFired: false,
now,
toolRows: new Map(),
toolCallStartedAt: new Map(),
lastToolRow: -1,
taskCallIds: new Set(),
agentSessions: [],
Expand Down Expand Up @@ -869,6 +927,17 @@ export function attachSessionBridge(

const paintPhaseAt = (nowMs: number, isStalled: boolean): void => {
const turn = bag.turn
// The stall notice is a live diagnosis, not a sticky banner: it has to
// set *and* clear on every paint — including handle() — because the
// cadence timer is cancelled the moment the turn settles. If we only
// touched it from tick(), a tool.done → inference.done burst that lands
// before the next tick would leave the banner up forever.
const level = stallLevel(stallArgsFor(nowMs))
if (level === "notice") {
setStatusFlash(shell, STALL_NOTICE_MESSAGE)
} else if (shell.statusFlash === STALL_NOTICE_MESSAGE) {
setStatusFlash(shell, null)
}
// The landing mark rides this same re-entry: it animates through the
// draw/fill loop while a turn is live and holds its filled frame otherwise.
paintLanding(shell, nowMs, turn.isProcessing)
Expand All @@ -878,6 +947,7 @@ export function attachSessionBridge(
if (bag.openRow !== null && bag.openRow.kind === "thinking") {
advanceOpenReveal(shell, bag.openRow, nowMs)
}
syncToolElapsed(shell, bag, nowMs)
const input = {
isProcessing: turn.isProcessing,
status: turn.status,
Expand Down Expand Up @@ -1157,13 +1227,6 @@ export function attachSessionBridge(
return
}

// Notice only — the phase still paints below, because a ramp that stops
// moving is the very thing that reads as a hang.
const level = stallLevel(stallArgs)
if (level === "notice") {
setStatusFlash(shell, STALL_NOTICE_MESSAGE)
}

// Same "is this stalled at all" question `paintPhase` asks above — call
// the one definition (`isStalledForDisplay`) rather than re-deriving it
// from `stallLevel`'s result, so the two call sites can never disagree.
Expand Down
43 changes: 43 additions & 0 deletions src/tui/turn-monitor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,49 @@ describe("stall watchdog", () => {
})
})

test("clears the notice once activity resumes, rather than leaving it up", async () => {
await withTestRenderer(async (h) => {
const t: Harness = await setup(h)
try {
t.bridge.submit("build it", "immediate")
t.port.clear()

t.advance(500)
t.tick()
expect(t.shell.statusFlash).toBe(STALL_NOTICE_MESSAGE)

// The model starts producing again — the notice must not linger past
// the silence it was reporting. handle() itself has to take it down;
// waiting for the next tick leaves a window where the turn can settle
// and cancel the cadence, which would strand the banner forever.
t.bridge.handle({ type: "inference.text.delta", data: { token: "ok" } })
expect(t.shell.statusFlash).not.toBe(STALL_NOTICE_MESSAGE)
} finally {
t.bridge.dispose()
}
})
})

test("clears the notice when the turn settles before the next tick", async () => {
await withTestRenderer(async (h) => {
const t: Harness = await setup(h)
try {
t.bridge.submit("build it", "immediate")
t.port.clear()

t.advance(500)
t.tick()
expect(t.shell.statusFlash).toBe(STALL_NOTICE_MESSAGE)

t.bridge.handle({ type: "inference.done", data: {} })
// Cadence is cancelled on settle. The notice has to already be gone.
expect(t.shell.statusFlash).not.toBe(STALL_NOTICE_MESSAGE)
} finally {
t.bridge.dispose()
}
})
})

test("aborts and flashes once a mid-stream hang crosses the stall timeout", async () => {
await withTestRenderer(async (h) => {
const t: Harness = await setup(h)
Expand Down
Loading