pantheon-opencode 1.5.0-beta.12 → 1.5.0-beta.13

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.
@@ -900,9 +900,9 @@ async function setupUsageBar(api: TuiPluginApi) {
900
900
  * and the native `task()` tool ALSO spawns a child session with
901
901
  * parentID = caller. A child WITHOUT a board report is a native
902
902
  * task() child: childrenToDelegationEntries tags it source
903
- * 'children-only' with the `nat:` row tag (distinct info color),
903
+ * 'children-only' (distinct info color),
904
904
  * so native task() work is visible in the panel alongside board
905
- * delegates (`pan:apo-1`) — never filtered out, never duplicated
905
+ * delegates — never filtered out, never duplicated
906
906
  * with a board row for the same child (a child WITH a report keeps the
907
907
  * md entry, source 'md').
908
908
  *
@@ -984,7 +984,7 @@ export type DelegationEntry = {
984
984
  // Reintroduce them once nested rows are actually produced.
985
985
  /** Internal provenance used to keep a finalized md report authoritative.
986
986
  * 'children-only' = a native task() child session with NO board report
987
- * (rendered with the distinct `nat:` prefix); 'md' = board report wins;
987
+ * (a native child row); 'md' = board report wins;
988
988
  * 'board' = read from .pantheon/board/state.json (cross-session FSM). */
989
989
  source?: 'child' | 'live' | 'md' | 'children-only' | 'board'
990
990
  }
@@ -1172,7 +1172,7 @@ export async function readAllDelegationEntries(root: string): Promise<Delegation
1172
1172
 
1173
1173
  /* ─── Job board channel (4th source: .pantheon/board/state.json) ────────
1174
1174
  * `session.children` is scoped to the focused session, so a job running in
1175
- * ANOTHER session is invisible to it (the "Delegations (0)" / nat:0 symptom).
1175
+ * ANOTHER session is invisible to it (the "Delegations (0)" symptom).
1176
1176
  * The board state file is the only durable, cross-session source:
1177
1177
  * FilePersistence (src/pantheon/file-persistence.ts) stores every
1178
1178
  * BackgroundJobRecord there as a JSON array. Reads are fail-open — absent,
@@ -1302,18 +1302,18 @@ export function compareDelegationEntries(a: DelegationEntry, b: DelegationEntry)
1302
1302
  }
1303
1303
 
1304
1304
  /** Split the panel list: active jobs (running/retry, stale-marked) first,
1305
- * then the most recent terminal reports, then the archived tail (paginated
1306
- * in the View). Pure — so the history-only panel (no sessionID) is testable
1305
+ * then the most recent terminal reports; the remaining tail is collapsed
1306
+ * by the View into a single "… +N more" line. Pure — so the history-only panel (no sessionID) is testable
1307
1307
  * without the TUI runtime. */
1308
1308
  export function splitDelegationList(
1309
1309
  all: readonly DelegationEntry[],
1310
1310
  maxRecent = 8,
1311
1311
  now = Date.now(),
1312
1312
  staleThresholdMs = 30 * 60 * 1000, // 30 minutes
1313
- ): { active: DelegationEntry[]; recent: DelegationEntry[]; archived: DelegationEntry[] } {
1313
+ ): { active: DelegationEntry[]; recent: DelegationEntry[] } {
1314
1314
  // Active = running OR retry OR the display-only stale-running. A stale row
1315
1315
  // is still a live job (backend state unchanged), so it must never fall into
1316
- // the terminal/archived tail — archiving a running delegation hid the row
1316
+ // the terminal tail — pushing a running delegation out hid the row
1317
1317
  // the user launched.
1318
1318
  const isActiveState = (st: DelegationEntry['state']): boolean =>
1319
1319
  st === 'running' || st === 'retry' || st === 'stale-running'
@@ -1324,13 +1324,12 @@ export function splitDelegationList(
1324
1324
  return {
1325
1325
  active,
1326
1326
  recent: terminal.slice(0, maxRecent),
1327
- archived: terminal.slice(maxRecent),
1328
1327
  }
1329
1328
  }
1330
1329
 
1331
- /** The list the panel actually renders (kept for the header count and
1332
- * existing tests): active jobs first, then the most recent terminal
1333
- * reports (capped) — the archived tail is rendered separately. Pure. */
1330
+ /** Live-first window used by {@link ceilingDelegationList} (kept for the
1331
+ * ceiling helper and existing tests): active jobs first, then the most
1332
+ * recent terminal reports (capped). Pure. */
1334
1333
  export function visibleDelegationList(
1335
1334
  all: readonly DelegationEntry[],
1336
1335
  maxTerminal = 8,
@@ -1370,7 +1369,7 @@ export function markStaleIfRunning(
1370
1369
  return { ...entry, state: 'stale-running' as const }
1371
1370
  }
1372
1371
 
1373
- /** Compact elapsed-time label: "5m 12s", "1h 30m", "2d 4h" — ticks every
1372
+ /** Compact elapsed-time label, single unit only: "12s"/"3m"/"1h"/"2d" — ticks every
1374
1373
  * second for running jobs. */
1375
1374
  export function fmtElapsed(ms: number): string {
1376
1375
  const total = Math.max(0, Math.floor(ms / 1000))
@@ -1378,9 +1377,9 @@ export function fmtElapsed(ms: number): string {
1378
1377
  const hours = Math.floor((total % 86_400) / 3_600)
1379
1378
  const minutes = Math.floor((total % 3_600) / 60)
1380
1379
  const seconds = total % 60
1381
- if (days > 0) return `${days}d ${hours}h`
1382
- if (hours > 0) return `${hours}h ${minutes}m`
1383
- if (minutes > 0) return `${minutes}m ${seconds}s`
1380
+ if (days > 0) return `${days}d`
1381
+ if (hours > 0) return `${hours}h`
1382
+ if (minutes > 0) return `${minutes}m`
1384
1383
  return `${seconds}s`
1385
1384
  }
1386
1385
 
@@ -1451,38 +1450,14 @@ export function delegationSpinnerFrame(now: number): string {
1451
1450
  * pre-dispatch tool part maps to `running` in {@link reduceDelegationToolPart}. */
1452
1451
  export type DelegationDisplayState = DelegationEntry['state']
1453
1452
 
1454
- /** Static glyph per display state. `running` shows its base spinner frame;
1455
- * callers that animate must prefer {@link delegationStateMarker}. Unicode
1456
- * geometric shapes only (no Nerd Font) — shape is an independent channel
1457
- * from color, so rows stay legible without color. */
1458
- export const DELEGATION_STATE_GLYPHS: Readonly<Record<DelegationDisplayState, string>> = {
1459
- running: '\u280b', // ⠋ base frame of the animated spinner
1460
- retry: '\u27f3', // ⟳ retrying
1461
- 'stale-running': '\u26a0', // ⚠ stale (display-only alert)
1462
- completed: '\u2713', // ✓
1463
- error: '\u2715', // ✕
1464
- cancelled: '\u2212', // − minus: stopped, distinct from error's ✕
1465
- startup_failed: '\u26a0', // ⚠ boot failed
1466
- startup_unknown: '\u26a0', // ⚠ boot outcome unknown
1467
- }
1468
-
1469
- export function delegationStateGlyph(state: DelegationDisplayState): string {
1470
- return DELEGATION_STATE_GLYPHS[state]
1471
- }
1472
-
1473
- /** Row marker (`<glyph> `) — the state channel. `running` animates through
1474
- * {@link delegationSpinnerFrame} for the given tick; every other state is
1475
- * static. Pure. */
1476
- export function delegationStateMarker(state: DelegationDisplayState, now = Date.now()): string {
1477
- const glyph = state === 'running' ? delegationSpinnerFrame(now) : delegationStateGlyph(state)
1478
- return `${glyph} `
1479
- }
1480
-
1481
1453
  /** Semantic tone mapped to the TUI theme at the row ({@link DelegationRow}).
1482
1454
  * Kept separate + pure so the color channel is testable without booting the
1483
1455
  * renderer. */
1484
1456
  export type DelegationStateTone = 'warning' | 'error' | 'success' | 'muted'
1485
1457
 
1458
+ /** Color is the ONLY channel separating `stale-running` from a live `running`
1459
+ * row — the 4-glyph row design has no dedicated stale shape. Display-only;
1460
+ * no behavior change. */
1486
1461
  export function delegationStateTone(state: DelegationDisplayState): DelegationStateTone {
1487
1462
  switch (state) {
1488
1463
  case 'running':
@@ -1518,10 +1493,6 @@ const latestToolActivity = new Map<string, ToolActivity>()
1518
1493
  const MAX_TOOL_ACTIVITY_ENTRIES = 200
1519
1494
  const TOOL_ACTIVITY_TTL_MS = 5 * 60 * 1000
1520
1495
 
1521
- /** Archived-section pagination state — module-level so it survives sidebar
1522
- * remounts (route changes) without leaking into the component tree. */
1523
- const archivedUiState = { page: 0 }
1524
-
1525
1496
  /** First usable summary string from common tool input fields. */
1526
1497
  function summarizeToolInput(input: Record<string, unknown> | undefined): string {
1527
1498
  if (!input) return ''
@@ -1611,6 +1582,17 @@ export function mergeChildDelegationSources(
1611
1582
  )
1612
1583
  continue
1613
1584
  const incoming = toDelegationEntry(liveEntry)
1585
+ // A native task() live part never carries a board alias — it is a
1586
+ // children-only row by definition whether it lands on a new row or
1587
+ // upgrades an existing
1588
+ // children-only one. pantheon_delegate pending parts keep source 'live'.
1589
+ const isNativeLive = liveEntry.tool === 'task' && liveEntry.alias === null
1590
+ if (isNativeLive) {
1591
+ // Native task() rows carry no board alias — reuse the synthetic
1592
+ // 'native-task' marker so the single identity falls back to the agent.
1593
+ incoming.source = 'children-only'
1594
+ incoming.alias = 'native-task'
1595
+ }
1614
1596
  const index =
1615
1597
  (incoming.taskID !== undefined ? byTask.get(incoming.taskID) : undefined) ??
1616
1598
  (incoming.alias !== ''
@@ -1658,7 +1640,10 @@ export function mergeChildDelegationSources(
1658
1640
  startedAt: Math.min(existing.startedAt, incoming.startedAt),
1659
1641
  updatedAt: incoming.updatedAt,
1660
1642
  read: incoming.read,
1661
- source: 'live',
1643
+ // A native task() live upgrade must not flip a children-only row to
1644
+ // 'live' (that would drop the children-only provenance); pantheon_delegate
1645
+ // upgrades still take source 'live'.
1646
+ source: isNativeLive ? existing.source : 'live',
1662
1647
  }
1663
1648
  }
1664
1649
 
@@ -1803,7 +1788,7 @@ export function parseDelegationToolPart(
1803
1788
  // The alias/taskID only exist once the delegate tool COMPLETES (they are
1804
1789
  // returned in its output); a running/pending part has neither. Native
1805
1790
  // `task` parts never carry a board alias — the children channel supplies
1806
- // the child session id, so the row renders `nat:`.
1791
+ // the child session id and the row's identity.
1807
1792
  let alias: string | null = null
1808
1793
  let taskID: string | null = null
1809
1794
  if (status === 'completed') {
@@ -1877,7 +1862,7 @@ export function reduceDelegationToolPart(
1877
1862
  // job launched (running). A COMPLETED delegate tool only means the job was
1878
1863
  // registered — it stays running and we pick up alias + taskID from the
1879
1864
  // output. Native `task` parts never yield a board alias, so their rows
1880
- // stay `nat:` until the children channel resolves them.
1865
+ // stay identity-less until the children channel resolves them.
1881
1866
  const existing = map.get(parsed.callID)
1882
1867
  if (parsed.status === 'error') {
1883
1868
  if (existing !== undefined && existing.state === 'error' && existing.updatedAt === parsed.endAt)
@@ -1962,7 +1947,7 @@ export function removeDelegationEntry(
1962
1947
  * SDK exposes `api.state.part(messageID)`). The native `task` tool spawns a
1963
1948
  * child session with parentID = caller — the same mechanism as
1964
1949
  * pantheon_delegate — so its parts feed the live-map as the native signal
1965
- * (rows render `nat:` via the children channel). Pure w.r.t. I/O — used
1950
+ * (rows come from the children channel). Pure w.r.t. I/O — used
1966
1951
  * by the mount re-scan to re-seed the live map after compaction/attach. */
1967
1952
  export function collectDelegationToolParts(
1968
1953
  messages: readonly { id?: string; parts?: unknown[] }[] | undefined,
@@ -2135,70 +2120,20 @@ export function mergeBoardDelegationSources(
2135
2120
  bySessionAlias.set(key(incoming.sessionID, incoming.alias), next)
2136
2121
  continue
2137
2122
  }
2138
- out[index] = incoming // board wins — it is the persisted source of truth
2123
+ out[index] = {
2124
+ ...incoming,
2125
+ // Session preservation: the board is authoritative for state/alias/agent,
2126
+ // but an EMPTY board sessionID (record without parentSessionID) must
2127
+ // never clobber a known session — otherwise a focused-session row would
2128
+ // lose its attribution. Keep the known session.
2129
+ sessionID: incoming.sessionID !== '' ? incoming.sessionID : (out[index]?.sessionID ?? ''),
2130
+ } // board wins — it is the persisted source of truth
2139
2131
  }
2140
2132
 
2141
2133
  out.sort(compareDelegationEntries)
2142
2134
  return out
2143
2135
  }
2144
2136
 
2145
- /* ─── Delegation scope toggle ([Sessão]/[Tudo]) ───────────────────────── */
2146
-
2147
- /** 'session' = focused session only (default); 'all' = every session the
2148
- * board knows about. */
2149
- export type DelegationScope = 'session' | 'all'
2150
-
2151
- /** api.kv key holding the persisted scope (defaults to 'session'). */
2152
- export const DELEGATION_SCOPE_KV_KEY = 'delegations.scope'
2153
-
2154
- /** Structural subset of TuiKV — kept local so the pure helpers are testable
2155
- * without the TUI runtime. */
2156
- export type DelegationScopeKv = {
2157
- get: (key: string, fallback?: unknown) => unknown
2158
- set: (key: string, value: unknown) => void
2159
- }
2160
-
2161
- /** Read the persisted scope; anything malformed/absent → 'session'. */
2162
- export function readDelegationScope(kv: DelegationScopeKv | undefined): DelegationScope {
2163
- try {
2164
- const raw = kv?.get?.(DELEGATION_SCOPE_KV_KEY, 'session')
2165
- return raw === 'all' ? 'all' : 'session'
2166
- } catch {
2167
- return 'session'
2168
- }
2169
- }
2170
-
2171
- /** Persist the scope. Failure is non-fatal — the signal keeps it in memory. */
2172
- export function writeDelegationScope(
2173
- kv: DelegationScopeKv | undefined,
2174
- scope: DelegationScope,
2175
- ): void {
2176
- try {
2177
- kv?.set?.(DELEGATION_SCOPE_KV_KEY, scope)
2178
- } catch {
2179
- /* in-memory only */
2180
- }
2181
- }
2182
-
2183
- /** Keyboard/click toggle target. Pure. */
2184
- export function nextDelegationScope(scope: DelegationScope): DelegationScope {
2185
- return scope === 'session' ? 'all' : 'session'
2186
- }
2187
-
2188
- /** Keep only the focused session's jobs ('session') or all of them ('all').
2189
- * An empty sessionID marks a current-session child (the children channel is
2190
- * session-scoped and only fills sessionID from a matching md report), so it
2191
- * counts as the current session. With no resolved session there is nothing
2192
- * to scope against — fail-open to the full list. Pure. */
2193
- export function filterDelegationsByScope(
2194
- entries: readonly DelegationEntry[],
2195
- scope: DelegationScope,
2196
- sessionID: string | null,
2197
- ): DelegationEntry[] {
2198
- if (scope === 'all' || sessionID === null) return [...entries]
2199
- return entries.filter((entry) => entry.sessionID === sessionID || entry.sessionID === '')
2200
- }
2201
-
2202
2137
  /* ─── Children channel (primary source, delegations-sidebar pattern) ────
2203
2138
  * `api.client.session.children` returns the child sessions of the current
2204
2139
  * session — every pantheon_delegate spawns one (parentID = caller). The
@@ -2318,37 +2253,72 @@ export function childStatusToState(status: string | undefined): 'running' | 'com
2318
2253
  return 'running' // busy or unknown
2319
2254
  }
2320
2255
 
2321
- /** Short row prefix for a delegation entry (one of the two visual channels
2322
- * that split native task() work from pantheon_delegate jobs — the other is
2323
- * {@link delegationIcon}):
2256
+ /** Status-only row model: ONE glyph + ONE identity per row.
2324
2257
  *
2325
- * - `nat:<agent>` — a native task() child (source 'children-only', no board
2326
- * report). The child session carries no board alias, so the agent IS the
2327
- * identity.
2328
- * - `pan:<alias>` — a pantheon_delegate board row (`pan:apo-1`), the same
2329
- * alias the manager prints in pantheon_delegation_list.
2330
- *
2331
- * Short prefixes keep the narrow sidebar readable (the old
2332
- * `pan:apo-1`/`nat:` tags ate the row width). */
2333
- export function delegationTag(entry: DelegationEntry): string {
2334
- return entry.source === 'children-only' ? `nat:${entry.agent}` : `pan:${entry.alias}`
2258
+ * A row is
2259
+ * `{glyph} {alias:7} {elapsed:>5}` plus a muted description line. The short
2260
+ * alias (`apo-1`) is the single identity; rows without a board alias show
2261
+ * the agent instead (never both). */
2262
+ export type DelegationRowStatus = 'active' | 'done' | 'failed' | 'retry'
2263
+
2264
+ /** Map every FSM/display state to one of the 4 row kinds. reconciled never
2265
+ * reaches the panel (the board parser drops it) but reads as done;
2266
+ * cancelled reads as done; startup_failed reads as failed while
2267
+ * startup_unknown (no error known) reads as retry. Pure. */
2268
+ export function delegationRowStatus(state: DelegationDisplayState): DelegationRowStatus {
2269
+ switch (state) {
2270
+ case 'running':
2271
+ case 'stale-running':
2272
+ return 'active'
2273
+ case 'retry':
2274
+ case 'startup_unknown':
2275
+ return 'retry'
2276
+ case 'completed':
2277
+ case 'cancelled':
2278
+ return 'done'
2279
+ case 'error':
2280
+ case 'startup_failed':
2281
+ return 'failed'
2282
+ }
2335
2283
  }
2336
2284
 
2337
- /** Row glyph: hollow diamond `◇` for native task() children (info color,
2338
- * outline) vs filled diamond `◆` for pantheon_delegate rows (state color).
2339
- * Shape + color are independent channels, so the split stays legible even
2340
- * without color. */
2341
- export function delegationIcon(entry: DelegationEntry): string {
2342
- return entry.source === 'children-only' ? '\u25c7' : '\u25c6'
2285
+ /** The only 4 glyphs a row may show: animated active, done, failed, retry.
2286
+ * Shape is redundant with color (never the only signal). Pure. */
2287
+ export const DELEGATION_ROW_GLYPHS: Readonly<Record<DelegationRowStatus, string>> = {
2288
+ active: '⠋', // base frame of the animated spinner
2289
+ done: '✓',
2290
+ failed: '✕',
2291
+ retry: '⟳',
2343
2292
  }
2344
2293
 
2345
- /** Row identity after the status marker + glyph. Pantheon rows append the
2346
- * agent (`pan:apo-1 apollo`); native rows already carry it inside the tag
2347
- * (`nat:apollo`) and must not duplicate it. */
2348
- export function formatDelegationIdentity(entry: DelegationEntry): string {
2349
- return entry.source === 'children-only'
2350
- ? delegationTag(entry)
2351
- : `${delegationTag(entry)} ${entry.agent}`
2294
+ export function delegationRowGlyph(status: DelegationRowStatus): string {
2295
+ return DELEGATION_ROW_GLYPHS[status]
2296
+ }
2297
+
2298
+ /** Row marker (`<glyph> `) — the single state channel. `active` animates
2299
+ * through the 1s spinner for the given tick; every other kind is static.
2300
+ * Pure. */
2301
+ export function delegationRowMarker(state: DelegationDisplayState, now = Date.now()): string {
2302
+ const status = delegationRowStatus(state)
2303
+ const glyph = status === 'active' ? delegationSpinnerFrame(now) : delegationRowGlyph(status)
2304
+ return `${glyph} `
2305
+ }
2306
+
2307
+ /** ONE identity per row: the short board alias (`apo-1`); a row without a
2308
+ * board alias (native task() child, alias `native-task`) shows the agent
2309
+ * instead — never alias + agent stacked. Pure. */
2310
+ export function delegationRowIdentity(entry: DelegationEntry): string {
2311
+ if (entry.alias === 'native-task' && entry.agent !== '') return entry.agent
2312
+ return entry.alias
2313
+ }
2314
+
2315
+ /** Fixed alias cell, pad-right + hard-truncate to `width` (default 7) so
2316
+ * identities line up across rows. Pure. */
2317
+ export const DELEGATION_ALIAS_WIDTH = 7
2318
+
2319
+ export function formatDelegationAlias(identity: string, width = DELEGATION_ALIAS_WIDTH): string {
2320
+ if (identity.length >= width) return identity.slice(0, width)
2321
+ return `${identity}${' '.repeat(width - identity.length)}`
2352
2322
  }
2353
2323
 
2354
2324
  /** Max description width on the row detail line — the old 180-char slice
@@ -2382,10 +2352,10 @@ export function truncateDelegationDescription(
2382
2352
  return `${graphemes.slice(0, Math.max(0, max - 1)).join('')}\u2026`
2383
2353
  }
2384
2354
 
2385
- /** Fixed elapsed-time box: right-aligned to `width` (default 8) so elapsed
2386
- * values line up across rows, e.g. ` 12s`. A label that already fills
2355
+ /** Fixed elapsed-time box: right-aligned to `width` (default 5) so elapsed
2356
+ * values line up across rows, e.g. ` 12s`. A label that already fills
2387
2357
  * the box is returned untouched. Pure. */
2388
- export const DELEGATION_ELAPSED_WIDTH = 8
2358
+ export const DELEGATION_ELAPSED_WIDTH = 5
2389
2359
 
2390
2360
  export function formatDelegationElapsed(
2391
2361
  entry: DelegationEntry,
@@ -2397,10 +2367,11 @@ export function formatDelegationElapsed(
2397
2367
  return `${' '.repeat(width - label.length)}${label}`
2398
2368
  }
2399
2369
 
2400
- /** First row line: `<marker><glyph> <identity>`. The marker already carries
2401
- * its trailing space. Pure — the row renders the returned string verbatim. */
2402
- export function formatDelegationRow(entry: DelegationEntry, marker: string): string {
2403
- return `${marker}${delegationIcon(entry)} ${formatDelegationIdentity(entry)}`
2370
+ /** Colored left half of a row line: `<marker><alias:7> ` — the state glyph
2371
+ * plus the single identity, padded so the muted elapsed column aligns. The
2372
+ * row renders this colored lead separately from the muted elapsed tail. Pure. */
2373
+ export function formatDelegationRowLead(entry: DelegationEntry, marker: string): string {
2374
+ return `${marker}${formatDelegationAlias(delegationRowIdentity(entry))} `
2404
2375
  }
2405
2376
 
2406
2377
  // Tree connectors (`isNestedDelegation` / `delegationTreePrefix`) were removed
@@ -2408,22 +2379,52 @@ export function formatDelegationRow(entry: DelegationEntry, marker: string): str
2408
2379
  // ever actually nested and the connectors were never populated in production.
2409
2380
  // BACKLOG: produce real grandchild rows first, then reintroduce them.
2410
2381
 
2411
- /** Header summary: `(N active · M done · nat:K pan:M)`. Active counts
2412
- * running/retry AND display-only stale-running (a stale row is still a live
2413
- * job — it must never read as done). Pure. */
2382
+ /** Visible-row ceiling for the panel: the remainder collapses into a single
2383
+ * "… +N more" line. Live rows render first, so a running job is never hidden
2384
+ * by the cap. */
2385
+ export const DELEGATION_VISIBLE_CEILING = 8
2386
+
2387
+ /** Header summary: `(N active · M done)` plus `· K failed` only when K > 0.
2388
+ * Active counts running/retry AND display-only stale-running (a stale row is
2389
+ * still a live job — it must never read as done); failed counts
2390
+ * error/startup_failed; cancelled reads as done. Pure. */
2414
2391
  export function formatDelegationHeader(entries: readonly DelegationEntry[]): string {
2415
2392
  let active = 0
2393
+ let done = 0
2394
+ let failed = 0
2416
2395
  for (const e of entries) {
2417
- if (e.state === 'running' || e.state === 'retry' || e.state === 'stale-running') active++
2396
+ switch (delegationRowStatus(e.state)) {
2397
+ case 'active':
2398
+ case 'retry':
2399
+ active++
2400
+ break
2401
+ case 'failed':
2402
+ failed++
2403
+ break
2404
+ case 'done':
2405
+ done++
2406
+ break
2407
+ }
2418
2408
  }
2419
- const done = entries.length - active
2420
- const counts = countDelegationSources(entries)
2421
- return `(${active} active \u00b7 ${done} done \u00b7 nat:${counts.native} pan:${counts.pantheon})`
2409
+ const tail = failed > 0 ? ` · ${failed} failed` : ''
2410
+ return `(${active} active · ${done} done${tail})`
2411
+ }
2412
+
2413
+ /** Cap the panel: live rows first, then most-recent terminal rows, at most
2414
+ * `maxVisible` total. `hidden` is how many were dropped (rendered as
2415
+ * "… +N more"). Pure. */
2416
+ export function ceilingDelegationList(
2417
+ all: readonly DelegationEntry[],
2418
+ maxVisible = DELEGATION_VISIBLE_CEILING,
2419
+ now = Date.now(),
2420
+ ): { visible: DelegationEntry[]; hidden: number } {
2421
+ const visible = visibleDelegationList(all, maxVisible, now).slice(0, maxVisible)
2422
+ return { visible, hidden: Math.max(0, all.length - visible.length) }
2422
2423
  }
2423
2424
 
2424
2425
  /** Split a display list into native task() rows vs pantheon_delegate rows.
2425
2426
  * Native = source 'children-only' (no board report); everything else counts
2426
- * as pantheon. Pure — powers the header breakdown + the hooks.log line. */
2427
+ * as pantheon. Pure — powers the hooks.log line. */
2427
2428
  export function countDelegationSources(entries: readonly DelegationEntry[]): {
2428
2429
  native: number
2429
2430
  pantheon: number
@@ -2456,18 +2457,22 @@ export function formatPanelLogLine(
2456
2457
  * time.created. A report-less child is a NATIVE task() child (every
2457
2458
  * child of the current session — pantheon_delegate OR the native `task()`
2458
2459
  * tool — carries parentID = caller), so it gets source 'children-only',
2459
- * the internal alias 'native-task' and the `nat:` tag instead of a
2460
+ * the internal alias 'native-task' instead of a
2460
2461
  * board alias. The 'task nativa' description fallback keeps the row
2461
2462
  * non-empty when the child carries no title.
2462
2463
  * Terminal md state wins over the derived state; a running md defers to
2463
2464
  * the child's live status. A running child is NEVER archived — it always
2464
2465
  * lands in the active split (splitDelegationList active = running/retry).
2466
+ * `parentSessionID` (the focused session id) is stamped on report-less
2467
+ * children so the row carries its origin session; omit it and they stay
2468
+ * unscoped ('').
2465
2469
  * Sorted running-first (compareDelegationEntries).
2466
2470
  * Pure — no I/O. */
2467
2471
  export function childrenToDelegationEntries(
2468
2472
  children: readonly ChildDelegationLike[] | undefined,
2469
2473
  md: readonly DelegationEntry[],
2470
2474
  now = Date.now(),
2475
+ parentSessionID = '',
2471
2476
  ): DelegationEntry[] {
2472
2477
  const byTaskID = new Map<string, DelegationEntry>()
2473
2478
  for (const m of md) {
@@ -2485,7 +2490,11 @@ export function childrenToDelegationEntries(
2485
2490
  : childStatusToState(child.status)
2486
2491
  out.push({
2487
2492
  alias: mdEntry?.alias ?? 'native-task',
2488
- sessionID: mdEntry?.sessionID ?? '',
2493
+ // A report-less child IS a child of the focused session
2494
+ // (session.children is session-scoped), so the caller-passed
2495
+ // parentSessionID is stamped on it as its origin session; an md
2496
+ // match always carries its own session (the report dir name).
2497
+ sessionID: mdEntry?.sessionID ?? parentSessionID,
2489
2498
  taskID: child.id,
2490
2499
  agent: mdEntry?.agent ?? child.agent ?? 'agent',
2491
2500
  state,
@@ -2624,7 +2633,7 @@ function SessionRow(props: { api: TuiPluginApi; session: any }) {
2624
2633
  )
2625
2634
  }
2626
2635
 
2627
- /* ─── Delegation Row (single job from the children/md channels) ─ */
2636
+ /* ─── Delegation Row (single job from the children/md/board channels) ─ */
2628
2637
 
2629
2638
  function DelegationRow(props: {
2630
2639
  api: TuiPluginApi
@@ -2634,7 +2643,10 @@ function DelegationRow(props: {
2634
2643
  }) {
2635
2644
  const theme = () => props.api.theme.current
2636
2645
 
2637
- const color = createMemo(() => {
2646
+ // Color is a SEPARATE channel from the glyph: the lead (glyph + identity)
2647
+ // carries the state tone, while elapsed + description stay muted so the
2648
+ // state reads at a glance without shouting.
2649
+ const tone = createMemo(() => {
2638
2650
  const t = theme()
2639
2651
  switch (delegationStateTone(props.job.state)) {
2640
2652
  case 'warning':
@@ -2648,16 +2660,10 @@ function DelegationRow(props: {
2648
2660
  }
2649
2661
  })
2650
2662
 
2651
- const marker = createMemo(() => delegationStateMarker(props.job.state, props.animationNow))
2652
-
2653
- // Two visual channels split native task() from pantheon_delegate: the tag
2654
- // prefix (`nat:`/`pan:`, via delegationTag) and the glyph color — info
2655
- // (cyan) for native, state color for board rows. The glyph shape (◇/◆) is
2656
- // the third, color-independent channel.
2657
- const tagColor = createMemo(() => (props.job.source === 'children-only' ? theme().info : color()))
2658
-
2659
- const description = createMemo(() => truncateDelegationDescription(props.job.description))
2663
+ const marker = createMemo(() => delegationRowMarker(props.job.state, props.animationNow))
2664
+ const lead = createMemo(() => formatDelegationRowLead(props.job, marker()))
2660
2665
  const elapsed = createMemo(() => formatDelegationElapsed(props.job, props.now))
2666
+ const description = createMemo(() => truncateDelegationDescription(props.job.description))
2661
2667
 
2662
2668
  // beta.6: live tool call the child is running RIGHT NOW ("↳ bash npm test"),
2663
2669
  // fed by message.part.updated activity tracking. Null while idle/done.
@@ -2674,22 +2680,22 @@ function DelegationRow(props: {
2674
2680
 
2675
2681
  return (
2676
2682
  <box onMouseDown={open}>
2683
+ {/* Line 1: colored lead (glyph + identity) + muted elapsed tail. */}
2677
2684
  <box flexDirection="row">
2678
- <text fg={tagColor()}>{formatDelegationRow(props.job, marker())}</text>
2679
- </box>
2680
- {/* Elapsed is ALWAYS rendered (outside the optional-description Show) so
2681
- * an active job with no description never loses its timer. */}
2682
- <box flexDirection="row" justifyContent={description() !== '' ? 'space-between' : 'flex-end'}>
2683
- <Show when={description() !== ''}>
2684
- <text fg={theme().textMuted}>{description()}</text>
2685
- </Show>
2686
- <text fg={theme().textMuted}>{elapsed()}</text>
2685
+ <text>
2686
+ <span fg={tone()}>{lead()}</span>
2687
+ <span fg={theme().textMuted}>{elapsed()}</span>
2688
+ </text>
2687
2689
  </box>
2690
+ {/* Line 2: the description, indented under the identity and muted. */}
2691
+ <Show when={description() !== ''}>
2692
+ <text fg={theme().textMuted}>{` ${description()}`}</text>
2693
+ </Show>
2688
2694
  <Show when={activity()}>
2689
2695
  {(a) => (
2690
2696
  <text
2691
2697
  fg={theme().textMuted}
2692
- >{` \u21b3 ${truncateDelegationDescription(`${a().tool}${a().summary !== '' ? ` ${a().summary}` : ''}`)}`}</text>
2698
+ >{` ↳ ${truncateDelegationDescription(`${a().tool}${a().summary !== '' ? ` ${a().summary}` : ''}`)}`}</text>
2693
2699
  )}
2694
2700
  </Show>
2695
2701
  </box>
@@ -2804,16 +2810,9 @@ function View(props: {
2804
2810
  const projectRoot = createMemo(() => resolvePantheonRoot((props.api.state as any).path))
2805
2811
  // Silence-by-default panel logger → the REAL <root>/.pantheon/logs/hooks.log.
2806
2812
  const panelLog = createTuiLogger(projectRoot())
2807
- // Scope toggle: [Sessão] = focused session only (default), [Tudo] = every
2808
- // session the board knows about. Persisted best-effort in api.kv; if the kv
2809
- // is unavailable the signal keeps it for the process lifetime.
2810
- const [delegationScope, setDelegationScope] = createSignal<DelegationScope>(
2811
- readDelegationScope(props.api.kv as unknown as DelegationScopeKv),
2812
- )
2813
- const applyDelegationScope = (scope: DelegationScope) => {
2814
- setDelegationScope(scope)
2815
- writeDelegationScope(props.api.kv as unknown as DelegationScopeKv, scope)
2816
- }
2813
+ // Every job the board knows about is shown — no scope filter ("showing is
2814
+ // what matters"). Live-first ordering keeps running rows on top; the
2815
+ // ceiling collapses the rest into "… +N more".
2817
2816
  // The rendered list is childDelegations (children enriched with md);
2818
2817
  // there is no separate md-only signal — the md channel only feeds
2819
2818
  // childrenToDelegationEntries below.
@@ -2901,13 +2900,10 @@ function View(props: {
2901
2900
  }
2902
2901
  }
2903
2902
  const withStatus = children.map((c) => ({ ...c, status: resolveStatus(c.id) }))
2904
- const childEntries = childrenToDelegationEntries(withStatus, md, now())
2905
- // childrenToDelegationEntries only fills sessionID from a matching md
2906
- // report; a report-less row IS a child of the focused session, so tag
2907
- // it with the resolved id — the scope filter can then keep it.
2908
- for (const entry of childEntries) {
2909
- if (entry.sessionID === '') entry.sessionID = sessionID
2910
- }
2903
+ // Report-less rows ARE children of the focused session
2904
+ // (session.children is session-scoped), so the resolved id is stamped on
2905
+ // the row as its origin session.
2906
+ const childEntries = childrenToDelegationEntries(withStatus, md, now(), sessionID)
2911
2907
  for (const [k, v] of props.liveStore.map)
2912
2908
  if (v.alias === null && v.taskID === null && Date.now() - v.startedAt > 30_000)
2913
2909
  props.liveStore.map.delete(k)
@@ -2942,33 +2938,13 @@ function View(props: {
2942
2938
  })()
2943
2939
  return delegationsInflight
2944
2940
  }
2945
- // beta.6: active (running/retry) first, then the 8 most recent terminal
2946
- // reports, then the archived tail (paginated). Pagination state lives at
2947
- // module level so sidebar remounts keep the user's page.
2948
- // The scope filters the rendered list (and the header counts): children/live
2949
- // are already session-scoped, md and the board are not.
2950
- const scopedDelegations = createMemo(() =>
2951
- filterDelegationsByScope(
2952
- childDelegations(),
2953
- delegationScope(),
2954
- resolveCurrentSessionID({ sessionID: props.sessionID, api: props.api }),
2955
- ),
2941
+ // Live-first ceiling: at most DELEGATION_VISIBLE_CEILING rows render; the
2942
+ // remainder collapses into a single "… +N more" line. The header counts the
2943
+ // FULL board list — every session, no scope filter.
2944
+ const delegationCeiling = createMemo(() =>
2945
+ ceilingDelegationList(childDelegations(), DELEGATION_VISIBLE_CEILING, now()),
2956
2946
  )
2957
- const delegationSplit = createMemo(() => splitDelegationList(scopedDelegations()))
2958
- const visibleDelegations = createMemo(() => [
2959
- ...delegationSplit().active,
2960
- ...delegationSplit().recent,
2961
- ])
2962
- // Header summary over the FULL scoped list (active + recent + archived):
2963
- // active vs done plus the native/pantheon breakdown, so a busy session reads
2964
- // "Delegations (2 active · 9 done · nat:3 pan:8)".
2965
- const delegationHeader = createMemo(() => formatDelegationHeader(scopedDelegations()))
2966
- const [archivedPage, setArchivedPage] = createSignal(archivedUiState.page)
2967
- const clampArchivedPage = (page: number, pages: number) => {
2968
- const clamped = Math.max(0, Math.min(page, Math.max(0, pages - 1)))
2969
- archivedUiState.page = clamped
2970
- setArchivedPage(clamped)
2971
- }
2947
+ const delegationHeader = createMemo(() => formatDelegationHeader(childDelegations()))
2972
2948
 
2973
2949
  // ── Event subscriptions for live session/delegation updates ──
2974
2950
  onMount(() => {
@@ -3047,29 +3023,6 @@ function View(props: {
3047
3023
  /* re-scan unavailable — children API + 1s poll still cover it */
3048
3024
  }
3049
3025
 
3050
- // Keyboard shortcut for the scope toggle. `api.keymap` is an optional
3051
- // peer (@opentui/keymap) — guard the duck-typed call so a degraded/missing
3052
- // keymap never breaks the sidebar. The clickable [Sessão]/[Tudo] chips are
3053
- // the always-available fallback.
3054
- try {
3055
- const keymap = (props.api as any)?.keymap
3056
- if (typeof keymap?.registerLayer === 'function') {
3057
- const off = keymap.registerLayer({
3058
- priority: 1000,
3059
- commands: [
3060
- {
3061
- name: 'pantheon.delegations.toggleScope',
3062
- run: () => applyDelegationScope(nextDelegationScope(delegationScope())),
3063
- },
3064
- ],
3065
- bindings: [{ key: 'ctrl+shift+d', cmd: 'pantheon.delegations.toggleScope' }],
3066
- })
3067
- if (typeof off === 'function') cleanup.push(off)
3068
- }
3069
- } catch {
3070
- /* keymap unavailable — the click toggle still works */
3071
- }
3072
-
3073
3026
  onCleanup(() =>
3074
3027
  cleanup.forEach((fn) => {
3075
3028
  fn()
@@ -3136,31 +3089,13 @@ function View(props: {
3136
3089
  <text fg={theme().text} attributes={1}>
3137
3090
  {`${showDelegations() ? '▼' : '▶'} Delegations`}
3138
3091
  </text>
3139
- <text fg={theme().textMuted}>{` ${delegationHeader()}`}</text>
3140
- <text fg={theme().textMuted}>{` `}</text>
3141
- <text
3142
- fg={delegationScope() === 'session' ? theme().accent : theme().textMuted}
3143
- attributes={delegationScope() === 'session' ? 1 : undefined}
3144
- onMouseDown={(e: unknown) => {
3145
- if (typeof e === 'object' && e !== null && 'stopPropagation' in e)
3146
- (e as { stopPropagation: () => void }).stopPropagation()
3147
- applyDelegationScope('session')
3148
- }}
3149
- >{`[Sessão]`}</text>
3150
- <text fg={theme().textMuted}>{`/`}</text>
3151
- <text
3152
- fg={delegationScope() === 'all' ? theme().accent : theme().textMuted}
3153
- attributes={delegationScope() === 'all' ? 1 : undefined}
3154
- onMouseDown={(e: unknown) => {
3155
- if (typeof e === 'object' && e !== null && 'stopPropagation' in e)
3156
- (e as { stopPropagation: () => void }).stopPropagation()
3157
- applyDelegationScope('all')
3158
- }}
3159
- >{`[Tudo]`}</text>
3092
+ <text fg={theme().textMuted}>
3093
+ {childDelegations().length > 0 ? ` ${delegationHeader()}` : ' — idle'}
3094
+ </text>
3160
3095
  </box>
3161
3096
  <Show when={showDelegations()}>
3162
3097
  <Show
3163
- when={visibleDelegations().length > 0}
3098
+ when={delegationCeiling().visible.length > 0}
3164
3099
  fallback={
3165
3100
  <box marginLeft={1}>
3166
3101
  <text fg={theme().textMuted}>No delegations</text>
@@ -3168,7 +3103,7 @@ function View(props: {
3168
3103
  }
3169
3104
  >
3170
3105
  <box marginLeft={1} flexDirection="column">
3171
- <For each={visibleDelegations()}>
3106
+ <For each={delegationCeiling().visible}>
3172
3107
  {(job) => (
3173
3108
  <DelegationRow
3174
3109
  api={props.api}
@@ -3178,40 +3113,8 @@ function View(props: {
3178
3113
  />
3179
3114
  )}
3180
3115
  </For>
3181
- <Show when={delegationSplit().archived.length > 0}>
3182
- {(() => {
3183
- const archived = delegationSplit().archived
3184
- const pageSize = 8
3185
- const pages = Math.ceil(archived.length / pageSize)
3186
- const page = Math.min(archivedPage(), pages - 1)
3187
- const slice = archived.slice(page * pageSize, page * pageSize + pageSize)
3188
- return (
3189
- <box flexDirection="column">
3190
- <box flexDirection="row" marginLeft={0}>
3191
- <text
3192
- fg={theme().textMuted}
3193
- onMouseDown={() => clampArchivedPage(page - 1, pages)}
3194
- >{`\u2039 `}</text>
3195
- <text fg={theme().textMuted}>{`Archived (${archived.length}) `}</text>
3196
- <text fg={theme().textMuted}>{`${page + 1}/${pages}`}</text>
3197
- <text
3198
- fg={theme().textMuted}
3199
- onMouseDown={() => clampArchivedPage(page + 1, pages)}
3200
- >{` \u203a`}</text>
3201
- </box>
3202
- <For each={slice}>
3203
- {(job) => (
3204
- <DelegationRow
3205
- api={props.api}
3206
- job={job}
3207
- now={now()}
3208
- animationNow={animationNow()}
3209
- />
3210
- )}
3211
- </For>
3212
- </box>
3213
- )
3214
- })()}
3116
+ <Show when={delegationCeiling().hidden > 0}>
3117
+ <text fg={theme().textMuted}>{`… +${delegationCeiling().hidden} more`}</text>
3215
3118
  </Show>
3216
3119
  </box>
3217
3120
  </Show>