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

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.
@@ -5,9 +5,10 @@
5
5
  /**
6
6
  * pantheon-tui — Pantheon TUI plugin for opencode.
7
7
  *
8
- * Single self-contained entry file: `tsdown && cp src/index.tsx dist/tui.tsx`
9
- * keeps the no-build load path working (dist/tui.tsx is raw source, transpiled
10
- * on the fly by opencode). All feature code lives in this one file.
8
+ * Bundled entry: `tsdown` emits `dist/tui.js`, and the package `exports`
9
+ * (`./tui` → ./dist/tui.js, `./server` → ./dist/server.js) is the ONLY load
10
+ * path — no raw TSX copy is shipped. Because the bundle inlines relative
11
+ * imports, this entry may be split into modules without breaking the loader.
11
12
  *
12
13
  * Slots:
13
14
  * - sidebar_content (order 900) — Pantheon sidebar (header/version/
@@ -90,9 +91,9 @@ const PRESET_REFRESH_MS = 30_000
90
91
  /* ─── Helpers ───────────────────────────────────────────── */
91
92
 
92
93
  /* ─── Active Preset Detection ──────────────────────────────
93
- * Inline replica of src/pantheon/presets.mjs resolveActivePreset() — the TUI
94
- * plugin is self-contained (dist/tui.tsx is a raw copy of this file with no
95
- * relative imports), so the resolution logic is mirrored here rather than
94
+ * Inline replica of src/pantheon/presets.mjs resolveActivePreset() — the
95
+ * copied plugin ships only dist/ + package.json, so it cannot reach
96
+ * src/pantheon at runtime; the resolution logic is mirrored here rather than
96
97
  * importing presets.mjs.
97
98
  *
98
99
  * Priority: env PANTHEON_MODEL_PRESET > first existing candidate file > none.
@@ -155,7 +156,7 @@ async function resolvePresetForTui(
155
156
  *
156
157
  * Try order:
157
158
  * 0a. pantheon-tui/package.json (installed: dist/tui.js → 2 níveis)
158
- * 0b. pantheon/package.json (dev: src/plugins/tui/dist/tui.tsx → 4 níveis)
159
+ * 0b. pantheon/package.json (dev: src/plugins/tui/dist/tui.js → 4 níveis)
159
160
  * 1. api.client.file.read package.json
160
161
  * 2. git describe --tags
161
162
  * 3. opencode --version
@@ -163,7 +164,7 @@ async function resolvePresetForTui(
163
164
  * 5. null */
164
165
 
165
166
  // Build-time embedded version (tsdown `define` in tsdown.config.ts). Injected
166
- // only into the bundled dist/tui.js; the raw dist/tui.tsx copy falls through.
167
+ // into the bundled dist/tui.js — the only artifact the loader consumes.
167
168
  declare const __PANTHEON_VERSION__: string | undefined
168
169
 
