pantheon-opencode 1.5.0-beta.5 → 1.5.0-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -959,6 +959,7 @@ export type DelegationEntry = {
959
959
  agent: string
960
960
  state:
961
961
  | 'running'
962
+ | 'retry'
962
963
  | 'completed'
963
964
  | 'error'
964
965
  | 'startup_failed'
@@ -1195,27 +1196,46 @@ export function tuiLogPath(projectRoot: string): string {
1195
1196
  * falling back to startedAt, descending). Shared by the md reader and
1196
1197
  * mergeDelegationSources. */
1197
1198
  export function compareDelegationEntries(a: DelegationEntry, b: DelegationEntry): number {
1198
- const aRun = a.state === 'running' || a.state === 'stale-running' ? 1 : 0
1199
- const bRun = b.state === 'running' || b.state === 'stale-running' ? 1 : 0
1199
+ const isActive = (st: DelegationEntry['state']) =>
1200
+ st === 'running' || st === 'retry' || st === 'stale-running' ? 1 : 0
1201
+ const aRun = isActive(a.state)
1202
+ const bRun = isActive(b.state)
1200
1203
  if (aRun !== bRun) return bRun - aRun
1201
1204
  return (b.updatedAt ?? b.startedAt) - (a.updatedAt ?? a.startedAt)
1202
1205
  }
1203
1206
 
1204
- /** The list the panel actually renders: running jobs first, then the most
1205
- * recent terminal reports (capped). Pure — so the history-only panel (no
1206
- * sessionID) is testable without the TUI runtime. The header count uses the
1207
- * same "running + recentes" list. */
1207
+ /** Split the panel list: active jobs (running/retry, stale-marked) first,
1208
+ * then the most recent terminal reports, then the archived tail (paginated
1209
+ * in the View). Pure — so the history-only panel (no sessionID) is testable
1210
+ * without the TUI runtime. */
1211
+ export function splitDelegationList(
1212
+ all: readonly DelegationEntry[],
1213
+ maxRecent = 8,
1214
+ now = Date.now(),
1215
+ staleThresholdMs = 30 * 60 * 1000, // 30 minutes
1216
+ ): { active: DelegationEntry[]; recent: DelegationEntry[]; archived: DelegationEntry[] } {
1217
+ const active = all
1218
+ .filter((d) => d.state === 'running' || d.state === 'retry')
1219
+ .map((d) => markStaleIfRunning(d, now, staleThresholdMs))
1220
+ const terminal = all.filter((d) => d.state !== 'running' && d.state !== 'retry')
1221
+ return {
1222
+ active,
1223
+ recent: terminal.slice(0, maxRecent),
1224
+ archived: terminal.slice(maxRecent),
1225
+ }
1226
+ }
1227
+
1228
+ /** The list the panel actually renders (kept for the header count and
1229
+ * existing tests): active jobs first, then the most recent terminal
1230
+ * reports (capped) — the archived tail is rendered separately. Pure. */
1208
1231
  export function visibleDelegationList(
1209
1232
  all: readonly DelegationEntry[],
1210
1233
  maxTerminal = 8,
1211
1234
  now = Date.now(),
1212
1235
  staleThresholdMs = 30 * 60 * 1000, // 30 minutes
1213
1236
  ): DelegationEntry[] {
1214
- const running = all
1215
- .filter((d) => d.state === 'running')
1216
- .map((d) => markStaleIfRunning(d, now, staleThresholdMs))
1217
- const terminal = all.filter((d) => d.state !== 'running').slice(0, maxTerminal)
1218
- return [...running, ...terminal]
1237
+ const { active, recent } = splitDelegationList(all, maxTerminal, now, staleThresholdMs)
1238
+ return [...active, ...recent]
1219
1239
  }
1220
1240
 
1221
1241
  /** Default stale-running threshold: 30 minutes. */
@@ -1294,6 +1314,7 @@ export function delegationActivity(entry: DelegationEntry): DelegationActivity {
1294
1314
  }
1295
1315
 
1296
1316
  export function delegationActivityLabel(entry: DelegationEntry): string {
1317
+ if (entry.state === 'retry') return 'RETRYING'
1297
1318
  switch (delegationActivity(entry)) {
1298
1319
  case 'delegating':
1299
1320
  return 'DELEGATING'
@@ -1318,6 +1339,89 @@ export function delegationSpinnerFrame(now: number): string {
1318
1339
  return DELEGATION_SPINNER_FRAMES[index] ?? DELEGATION_SPINNER_FRAMES[0]
1319
1340
  }
1320
1341
 
1342
+ /* ─── Live tool activity per child session (beta.6) ────────────────────────
1343
+ * The delegation panel's activity line ("↳ bash npm test") — what the agent
1344
+ * is doing RIGHT NOW. Sourced from message.part.updated tool parts: every
1345
+ * pending/running tool part updates a per-child-session record (latest wins).
1346
+ * Module-level by design: the sidebar remounts on route changes and the
1347
+ * "what is each agent doing" state should survive that (OMO pattern). */
1348
+
1349
+ export type ToolActivity = {
1350
+ tool: string
1351
+ summary: string
1352
+ at: number
1353
+ }
1354
+
1355
+ /** Bounded tracker: child session id → latest running tool call. */
1356
+ const latestToolActivity = new Map<string, ToolActivity>()
1357
+ const MAX_TOOL_ACTIVITY_ENTRIES = 200
1358
+ const TOOL_ACTIVITY_TTL_MS = 5 * 60 * 1000
1359
+
1360
+ /** Archived-section pagination state — module-level so it survives sidebar
1361
+ * remounts (route changes) without leaking into the component tree. */
1362
+ const archivedUiState = { page: 0 }
1363
+
1364
+ /** First usable summary string from common tool input fields. */
1365
+ function summarizeToolInput(input: Record<string, unknown> | undefined): string {
1366
+ if (!input) return ''
1367
+ const candidates = ['command', 'description', 'prompt', 'filePath', 'pattern', 'url', 'query']
1368
+ for (const key of candidates) {
1369
+ const value = input[key]
1370
+ if (typeof value === 'string' && value.trim() !== '') return value
1371
+ }
1372
+ return ''
1373
+ }
1374
+
1375
+ /** Reduce one message.part.updated tool part to displayable activity.
1376
+ * Returns null for non-tool parts, completed/error parts (no live activity)
1377
+ * or parts without a session id. Pure. */
1378
+ export function extractToolActivity(
1379
+ part: {
1380
+ type?: string
1381
+ tool?: string
1382
+ sessionID?: string
1383
+ state?: { status?: string; input?: Record<string, unknown> }
1384
+ },
1385
+ now = Date.now(),
1386
+ ): { sessionID: string; activity: ToolActivity } | null {
1387
+ if (part?.type !== 'tool') return null
1388
+ const status = part.state?.status
1389
+ if (status !== 'pending' && status !== 'running') return null
1390
+ const sessionID = typeof part.sessionID === 'string' ? part.sessionID : ''
1391
+ if (sessionID === '') return null
1392
+ const tool = typeof part.tool === 'string' && part.tool !== '' ? part.tool : 'tool'
1393
+ const summary = summarizeToolInput(part.state?.input).replace(/\s+/g, ' ').trim().slice(0, 48)
1394
+ return { sessionID, activity: { tool, summary, at: now } }
1395
+ }
1396
+
1397
+ /** Record an activity sample (bounded map, oldest dropped). */
1398
+ export function trackToolActivity(
1399
+ map: Map<string, ToolActivity>,
1400
+ sessionID: string,
1401
+ activity: ToolActivity,
1402
+ ): void {
1403
+ if (sessionID === '') return
1404
+ if (map.size >= MAX_TOOL_ACTIVITY_ENTRIES) {
1405
+ const oldest = map.keys().next().value
1406
+ if (oldest !== undefined) map.delete(oldest)
1407
+ }
1408
+ map.set(sessionID, activity)
1409
+ }
1410
+
1411
+ /** Latest live activity for a child session, or null when absent/stale. */
1412
+ export function latestToolActivityFor(
1413
+ map: Map<string, ToolActivity>,
1414
+ sessionID: string | undefined,
1415
+ now = Date.now(),
1416
+ ttlMs = TOOL_ACTIVITY_TTL_MS,
1417
+ ): ToolActivity | null {
1418
+ if (!sessionID) return null
1419
+ const hit = map.get(sessionID)
1420
+ if (hit === undefined) return null
1421
+ if (now - hit.at > ttlMs) return null
1422
+ return hit
1423
+ }
1424
+
1321
1425
  /** Merge the immediate tool-event channel into the child-session channel.
1322
1426
  *
1323
1427
  * Children remain the durable source, while live entries make a delegation
@@ -1880,9 +1984,10 @@ export type ChildDelegationLike = {
1880
1984
  * (the child is actively working), idle → completed, unknown → running
1881
1985
  * (fail-open: a freshly-seen child is assumed active; the 1s poll + md
1882
1986
  * correct it as soon as terminal data exists). */
1883
- export function childStatusToState(status: string | undefined): 'running' | 'completed' {
1987
+ export function childStatusToState(status: string | undefined): 'running' | 'completed' | 'retry' {
1884
1988
  if (status === 'idle') return 'completed'
1885
- return 'running' // busy, retry, or unknown
1989
+ if (status === 'retry') return 'retry'
1990
+ return 'running' // busy or unknown
1886
1991
  }
1887
1992
 
1888
1993
  /** Row tag for a delegation entry: `[native]` for native task() children
@@ -2070,6 +2175,8 @@ function DelegationRow(props: {
2070
2175
  switch (props.job.state) {
2071
2176
  case 'running':
2072
2177
  return t.warning
2178
+ case 'retry':
2179
+ return t.warning
2073
2180
  case 'stale-running':
2074
2181
  return t.error // warning color for stale delegations
2075
2182
  case 'completed':
@@ -2083,6 +2190,7 @@ function DelegationRow(props: {
2083
2190
 
2084
2191
  const marker = createMemo(() => {
2085
2192
  if (props.job.state === 'running') return `${delegationSpinnerFrame(props.animationNow)} `
2193
+ if (props.job.state === 'retry') return '\u27f3 ' // ⟳ retrying
2086
2194
  if (props.job.state === 'stale-running') return '\u26a0 ' // warning triangle
2087
2195
  switch (props.job.state) {
2088
2196
  case 'completed':
@@ -2106,6 +2214,12 @@ function DelegationRow(props: {
2106
2214
  return line.length > 180 ? `${line.slice(0, 177)}\u2026` : line
2107
2215
  })
2108
2216
 
2217
+ // beta.6: live tool call the child is running RIGHT NOW ("↳ bash npm test"),
2218
+ // fed by message.part.updated activity tracking. Null while idle/done.
2219
+ const activity = createMemo(() =>
2220
+ latestToolActivityFor(latestToolActivity, props.job.taskID, props.now),
2221
+ )
2222
+
2109
2223
  // Click a row → navigate to the child session (route API guarded in the
2110
2224
  // helper). Enter-key binding is left out (per-row keymap wiring is not
2111
2225
  // worth the complexity in this sidebar); mouse is enabled in tui.json.
@@ -2123,6 +2237,13 @@ function DelegationRow(props: {
2123
2237
  >{` ${props.job.agent} \u2014 ${delegationActivityLabel(props.job)}`}</text>
2124
2238
  </box>
2125
2239
  <text fg={theme().textMuted}>{detail()}</text>
2240
+ <Show when={activity()}>
2241
+ {(a) => (
2242
+ <text
2243
+ fg={theme().textMuted}
2244
+ >{` \u21b3 ${a().tool}${a().summary !== '' ? ' ' + a().summary : ''}`}</text>
2245
+ )}
2246
+ </Show>
2126
2247
  </box>
2127
2248
  )
2128
2249
  }
@@ -2335,9 +2456,20 @@ function View(props: {
2335
2456
  })()
2336
2457
  return delegationsInflight
2337
2458
  }
2338
- // Running jobs first, then the 8 most recent terminal reports. The header
2339
- // count uses this same "running + recentes" list.
2340
- const visibleDelegations = createMemo(() => visibleDelegationList(childDelegations()))
2459
+ // beta.6: active (running/retry) first, then the 8 most recent terminal
2460
+ // reports, then the archived tail (paginated). Pagination state lives at
2461
+ // module level so sidebar remounts keep the user's page.
2462
+ const delegationSplit = createMemo(() => splitDelegationList(childDelegations()))
2463
+ const visibleDelegations = createMemo(() => [
2464
+ ...delegationSplit().active,
2465
+ ...delegationSplit().recent,
2466
+ ])
2467
+ const [archivedPage, setArchivedPage] = createSignal(archivedUiState.page)
2468
+ const clampArchivedPage = (page: number, pages: number) => {
2469
+ const clamped = Math.max(0, Math.min(page, Math.max(0, pages - 1)))
2470
+ archivedUiState.page = clamped
2471
+ setArchivedPage(clamped)
2472
+ }
2341
2473
 
2342
2474
  // ── Event subscriptions for live session/delegation updates ──
2343
2475
  onMount(() => {
@@ -2504,6 +2636,41 @@ function View(props: {
2504
2636
  />
2505
2637
  )}
2506
2638
  </For>
2639
+ <Show when={delegationSplit().archived.length > 0}>
2640
+ {(() => {
2641
+ const archived = delegationSplit().archived
2642
+ const pageSize = 8
2643
+ const pages = Math.ceil(archived.length / pageSize)
2644
+ const page = Math.min(archivedPage(), pages - 1)
2645
+ const slice = archived.slice(page * pageSize, page * pageSize + pageSize)
2646
+ return (
2647
+ <box flexDirection="column">
2648
+ <box flexDirection="row" marginLeft={0}>
2649
+ <text
2650
+ fg={theme().textMuted}
2651
+ onMouseDown={() => clampArchivedPage(page - 1, pages)}
2652
+ >{`\u2039 `}</text>
2653
+ <text fg={theme().textMuted}>{`Archived (${archived.length}) `}</text>
2654
+ <text fg={theme().textMuted}>{`${page + 1}/${pages}`}</text>
2655
+ <text
2656
+ fg={theme().textMuted}
2657
+ onMouseDown={() => clampArchivedPage(page + 1, pages)}
2658
+ >{` \u203a`}</text>
2659
+ </box>
2660
+ <For each={slice}>
2661
+ {(job) => (
2662
+ <DelegationRow
2663
+ api={props.api}
2664
+ job={job}
2665
+ now={now()}
2666
+ animationNow={animationNow()}
2667
+ />
2668
+ )}
2669
+ </For>
2670
+ </box>
2671
+ )
2672
+ })()}
2673
+ </Show>
2507
2674
  </box>
2508
2675
  </Show>
2509
2676
  </Show>
@@ -2552,6 +2719,11 @@ const tui: TuiPlugin = (api, _options, _meta) => {
2552
2719
  const props = (event?.properties ?? {}) as { part?: unknown; info?: { part?: unknown } }
2553
2720
  const part = (props.part ?? props.info?.part) as DelegationToolPart | undefined
2554
2721
  if (part === undefined) return
2722
+ // beta.6: track the live tool call per child session (panel's
2723
+ // activity line). Fire-and-forget — display-only state.
2724
+ const sample = extractToolActivity(part as any)
2725
+ if (sample !== null)
2726
+ trackToolActivity(latestToolActivity, sample.sessionID, sample.activity)
2555
2727
  if (reduceDelegationToolPart(liveStore.map, part)) liveStore.bump()
2556
2728
  }),
2557
2729
  )
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pantheon-tui",
3
- "version": "1.5.0-beta.5",
3
+ "version": "1.5.0-beta.7",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pantheon-tui",
9
- "version": "1.5.0-beta.5",
9
+ "version": "1.5.0-beta.7",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@opencode-ai/plugin": ">=1.14.0",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pantheon-tui",
3
3
  "type": "module",
4
- "version": "1.5.0-beta.5",
4
+ "version": "1.5.0-beta.7",
5
5
  "description": "Pantheon TUI plugin — sidebar (version, preset, sessions, real-time delegations) + AI subscription usage gauges incl. OpenCode Go/Zen dollar limits (vendored, MIT)",
6
6
  "license": "MIT",
7
7
  "exports": {
@@ -959,6 +959,7 @@ export type DelegationEntry = {
959
959
  agent: string
960
960
  state:
961
961
  | 'running'
962
+ | 'retry'
962
963
  | 'completed'
963
964
  | 'error'
964
965
  | 'startup_failed'
@@ -1195,27 +1196,46 @@ export function tuiLogPath(projectRoot: string): string {
1195
1196
  * falling back to startedAt, descending). Shared by the md reader and
1196
1197
  * mergeDelegationSources. */
1197
1198
  export function compareDelegationEntries(a: DelegationEntry, b: DelegationEntry): number {
1198
- const aRun = a.state === 'running' || a.state === 'stale-running' ? 1 : 0
1199
- const bRun = b.state === 'running' || b.state === 'stale-running' ? 1 : 0
1199
+ const isActive = (st: DelegationEntry['state']) =>
1200
+ st === 'running' || st === 'retry' || st === 'stale-running' ? 1 : 0
1201
+ const aRun = isActive(a.state)
1202
+ const bRun = isActive(b.state)
1200
1203
  if (aRun !== bRun) return bRun - aRun
1201
1204
  return (b.updatedAt ?? b.startedAt) - (a.updatedAt ?? a.startedAt)
1202
1205
  }
1203
1206
 
1204
- /** The list the panel actually renders: running jobs first, then the most
1205
- * recent terminal reports (capped). Pure — so the history-only panel (no
1206
- * sessionID) is testable without the TUI runtime. The header count uses the
1207
- * same "running + recentes" list. */
1207
+ /** Split the panel list: active jobs (running/retry, stale-marked) first,
1208
+ * then the most recent terminal reports, then the archived tail (paginated
1209
+ * in the View). Pure — so the history-only panel (no sessionID) is testable
1210
+ * without the TUI runtime. */
1211
+ export function splitDelegationList(
1212
+ all: readonly DelegationEntry[],
1213
+ maxRecent = 8,
1214
+ now = Date.now(),
1215
+ staleThresholdMs = 30 * 60 * 1000, // 30 minutes
1216
+ ): { active: DelegationEntry[]; recent: DelegationEntry[]; archived: DelegationEntry[] } {
1217
+ const active = all
1218
+ .filter((d) => d.state === 'running' || d.state === 'retry')
1219
+ .map((d) => markStaleIfRunning(d, now, staleThresholdMs))
1220
+ const terminal = all.filter((d) => d.state !== 'running' && d.state !== 'retry')
1221
+ return {
1222
+ active,
1223
+ recent: terminal.slice(0, maxRecent),
1224
+ archived: terminal.slice(maxRecent),
1225
+ }
1226
+ }
1227
+
1228
+ /** The list the panel actually renders (kept for the header count and
1229
+ * existing tests): active jobs first, then the most recent terminal
1230
+ * reports (capped) — the archived tail is rendered separately. Pure. */
1208
1231
  export function visibleDelegationList(
1209
1232
  all: readonly DelegationEntry[],
1210
1233
  maxTerminal = 8,
1211
1234
  now = Date.now(),
1212
1235
  staleThresholdMs = 30 * 60 * 1000, // 30 minutes
1213
1236
  ): DelegationEntry[] {
1214
- const running = all
1215
- .filter((d) => d.state === 'running')
1216
- .map((d) => markStaleIfRunning(d, now, staleThresholdMs))
1217
- const terminal = all.filter((d) => d.state !== 'running').slice(0, maxTerminal)
1218
- return [...running, ...terminal]
1237
+ const { active, recent } = splitDelegationList(all, maxTerminal, now, staleThresholdMs)
1238
+ return [...active, ...recent]
1219
1239
  }
1220
1240
 
1221
1241
  /** Default stale-running threshold: 30 minutes. */
@@ -1294,6 +1314,7 @@ export function delegationActivity(entry: DelegationEntry): DelegationActivity {
1294
1314
  }
1295
1315
 
1296
1316
  export function delegationActivityLabel(entry: DelegationEntry): string {
1317
+ if (entry.state === 'retry') return 'RETRYING'
1297
1318
  switch (delegationActivity(entry)) {
1298
1319
  case 'delegating':
1299
1320
  return 'DELEGATING'
@@ -1318,6 +1339,89 @@ export function delegationSpinnerFrame(now: number): string {
1318
1339
  return DELEGATION_SPINNER_FRAMES[index] ?? DELEGATION_SPINNER_FRAMES[0]
1319
1340
  }
1320
1341
 
1342
+ /* ─── Live tool activity per child session (beta.6) ────────────────────────
1343
+ * The delegation panel's activity line ("↳ bash npm test") — what the agent
1344
+ * is doing RIGHT NOW. Sourced from message.part.updated tool parts: every
1345
+ * pending/running tool part updates a per-child-session record (latest wins).
1346
+ * Module-level by design: the sidebar remounts on route changes and the
1347
+ * "what is each agent doing" state should survive that (OMO pattern). */
1348
+
1349
+ export type ToolActivity = {
1350
+ tool: string
1351
+ summary: string
1352
+ at: number
1353
+ }
1354
+
1355
+ /** Bounded tracker: child session id → latest running tool call. */
1356
+ const latestToolActivity = new Map<string, ToolActivity>()
1357
+ const MAX_TOOL_ACTIVITY_ENTRIES = 200
1358
+ const TOOL_ACTIVITY_TTL_MS = 5 * 60 * 1000
1359
+
1360
+ /** Archived-section pagination state — module-level so it survives sidebar
1361
+ * remounts (route changes) without leaking into the component tree. */
1362
+ const archivedUiState = { page: 0 }
1363
+
1364
+ /** First usable summary string from common tool input fields. */
1365
+ function summarizeToolInput(input: Record<string, unknown> | undefined): string {
1366
+ if (!input) return ''
1367
+ const candidates = ['command', 'description', 'prompt', 'filePath', 'pattern', 'url', 'query']
1368
+ for (const key of candidates) {
1369
+ const value = input[key]
1370
+ if (typeof value === 'string' && value.trim() !== '') return value
1371
+ }
1372
+ return ''
1373
+ }
1374
+
1375
+ /** Reduce one message.part.updated tool part to displayable activity.
1376
+ * Returns null for non-tool parts, completed/error parts (no live activity)
1377
+ * or parts without a session id. Pure. */
1378
+ export function extractToolActivity(
1379
+ part: {
1380
+ type?: string
1381
+ tool?: string
1382
+ sessionID?: string
1383
+ state?: { status?: string; input?: Record<string, unknown> }
1384
+ },
1385
+ now = Date.now(),
1386
+ ): { sessionID: string; activity: ToolActivity } | null {
1387
+ if (part?.type !== 'tool') return null
1388
+ const status = part.state?.status
1389
+ if (status !== 'pending' && status !== 'running') return null
1390
+ const sessionID = typeof part.sessionID === 'string' ? part.sessionID : ''
1391
+ if (sessionID === '') return null
1392
+ const tool = typeof part.tool === 'string' && part.tool !== '' ? part.tool : 'tool'
1393
+ const summary = summarizeToolInput(part.state?.input).replace(/\s+/g, ' ').trim().slice(0, 48)
1394
+ return { sessionID, activity: { tool, summary, at: now } }
1395
+ }
1396
+
1397
+ /** Record an activity sample (bounded map, oldest dropped). */
1398
+ export function trackToolActivity(
1399
+ map: Map<string, ToolActivity>,
1400
+ sessionID: string,
1401
+ activity: ToolActivity,
1402
+ ): void {
1403
+ if (sessionID === '') return
1404
+ if (map.size >= MAX_TOOL_ACTIVITY_ENTRIES) {
1405
+ const oldest = map.keys().next().value
1406
+ if (oldest !== undefined) map.delete(oldest)
1407
+ }
1408
+ map.set(sessionID, activity)
1409
+ }
1410
+
1411
+ /** Latest live activity for a child session, or null when absent/stale. */
1412
+ export function latestToolActivityFor(
1413
+ map: Map<string, ToolActivity>,
1414
+ sessionID: string | undefined,
1415
+ now = Date.now(),
1416
+ ttlMs = TOOL_ACTIVITY_TTL_MS,
1417
+ ): ToolActivity | null {
1418
+ if (!sessionID) return null
1419
+ const hit = map.get(sessionID)
1420
+ if (hit === undefined) return null
1421
+ if (now - hit.at > ttlMs) return null
1422
+ return hit
1423
+ }
1424
+
1321
1425
  /** Merge the immediate tool-event channel into the child-session channel.
1322
1426
  *
1323
1427
  * Children remain the durable source, while live entries make a delegation
@@ -1880,9 +1984,10 @@ export type ChildDelegationLike = {
1880
1984
  * (the child is actively working), idle → completed, unknown → running
1881
1985
  * (fail-open: a freshly-seen child is assumed active; the 1s poll + md
1882
1986
  * correct it as soon as terminal data exists). */
1883
- export function childStatusToState(status: string | undefined): 'running' | 'completed' {
1987
+ export function childStatusToState(status: string | undefined): 'running' | 'completed' | 'retry' {
1884
1988
  if (status === 'idle') return 'completed'
1885
- return 'running' // busy, retry, or unknown
1989
+ if (status === 'retry') return 'retry'
1990
+ return 'running' // busy or unknown
1886
1991
  }
1887
1992
 
1888
1993
  /** Row tag for a delegation entry: `[native]` for native task() children
@@ -2070,6 +2175,8 @@ function DelegationRow(props: {
2070
2175
  switch (props.job.state) {
2071
2176
  case 'running':
2072
2177
  return t.warning
2178
+ case 'retry':
2179
+ return t.warning
2073
2180
  case 'stale-running':
2074
2181
  return t.error // warning color for stale delegations
2075
2182
  case 'completed':
@@ -2083,6 +2190,7 @@ function DelegationRow(props: {
2083
2190
 
2084
2191
  const marker = createMemo(() => {
2085
2192
  if (props.job.state === 'running') return `${delegationSpinnerFrame(props.animationNow)} `
2193
+ if (props.job.state === 'retry') return '\u27f3 ' // ⟳ retrying
2086
2194
  if (props.job.state === 'stale-running') return '\u26a0 ' // warning triangle
2087
2195
  switch (props.job.state) {
2088
2196
  case 'completed':
@@ -2106,6 +2214,12 @@ function DelegationRow(props: {
2106
2214
  return line.length > 180 ? `${line.slice(0, 177)}\u2026` : line
2107
2215
  })
2108
2216
 
2217
+ // beta.6: live tool call the child is running RIGHT NOW ("↳ bash npm test"),
2218
+ // fed by message.part.updated activity tracking. Null while idle/done.
2219
+ const activity = createMemo(() =>
2220
+ latestToolActivityFor(latestToolActivity, props.job.taskID, props.now),
2221
+ )
2222
+
2109
2223
  // Click a row → navigate to the child session (route API guarded in the
2110
2224
  // helper). Enter-key binding is left out (per-row keymap wiring is not
2111
2225
  // worth the complexity in this sidebar); mouse is enabled in tui.json.
@@ -2123,6 +2237,13 @@ function DelegationRow(props: {
2123
2237
  >{` ${props.job.agent} \u2014 ${delegationActivityLabel(props.job)}`}</text>
2124
2238
  </box>
2125
2239
  <text fg={theme().textMuted}>{detail()}</text>
2240
+ <Show when={activity()}>
2241
+ {(a) => (
2242
+ <text
2243
+ fg={theme().textMuted}
2244
+ >{` \u21b3 ${a().tool}${a().summary !== '' ? ' ' + a().summary : ''}`}</text>
2245
+ )}
2246
+ </Show>
2126
2247
  </box>
2127
2248
  )
2128
2249
  }
@@ -2335,9 +2456,20 @@ function View(props: {
2335
2456
  })()
2336
2457
  return delegationsInflight
2337
2458
  }
2338
- // Running jobs first, then the 8 most recent terminal reports. The header
2339
- // count uses this same "running + recentes" list.
2340
- const visibleDelegations = createMemo(() => visibleDelegationList(childDelegations()))
2459
+ // beta.6: active (running/retry) first, then the 8 most recent terminal
2460
+ // reports, then the archived tail (paginated). Pagination state lives at
2461
+ // module level so sidebar remounts keep the user's page.
2462
+ const delegationSplit = createMemo(() => splitDelegationList(childDelegations()))
2463
+ const visibleDelegations = createMemo(() => [
2464
+ ...delegationSplit().active,
2465
+ ...delegationSplit().recent,
2466
+ ])
2467
+ const [archivedPage, setArchivedPage] = createSignal(archivedUiState.page)
2468
+ const clampArchivedPage = (page: number, pages: number) => {
2469
+ const clamped = Math.max(0, Math.min(page, Math.max(0, pages - 1)))
2470
+ archivedUiState.page = clamped
2471
+ setArchivedPage(clamped)
2472
+ }
2341
2473
 
2342
2474
  // ── Event subscriptions for live session/delegation updates ──
2343
2475
  onMount(() => {
@@ -2504,6 +2636,41 @@ function View(props: {
2504
2636
  />
2505
2637
  )}
2506
2638
  </For>
2639
+ <Show when={delegationSplit().archived.length > 0}>
2640
+ {(() => {
2641
+ const archived = delegationSplit().archived
2642
+ const pageSize = 8
2643
+ const pages = Math.ceil(archived.length / pageSize)
2644
+ const page = Math.min(archivedPage(), pages - 1)
2645
+ const slice = archived.slice(page * pageSize, page * pageSize + pageSize)
2646
+ return (
2647
+ <box flexDirection="column">
2648
+ <box flexDirection="row" marginLeft={0}>
2649
+ <text
2650
+ fg={theme().textMuted}
2651
+ onMouseDown={() => clampArchivedPage(page - 1, pages)}
2652
+ >{`\u2039 `}</text>
2653
+ <text fg={theme().textMuted}>{`Archived (${archived.length}) `}</text>
2654
+ <text fg={theme().textMuted}>{`${page + 1}/${pages}`}</text>
2655
+ <text
2656
+ fg={theme().textMuted}
2657
+ onMouseDown={() => clampArchivedPage(page + 1, pages)}
2658
+ >{` \u203a`}</text>
2659
+ </box>
2660
+ <For each={slice}>
2661
+ {(job) => (
2662
+ <DelegationRow
2663
+ api={props.api}
2664
+ job={job}
2665
+ now={now()}
2666
+ animationNow={animationNow()}
2667
+ />
2668
+ )}
2669
+ </For>
2670
+ </box>
2671
+ )
2672
+ })()}
2673
+ </Show>
2507
2674
  </box>
2508
2675
  </Show>
2509
2676
  </Show>
@@ -2552,6 +2719,11 @@ const tui: TuiPlugin = (api, _options, _meta) => {
2552
2719
  const props = (event?.properties ?? {}) as { part?: unknown; info?: { part?: unknown } }
2553
2720
  const part = (props.part ?? props.info?.part) as DelegationToolPart | undefined
2554
2721
  if (part === undefined) return
2722
+ // beta.6: track the live tool call per child session (panel's
2723
+ // activity line). Fire-and-forget — display-only state.
2724
+ const sample = extractToolActivity(part as any)
2725
+ if (sample !== null)
2726
+ trackToolActivity(latestToolActivity, sample.sessionID, sample.activity)
2555
2727
  if (reduceDelegationToolPart(liveStore.map, part)) liveStore.bump()
2556
2728
  }),
2557
2729
  )