169
170
  async function detectVersion(api: TuiPluginApi): Promise<string | null> {
@@ -175,7 +176,7 @@ async function detectVersion(api: TuiPluginApi): Promise<string | null> {
175
176
  if (pkg.version) return pkg.version
176
177
  } catch {}
177
178
 
178
- // Try 0b: dev location (4 levels: src/plugins/tui/dist/tui.tsx → package.json)
179
+ // Try 0b: dev location (4 levels: src/plugins/tui/dist/tui.js → package.json)
179
180
  try {
180
181
  const pkgUrl = new URL('../../../../package.json', import.meta.url)
181
182
  const pkgContent = await readFile(fileURLToPath(pkgUrl), 'utf8')
@@ -983,8 +984,9 @@ export type DelegationEntry = {
983
984
  // Reintroduce them once nested rows are actually produced.
984
985
  /** Internal provenance used to keep a finalized md report authoritative.
985
986
  * 'children-only' = a native task() child session with NO board report
986
- * (rendered with the distinct `nat:` prefix); 'md' = board report wins. */
987
- source?: 'child' | 'live' | 'md' | 'children-only'
987
+ * (rendered with the distinct `nat:` prefix); 'md' = board report wins;
988
+ * 'board' = read from .pantheon/board/state.json (cross-session FSM). */
989
+ source?: 'child' | 'live' | 'md' | 'children-only' | 'board'
988
990
  }
989
991
 
990
992
  /** True for `\s` characters (space, tab, newline, CR) — plain char checks so
@@ -1168,20 +1170,108 @@ export async function readAllDelegationEntries(root: string): Promise<Delegation
1168
1170
  return readDelegationEntries(join(root, '.pantheon', 'delegations'))
1169
1171
  }
1170
1172
 
1173
+ /* ─── Job board channel (4th source: .pantheon/board/state.json) ────────
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).
1176
+ * The board state file is the only durable, cross-session source:
1177
+ * FilePersistence (src/pantheon/file-persistence.ts) stores every
1178
+ * BackgroundJobRecord there as a JSON array. Reads are fail-open — absent,
1179
+ * corrupt or unreadable → [] — so a broken board never crashes the panel. */
1180
+
1181
+ /** The persisted subset of BackgroundJobRecord the panel consumes. Duck-typed
1182
+ * so a corrupt/older record never breaks parsing. */
1183
+ export type BoardJobRecord = {
1184
+ taskID: string
1185
+ parentSessionID: string
1186
+ agent: string
1187
+ description?: string
1188
+ state: string
1189
+ alias: string
1190
+ launchedAt?: number
1191
+ completedAt?: number
1192
+ updatedAt?: number
1193
+ timedOut?: boolean
1194
+ }
1195
+
1196
+ /** The FilePersistence state path: `<root>/.pantheon/board/state.json`. */
1197
+ export function boardStatePath(root: string): string {
1198
+ return join(root, '.pantheon', 'board', 'state.json')
1199
+ }
1200
+
1201
+ /** Validate one parsed record, keeping only the fields the panel uses.
1202
+ * Returns null for anything without a taskID/state (a corrupt entry is
1203
+ * skipped individually — the rest of the snapshot still loads). */
1204
+ function normalizeBoardRecord(item: unknown): BoardJobRecord | null {
1205
+ if (typeof item !== 'object' || item === null) return null
1206
+ const rec = item as Record<string, unknown>
1207
+ if (typeof rec.taskID !== 'string' || rec.taskID === '') return null
1208
+ if (typeof rec.state !== 'string' || rec.state === '') return null
1209
+ const record: BoardJobRecord = {
1210
+ taskID: rec.taskID,
1211
+ parentSessionID: typeof rec.parentSessionID === 'string' ? rec.parentSessionID : '',
1212
+ agent: typeof rec.agent === 'string' && rec.agent !== '' ? rec.agent : 'agent',
1213
+ state: rec.state,
1214
+ alias: typeof rec.alias === 'string' ? rec.alias : '',
1215
+ }
1216
+ if (typeof rec.description === 'string') record.description = rec.description
1217
+ if (typeof rec.launchedAt === 'number' && Number.isFinite(rec.launchedAt))
1218
+ record.launchedAt = rec.launchedAt
1219
+ if (typeof rec.completedAt === 'number' && Number.isFinite(rec.completedAt))
1220
+ record.completedAt = rec.completedAt
1221
+ if (typeof rec.updatedAt === 'number' && Number.isFinite(rec.updatedAt))
1222
+ record.updatedAt = rec.updatedAt
1223
+ if (typeof rec.timedOut === 'boolean') record.timedOut = rec.timedOut
1224
+ return record
1225
+ }
1226
+
1227
+ /** Read `.pantheon/board/state.json` (the cross-session job board snapshot).
1228
+ * Fail-open: missing file, corrupt JSON, non-array payload or an unreadable
1229
+ * path all yield [] — the panel keeps rendering from the other channels. */
1230
+ export async function readBoardState(root: string): Promise<BoardJobRecord[]> {
1231
+ let raw: string
1232
+ try {
1233
+ raw = await readFile(boardStatePath(root), 'utf8')
1234
+ } catch {
1235
+ return [] // absent/unreadable — no board yet
1236
+ }
1237
+ let parsed: unknown
1238
+ try {
1239
+ parsed = JSON.parse(raw)
1240
+ } catch {
1241
+ return [] // corrupt JSON — never crash the sidebar
1242
+ }
1243
+ if (!Array.isArray(parsed)) return []
1244
+ const records: BoardJobRecord[] = []
1245
+ for (const item of parsed) {
1246
+ const record = normalizeBoardRecord(item)
1247
+ if (record !== null) records.push(record)
1248
+ }
1249
+ return records
1250
+ }
1251
+
1252
+ /** Resolve the PROJECT ROOT used by every pantheon file channel. `directory`
1253
+ * wins over `worktree` (the old `resolveDelegationsDir` already did this);
1254
+ * an absent/empty root or `/` (no git — e.g. the sandbox test project) falls
1255
+ * back to cwd. Standardised here so the delegations md, the board state file
1256
+ * and the panel logger all read the SAME root (audit finding: the channels
1257
+ * resolved the root independently). Pure — no I/O. */
1258
+ export function resolvePantheonRoot(
1259
+ state: { directory?: string; worktree?: string } | undefined,
1260
+ cwd = process.cwd(),
1261
+ ): string {
1262
+ const root = state?.directory ?? state?.worktree ?? ''
1263
+ if (root === '' || root === '/') return cwd
1264
+ return root
1265
+ }
1266
+
1171
1267
  /** Resolve the directory where the job board writes delegation md reports.
1172
1268
  * The board writes `.pantheon/delegations` RELATIVE to the server cwd,
1173
- * which the TUI exposes as `TuiState.path.directory`. `project` does NOT
1174
- * exist on `TuiState.path` (the old `state?.project ?? state?.worktree`
1175
- * resolution was always undefined for the first term) and `worktree` is
1176
- * `/` when there is no git (e.g. the sandbox test project) — a root of
1177
- * `''` or `'/'` must fall back to `process.cwd()`. */
1269
+ * which the TUI exposes as `TuiState.path.directory`. */
1178
1270
  export function resolveDelegationsDir(
1179
1271
  state: { directory?: string; worktree?: string } | undefined,
1180
1272
  cwd = process.cwd(),
1181
1273
  ): string {
1182
- const root = state?.directory ?? state?.worktree ?? ''
1183
- if (root === '' || root === '/') return join(cwd, '.pantheon', 'delegations')
1184
- return join(root, '.pantheon', 'delegations')
1274
+ return join(resolvePantheonRoot(state, cwd), '.pantheon', 'delegations')
1185
1275
  }
1186
1276
 
1187
1277
  /** Project root derived from the delegations dir: `<root>/.pantheon/delegations`
@@ -1970,6 +2060,145 @@ export function mergeDelegationSources(
1970
2060
  return all
1971
2061
  }
1972
2062
 
2063
+ /* ─── Board channel: record → display + authoritative merge ───────────── */
2064
+
2065
+ /** Job-board FSM → panel display state. `reconciled` is intentionally absent:
2066
+ * the panel parser rejects it (a reconciled job is done and folded away). */
2067
+ const BOARD_STATE_TO_DISPLAY: Record<string, DelegationEntry['state']> = {
2068
+ running: 'running',
2069
+ completed: 'completed',
2070
+ error: 'error',
2071
+ startup_failed: 'startup_failed',
2072
+ startup_unknown: 'startup_unknown',
2073
+ cancelled: 'cancelled',
2074
+ }
2075
+
2076
+ /** Map one persisted board record into the display shape. Returns null for
2077
+ * states the panel does not render (reconciled / unknown). Pure. */
2078
+ export function boardRecordToDelegationEntry(record: BoardJobRecord): DelegationEntry | null {
2079
+ const state = BOARD_STATE_TO_DISPLAY[record.state]
2080
+ if (state === undefined) return null
2081
+ const running = state === 'running'
2082
+ return {
2083
+ alias: record.alias !== '' ? record.alias : `job-${record.taskID.slice(0, 8)}`,
2084
+ sessionID: record.parentSessionID,
2085
+ taskID: record.taskID,
2086
+ agent: record.agent,
2087
+ state,
2088
+ startedAt: record.launchedAt ?? record.updatedAt ?? 0,
2089
+ updatedAt: running ? null : (record.completedAt ?? record.updatedAt ?? null),
2090
+ timedOut: record.timedOut === true,
2091
+ description: record.description ?? '',
2092
+ source: 'board',
2093
+ }
2094
+ }
2095
+
2096
+ /** Map a whole board snapshot, dropping unrenderable states. Sorted like every
2097
+ * other channel (running first, then most recent). Pure. */
2098
+ export function boardRecordsToDelegationEntries(
2099
+ records: readonly BoardJobRecord[],
2100
+ ): DelegationEntry[] {
2101
+ const out: DelegationEntry[] = []
2102
+ for (const record of records) {
2103
+ const entry = boardRecordToDelegationEntry(record)
2104
+ if (entry !== null) out.push(entry)
2105
+ }
2106
+ out.sort(compareDelegationEntries)
2107
+ return out
2108
+ }
2109
+
2110
+ /** Merge the board over any other channel. The board is AUTHORITATIVE for
2111
+ * state/alias/agent (it is the persisted FSM) and it is the only channel
2112
+ * that carries jobs from OTHER sessions. Dedup by taskID first, then by
2113
+ * (sessionID, alias) — aliases are per parent session. Pure. */
2114
+ export function mergeBoardDelegationSources(
2115
+ base: readonly DelegationEntry[],
2116
+ board: readonly DelegationEntry[],
2117
+ ): DelegationEntry[] {
2118
+ const key = (sessionID: string, alias: string): string => `${sessionID}\u0000${alias}`
2119
+ const byTaskID = new Map<string, number>()
2120
+ const bySessionAlias = new Map<string, number>()
2121
+ const out = [...base]
2122
+ out.forEach((entry, index) => {
2123
+ if (entry.taskID !== undefined) byTaskID.set(entry.taskID, index)
2124
+ bySessionAlias.set(key(entry.sessionID, entry.alias), index)
2125
+ })
2126
+
2127
+ for (const incoming of board) {
2128
+ const index =
2129
+ (incoming.taskID !== undefined ? byTaskID.get(incoming.taskID) : undefined) ??
2130
+ bySessionAlias.get(key(incoming.sessionID, incoming.alias))
2131
+ if (index === undefined) {
2132
+ out.push(incoming)
2133
+ const next = out.length - 1
2134
+ if (incoming.taskID !== undefined) byTaskID.set(incoming.taskID, next)
2135
+ bySessionAlias.set(key(incoming.sessionID, incoming.alias), next)
2136
+ continue
2137
+ }
2138
+ out[index] = incoming // board wins — it is the persisted source of truth
2139
+ }
2140
+
2141
+ out.sort(compareDelegationEntries)
2142
+ return out
2143
+ }
2144
+
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
+
1973
2202
  /* ─── Children channel (primary source, delegations-sidebar pattern) ────
1974
2203
  * `api.client.session.children` returns the child sessions of the current
1975
2204
  * session — every pantheon_delegate spawns one (parentID = caller). The
@@ -2293,8 +2522,8 @@ export function navigateToDelegationSession(
2293
2522
  }
2294
2523
 
2295
2524
  /* ─── Silence-by-default panel logger ──────────────────────
2296
- * Mirrors src/pantheon/logger.ts createPantheonLogger (the TUI plugin is
2297
- * self-contained — dist/tui.tsx is a raw copy with no relative imports):
2525
+ * Mirrors src/pantheon/logger.ts createPantheonLogger (the copied plugin
2526
+ * ships only dist/ + package.json, so it cannot import from src/pantheon):
2298
2527
  * every line is ALWAYS appended to `<logDir>/.pantheon/logs/hooks.log` and
2299
2528
  * echoed to the console ONLY when PANTHEON_HOOKS_LOG is truthy (opencode
2300
2529
  * renders plugin console output directly into the TUI — the pollution bug).
@@ -2569,14 +2798,22 @@ function View(props: {
2569
2798
  // (.pantheon/delegations) enriches each child with alias/agent/description
2570
2799
  // and terminal duration via the `Task ID` match. Live tool-call events are
2571
2800
  // merged optimistically so the row appears before those channels catch up.
2572
- const delegationsDir = createMemo(() => resolveDelegationsDir((props.api.state as any).path))
2801
+ // Consistent project root (directory ?? worktree, cwd fallback) — the md
2802
+ // history, the board state file and the panel logger all read the SAME
2803
+ // root (audit finding: the channels resolved it independently).
2804
+ const projectRoot = createMemo(() => resolvePantheonRoot((props.api.state as any).path))
2573
2805
  // Silence-by-default panel logger → the REAL <root>/.pantheon/logs/hooks.log.
2574
- // panelLogDir(<root>/.pantheon/delegations) = <root>: the old code passed
2575
- // dirname(delegationsDir()) (= <root>/.pantheon), which createTuiLogger
2576
- // re-joined with `.pantheon/logs/hooks.log` → a nested
2577
- // <root>/.pantheon/.pantheon/logs/hooks.log that never reached the real
2578
- // hooks.log (the empty nested dirs observed in production).
2579
- const panelLog = createTuiLogger(panelLogDir(delegationsDir()))
2806
+ 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
+ }
2580
2817
  // The rendered list is childDelegations (children enriched with md);
2581
2818
  // there is no separate md-only signal — the md channel only feeds
2582
2819
  // childrenToDelegationEntries below.
@@ -2609,10 +2846,19 @@ function View(props: {
2609
2846
  // the full historical list. Fail-open: a missing dir yields [].
2610
2847
  let md: DelegationEntry[] = []
2611
2848
  try {
2612
- md = await readAllDelegationEntries(panelLogDir(delegationsDir()))
2849
+ md = await readAllDelegationEntries(projectRoot())
2613
2850
  } catch {
2614
2851
  md = [] // fail-open: never crash the sidebar on a read error
2615
2852
  }
2853
+ // 0b. board snapshot — the 4th channel. Cross-session and AUTHORITATIVE
2854
+ // for state/alias/agent (the persisted FSM). This is what makes a job
2855
+ // running in ANOTHER session visible. Fail-open: absent/corrupt → [].
2856
+ let board: DelegationEntry[] = []
2857
+ try {
2858
+ board = boardRecordsToDelegationEntries(await readBoardState(projectRoot()))
2859
+ } catch {
2860
+ board = [] // fail-open: never crash the sidebar on a board read error
2861
+ }
2616
2862
  // 1. current session — resolve + GUARD (placeholder regression). The
2617
2863
  // runtime may render the sidebar with an unsubstituted "{sessionID}"
2618
2864
  // slot prop; forwarding it to session.children made the server reject
@@ -2622,9 +2868,9 @@ function View(props: {
2622
2868
  // (zero children, zero errors).
2623
2869
  const sessionID = resolveCurrentSessionID({ sessionID: props.sessionID, api: props.api })
2624
2870
  if (sessionID === null) {
2625
- setChildDelegations(md)
2871
+ setChildDelegations(mergeBoardDelegationSources(md, board))
2626
2872
  panelLog.info(
2627
- `${formatPanelLogLine(0, 0, 0, md.length, eventRefreshCount)} (no sessionID — history shown)`,
2873
+ `${formatPanelLogLine(0, 0, 0, md.length, eventRefreshCount)} (no sessionID — history+board shown)`,
2628
2874
  )
2629
2875
  return
2630
2876
  }
@@ -2656,6 +2902,12 @@ function View(props: {
2656
2902
  }
2657
2903
  const withStatus = children.map((c) => ({ ...c, status: resolveStatus(c.id) }))
2658
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
+ }
2659
2911
  for (const [k, v] of props.liveStore.map)
2660
2912
  if (v.alias === null && v.taskID === null && Date.now() - v.startedAt > 30_000)
2661
2913
  props.liveStore.map.delete(k)
@@ -2665,7 +2917,14 @@ function View(props: {
2665
2917
  const liveEntries = [...props.liveStore.map.values()].filter(
2666
2918
  (entry) => entry.sessionID === sessionID,
2667
2919
  )
2668
- setChildDelegations(mergeChildDelegationSources(childEntries, liveEntries))
2920
+ // Board LAST so it is authoritative over children/md/live for the same
2921
+ // job, and adds the rows from other sessions the children API cannot see.
2922
+ setChildDelegations(
2923
+ mergeBoardDelegationSources(
2924
+ mergeChildDelegationSources(childEntries, liveEntries),
2925
+ board,
2926
+ ),
2927
+ )
2669
2928
  // 4. diagnostic line — every re-fetch (silence-by-default, hooks.log).
2670
2929
  const counts = countDelegationSources(childEntries)
2671
2930
  panelLog.info(
@@ -2686,15 +2945,24 @@ function View(props: {
2686
2945
  // beta.6: active (running/retry) first, then the 8 most recent terminal
2687
2946
  // reports, then the archived tail (paginated). Pagination state lives at
2688
2947
  // module level so sidebar remounts keep the user's page.
2689
- const delegationSplit = createMemo(() => splitDelegationList(childDelegations()))
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
+ ),
2956
+ )
2957
+ const delegationSplit = createMemo(() => splitDelegationList(scopedDelegations()))
2690
2958
  const visibleDelegations = createMemo(() => [
2691
2959
  ...delegationSplit().active,
2692
2960
  ...delegationSplit().recent,
2693
2961
  ])
2694
- // Header summary over the FULL list (active + recent + archived): active
2695
- // vs done plus the native/pantheon breakdown, so a busy session reads
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
2696
2964
  // "Delegations (2 active · 9 done · nat:3 pan:8)".
2697
- const delegationHeader = createMemo(() => formatDelegationHeader(childDelegations()))
2965
+ const delegationHeader = createMemo(() => formatDelegationHeader(scopedDelegations()))
2698
2966
  const [archivedPage, setArchivedPage] = createSignal(archivedUiState.page)
2699
2967
  const clampArchivedPage = (page: number, pages: number) => {
2700
2968
  const clamped = Math.max(0, Math.min(page, Math.max(0, pages - 1)))
@@ -2779,6 +3047,29 @@ function View(props: {
2779
3047
  /* re-scan unavailable — children API + 1s poll still cover it */
2780
3048
  }
2781
3049
 
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
+
2782
3073
  onCleanup(() =>
2783
3074
  cleanup.forEach((fn) => {
2784
3075
  fn()
@@ -2840,12 +3131,32 @@ function View(props: {
2840
3131
  </Show>
2841
3132
  </Show>
2842
3133
 
2843
- {/* ── Delegations (session.children primary + .pantheon/delegations md enrichment) ── */}
3134
+ {/* ── Delegations (session.children primary + .pantheon/delegations md enrichment + board snapshot) ── */}
2844
3135
  <box onMouseDown={() => setShowDelegations((x) => !x)}>
2845
3136
  <text fg={theme().text} attributes={1}>
2846
3137
  {`${showDelegations() ? '▼' : '▶'} Delegations`}
2847
3138
  </text>
2848
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>
2849
3160
  </box>
2850
3161
  <Show when={showDelegations()}>
2851
3162
  <Show