@aemonge-dev/pi-stardeck 0.9.1 → 0.9.3

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.
@@ -44,7 +44,7 @@ The composed modules currently coordinate through:
44
44
 
45
45
  These channels are compatibility boundaries. Consolidation may centralize their types
46
46
  but must not silently change semantics or permit sensitive payloads. Selection is an
47
- additive protocol-1 projection of explicit OpenSpec, Goal, or Session Work state;
47
+ additive protocol-1 projection of explicit OpenSpec or Session Work state;
48
48
  suggestion is controlled vocabulary rather than generated prose. Active-run telemetry
49
49
  contains only bounded child and subagent counts derived from Pi lifecycle events. It
50
50
  never carries prompts, names, process identities, arguments, or child output. The deck
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aemonge-dev/pi-stardeck",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "A Vim- and tmux-inspired command deck for Pi",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -41,7 +41,7 @@ The title has four styled groups: lifecycle, a structured active ancestry, focus
41
41
  optional focus detail. Each activity-path segment receives the blue minor `›`; the
42
42
  violet-pink major `⟩` divides runtime ancestry from the green narrative focus. Human
43
43
  prompt text is never converted into focus automatically. OpenSpec may add its exact Task
44
- after another green `›`. Focus selection prefers an exact OpenSpec Task, then Goal, then
44
+ after another green `›`. Focus selection prefers an exact OpenSpec Task, then
45
45
  Session Work. Active turns without a selection use the explicit `OpenSpec › no focus`,
46
46
  `no OpenSpec`, or `no focus` state supplied by the status producer; `Direct response` is
47
47
  never presented as a focus. Empty states are dim while real focus remains bold semantic
@@ -76,14 +76,14 @@ The additive header protocol projects selection independently from live work:
76
76
 
77
77
  ```ts
78
78
  selection: {
79
- source: 'openspec' | 'goal' | 'session-work';
79
+ source: 'openspec' | 'session-work';
80
80
  titles: string[]; // one or two bounded, normalized titles
81
81
  color: HeaderColor;
82
82
  } | null;
83
83
  suggestion: HeaderSuggestion | null; // controlled enum, never generated prose
84
84
  ```
85
85
 
86
- Selection priority remains exact OpenSpec Task, Goal, then Session Work. Older
86
+ Selection priority remains exact OpenSpec Task, then Session Work. Older
87
87
  protocol-1 events may use sanitized non-placeholder work titles as a legacy selection;
88
88
  explicit malformed selection is rejected rather than borrowing a fallback. The
89
89
  suggestion vocabulary is producer-owned, appears only in waiting, blocked, approval,
@@ -133,7 +133,7 @@ the otherwise missing project slot and yields before core project/work informati
133
133
  narrow terminals. Slash-prefixed Git branches collapse the prefix to its initial and cap
134
134
  display at 30 cells with `...` (`feat/persist-versioned-prompts-in-duckdb` →
135
135
  `f/persist-versioned-prompts...`). The right side shows compact OpenSpec Plan/Task
136
- progress, or the simple Goal, Focus, or `no OpenSpec` fallback when no OpenSpec
136
+ progress, or the simple Focus or `no OpenSpec` fallback when no OpenSpec
137
137
  portfolio is available. The footer reserves a four-cell visual split between alignment
138
138
  groups. OpenSpec uses icons instead of repeated labels: task-list `` for Plans and
139
139
  notebook `` for Tasks, with minor `›` between them. Their Nerd Font glyphs exactly
@@ -153,6 +153,13 @@ export function installEmptyDeckFooter(
153
153
  return () => ctx.ui.setFooter(undefined);
154
154
  }
155
155
 
156
+ /** Git-style short id of the running Pi session — 8 hex chars of the
157
+ * UUIDv7, undefined when the id does not match the expected shape. */
158
+ function shortSessionId(ctx: ExtensionContext): string | undefined {
159
+ const short = ctx.sessionManager.getSessionId().slice(0, 8);
160
+ return /^[0-9a-f]{8}$/u.test(short) ? short : undefined;
161
+ }
162
+
156
163
  export default function galacticaContextHeader(
157
164
  pi: ExtensionAPI,
158
165
  options: ContextHeaderOptions = {},
@@ -172,6 +179,7 @@ export default function galacticaContextHeader(
172
179
  let lastActivityAgeSignature = '';
173
180
  let activityAgeTimer: ReturnType<typeof setInterval> | undefined;
174
181
  let sessionGeneration = 0;
182
+ let sessionShortId: string | undefined;
175
183
  let activeCwd = '';
176
184
  let activeCtx: ExtensionContext | undefined;
177
185
  let gitRefreshInFlight = false;
@@ -493,6 +501,7 @@ export default function galacticaContextHeader(
493
501
  resourceTimer = undefined;
494
502
  ++sessionGeneration;
495
503
  activeCtx = ctx;
504
+ sessionShortId = shortSessionId(ctx);
496
505
  activeCwd = '';
497
506
  branch = '';
498
507
  gitAvailable = false;
@@ -578,6 +587,7 @@ export default function galacticaContextHeader(
578
587
  lsp: lspCapability,
579
588
  mcp: mcpCapability,
580
589
  mode: vimMode,
590
+ ...(sessionShortId !== undefined ? { sessionShortId } : {}),
581
591
  },
582
592
  width,
583
593
  theme,
@@ -2,7 +2,6 @@ import type { Theme } from '@earendil-works/pi-coding-agent';
2
2
  import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
3
3
 
4
4
  import type { LspCapability, McpCapability } from './capabilities.ts';
5
- import type { HeaderSelection } from './gauge.ts';
6
5
  import type {
7
6
  FooterTelemetrySnapshot,
8
7
  GitStatusSummary,
@@ -13,7 +12,7 @@ import type {
13
12
  } from './gauge.ts';
14
13
  import type { ResourceTelemetry } from './process-resources.ts';
15
14
  import { formatDeckCountdown } from './gauge.ts';
16
- import { formatDeckWorkLabel } from './title-state.ts';
15
+ import { deckWorkSegments } from './title-state.ts';
17
16
 
18
17
  export interface HeaderDeckModeRail {
19
18
  plain: string;
@@ -41,6 +40,8 @@ export interface HeaderDeckState {
41
40
  lsp: LspCapability | null;
42
41
  mcp: McpCapability | null;
43
42
  mode: VimMode;
43
+ /** Git-style first 8 chars of the Pi session id; worn by the sentinel. */
44
+ sessionShortId?: string;
44
45
  }
45
46
 
46
47
  const ANSI_ESCAPE = new RegExp(
@@ -163,23 +164,40 @@ function wrapFocus(text: string, width: number, maximumLines: number): string[]
163
164
  return lines.slice(0, maximumLines);
164
165
  }
165
166
 
167
+ function mirrorKey(value: string): string {
168
+ return value.toLowerCase().replace(/[^a-z0-9]/gu, '');
169
+ }
170
+
171
+ function bandWrap(
172
+ content: string,
173
+ width: number,
174
+ theme: Theme,
175
+ contentColor: string,
176
+ ): string[] {
177
+ return wrapFocus(content, Math.max(1, width), 2).map((line) =>
178
+ color(theme, contentColor, line),
179
+ );
180
+ }
181
+
166
182
  function titleBandLines(state: HeaderDeckState, width: number, theme: Theme): string[] {
167
- // Line 2 anchor: the clock always leads the band with its icon; the
168
- // Subject follows when the title state carries one, and a fresh session
169
- // (subject null) leaves the clock leading alone — no dangling separator.
170
- // Task 3.1: the unfocused open-goal hint left this band for the notice
171
- // slot (design: "notices absorb the unfocused open-goal hint").
172
- const clock = `${color(theme, 'customMessageLabel', '󰠭')} ${timerTile(state, theme)}`;
183
+ // Human amendment 2026-10-23: line 2 is sacred — Subject and the Work
184
+ // tile only; no timers, no icons, no chrome. The session clock is
185
+ // discarded; liveness lives in the turn cluster on the activity lane.
173
186
  const title = state.header?.title ?? null;
174
187
  if (title) {
175
188
  const subject = safeText(title.subject ?? '');
176
189
  if (!subject) {
177
190
  // Human amendment 2026-10-19: the empty slot renders its dim
178
191
  // placeholder — mirroring the Work side's `— no change owns the
179
- // moment` — instead of leaving the clock leading alone.
180
- return focusWrapLines('— no subject', clock, width, theme, 'dim');
192
+ // moment` — never an empty band while the session talks.
193
+ return bandWrap('— no subject', width, theme, 'dim');
181
194
  }
182
- return focusWrapLines(subject, clock, width, theme, 'accent');
195
+ // When the subject mirrors the focused change (the gate law's
196
+ // "subject mirrors its title"), it renders success-green like the
197
+ // PWD — the mirror visible at a glance.
198
+ const work = title.work;
199
+ const mirrorsFocus = work !== null && mirrorKey(subject) === mirrorKey(work.change);
200
+ return bandWrap(subject, width, theme, mirrorsFocus ? 'success' : 'accent');
183
201
  }
184
202
  // Pre-title publishers: the legacy selection keeps the band alive.
185
203
  const selection = state.header?.selection;
@@ -188,35 +206,16 @@ function titleBandLines(state: HeaderDeckState, width: number, theme: Theme): st
188
206
  .filter(Boolean);
189
207
  if (!selection || titles.length === 0) {
190
208
  if (!state.header) {
191
- // Frozen contract: a publisher that reports nothing at all keeps
192
- // the collapsed frame — clock alone, no placeholder dashes.
193
- return [clock];
209
+ // Frozen contract (amended 2026-10-23): a publisher that reports
210
+ // nothing keeps the collapsed frame — an empty band, no dashes.
211
+ return [''];
194
212
  }
195
- // Amendment: a live session (header present, Work rendering) that
196
- // simply has no subject gets the dim placeholder — the title slot
197
- // never renders empty when the session is talking to us.
198
- return focusWrapLines('— no subject', clock, width, theme, 'dim');
213
+ return bandWrap('— no subject', width, theme, 'dim');
199
214
  }
200
215
  const selectionColor = ['accent', 'success'].includes(selection.color)
201
216
  ? 'accent'
202
217
  : selection.color;
203
- return focusWrapLines(titles.join(' › '), clock, width, theme, selectionColor);
204
- }
205
-
206
- function focusWrapLines(
207
- content: string,
208
- clock: string,
209
- width: number,
210
- theme: Theme,
211
- contentColor: string,
212
- ): string[] {
213
- const lead = `${clock} ${semanticSeparator(theme)} `;
214
- const indent = ' '.repeat(visibleWidth(lead));
215
- const contentWidth = Math.max(1, width - visibleWidth(lead));
216
- return wrapFocus(content, contentWidth, 2).map(
217
- (line, index) =>
218
- `${index === 0 ? lead : indent}${color(theme, contentColor, line)}`,
219
- );
218
+ return bandWrap(titles.join(' › '), width, theme, selectionColor);
220
219
  }
221
220
 
222
221
  function semanticSeparator(theme: Theme): string {
@@ -247,22 +246,9 @@ function workTile(state: HeaderDeckState, theme: Theme): string {
247
246
  if (!title) return '';
248
247
  const work = title.work;
249
248
  if (!work) return color(theme, 'dim', '— no change owns the moment');
250
- return color(
251
- theme,
252
- work.total > 0 && work.done >= work.total ? 'success' : 'text',
253
- formatDeckWorkLabel(work),
254
- );
255
- }
256
-
257
- // Relocation phase (Human-directed): the clock leads the title band beside
258
- // the anchor, runs ride the lifecycle row, and Work keeps the title's
259
- // right end — real values when live, the dim hint when idle.
260
- function timerTile(state: HeaderDeckState, theme: Theme): string {
261
- const elapsed = state.elapsedMs;
262
- const idle = state.idleMs;
263
- return elapsed != null
264
- ? color(theme, 'accent', formatDeckElapsed(elapsed))
265
- : color(theme, 'dim', formatDeckElapsed(idle ?? 0));
249
+ return deckWorkSegments(work)
250
+ .map((segment) => color(theme, segment.color, segment.text, segment.bold === true))
251
+ .join('');
266
252
  }
267
253
 
268
254
  function spanElapsed(ms: number): string {
@@ -305,6 +291,7 @@ const ACTIVITY_LABELS: Record<HeaderActivity['kind'], string> = {
305
291
  regression: 'testing',
306
292
  verification: 'checking',
307
293
  'result-review': 'reviewing result',
294
+ answering: 'answering',
308
295
  recovery: 'recovering',
309
296
  'awaiting-validation': 'validation',
310
297
  'awaiting-input': 'input',
@@ -316,7 +303,10 @@ function activityLabel(state: HeaderDeckState): string {
316
303
  const pathLabel = safeText(pathActivity?.compact || pathActivity?.label || '');
317
304
  if (pathLabel) return pathLabel;
318
305
  const activity = state.header?.work?.activity;
319
- return activity ? ACTIVITY_LABELS[activity.kind] : '';
306
+ // `awaiting-*` kinds are the rest face — they never speak for the band
307
+ // (a focused session still retires; quiet-activity-row amendment).
308
+ if (!activity || activity.kind.startsWith('awaiting-')) return '';
309
+ return ACTIVITY_LABELS[activity.kind];
320
310
  }
321
311
 
322
312
  /** `editing deck.ts` / `reading schema.yaml` / `subagent: scout`. */
@@ -326,17 +316,45 @@ function titleActivityLabel(activity: { class: string; target: string }): string
326
316
  : `${activity.class} ${activity.target}`;
327
317
  }
328
318
 
329
- function activityTile(state: HeaderDeckState, theme: Theme): string {
330
- // Line 3 left: the observed activity owns the slot — `awaiting Human`
331
- // when idle, the legacy observed label for pre-title publishers.
319
+ /** The line-3 band speaks when either side carries a real signal:
320
+ * an observed activity (title-state or legacy) or a turn meter that
321
+ * cost tokens or time. Both idle (`awaiting Human` + `— · 0 tok` or
322
+ * no meter) is zero activity — the band and its seam retire
323
+ * (Human-directed 2026-10-20, quiet-activity-row). */
324
+ /** Live phase word from the header's kind-based activity projection —
325
+ * fills the gaps between tools (thinking, planning, checking) so the
326
+ * band never claims `awaiting Human` mid-turn. The `awaiting-*` kinds
327
+ * are the REST face (awaiting Human review/input after settle): they
328
+ * never speak for the band, or a focused session would never rest
329
+ * (quiet-activity-row amendment). */
330
+ function liveKindLabel(state: HeaderDeckState): string {
331
+ const activity = state.header?.activity ?? null;
332
+ if (!activity || activity.kind.startsWith('awaiting-')) return '';
333
+ return ACTIVITY_LABELS[activity.kind] ?? '';
334
+ }
335
+
336
+ /** The band's current left label: the precise title-state activity when a
337
+ * tool is observed, else the live phase word; empty when truly idle. */
338
+ function observedActivityLabel(state: HeaderDeckState): string {
332
339
  const title = state.header?.title ?? null;
333
340
  if (title) {
334
- return title.activity
335
- ? color(theme, 'text', titleActivityLabel(title.activity))
336
- : color(theme, 'dim', 'awaiting Human');
341
+ return title.activity ? titleActivityLabel(title.activity) : liveKindLabel(state);
337
342
  }
338
- const legacy = activityLabel(state);
339
- return legacy ? color(theme, 'text', legacy) : color(theme, 'dim', 'awaiting Human');
343
+ return activityLabel(state) || liveKindLabel(state);
344
+ }
345
+
346
+ function activityBandSpeaks(state: HeaderDeckState): boolean {
347
+ const activitySpeaks = observedActivityLabel(state) !== '';
348
+ const turn = state.header?.turn ?? null;
349
+ const costSpeaks = turn !== null && (turn.tokens > 0 || turn.elapsedMs > 0);
350
+ return activitySpeaks || costSpeaks;
351
+ }
352
+
353
+ function activityTile(state: HeaderDeckState, theme: Theme): string {
354
+ // Line 3 left: the observed activity owns the slot — the live phase word
355
+ // between tools, `awaiting Human` only when the session truly rests.
356
+ const label = observedActivityLabel(state);
357
+ return label ? color(theme, 'text', label) : color(theme, 'dim', 'awaiting Human');
340
358
  }
341
359
 
342
360
  function formatTurnElapsed(ms: number): string {
@@ -350,18 +368,21 @@ function formatTurnElapsed(ms: number): string {
350
368
  }
351
369
 
352
370
  function turnCostTile(state: HeaderDeckState, theme: Theme): string {
353
- // Line 3 right: the per-turn cost meter. Idle renders the accepted
354
- // `— · 0 tok`; pre-title publishers (no meter) stay silent.
371
+ // Activity lane right: the turn cluster — liveness icon, turn time,
372
+ // and tokens in one aligned group (Human amendment 2026-10-23: the
373
+ // deck's only timer). Idle keeps the accepted dim `— · 0 tok`; the
374
+ // quiet-activity-row contract depends on it.
355
375
  const turn = state.header?.turn ?? null;
356
376
  if (!turn) return '';
357
377
  if (turn.tokens <= 0 && turn.elapsedMs <= 0) {
358
378
  return color(theme, 'dim', '— · 0 tok');
359
379
  }
360
- return color(
380
+ return `${color(theme, 'customMessageLabel', '󰠭')} ${color(
361
381
  theme,
362
- 'accent',
363
- `this turn ${formatTokens(turn.tokens)} · ${formatTurnElapsed(turn.elapsedMs)}`,
364
- );
382
+ 'warning',
383
+ formatTurnElapsed(turn.elapsedMs),
384
+ true,
385
+ )} ${color(theme, 'dim', '·')} ${color(theme, 'warning', formatTokens(turn.tokens))}`;
365
386
  }
366
387
 
367
388
  function gitRight(state: HeaderDeckState, theme: Theme): string {
@@ -478,7 +499,7 @@ function identityCluster(
478
499
  // the bare→bare chip spacing).
479
500
  const texts = new Map<string, string>();
480
501
  for (const item of state.piStatus?.nativeStatuses ?? []) {
481
- if (item.key === 'goal' || texts.has(item.key)) continue;
502
+ if (texts.has(item.key)) continue;
482
503
  texts.set(item.key, item.text);
483
504
  }
484
505
  const parts: string[] = [];
@@ -512,12 +533,17 @@ function identityCluster(
512
533
  const BLOCKED_NOTICE = '\u26d4 blocked';
513
534
  const APPROVAL_NOTICE = 'approval required';
514
535
 
536
+ /** Narrowest frame that hosts the id-bearing sentinel cluster (12 cells
537
+ * plus breathing room for the machine ladder). */
538
+ const SENTINEL_ID_MIN_WIDTH = 24;
539
+ const SENTINEL_BUG = '\u{F082D}';
540
+
515
541
  function companionWarningTexts(state: HeaderDeckState): string[] {
516
542
  // Same sources as the capability-health chips: the known companions'
517
- // native-status texts, goal key filtered, ranked after blocked/approval.
543
+ // native-status texts, ranked after blocked/approval.
518
544
  const texts = new Map<string, string>();
519
545
  for (const item of state.piStatus?.nativeStatuses ?? []) {
520
- if (item.key === 'goal' || texts.has(item.key)) continue;
546
+ if (texts.has(item.key)) continue;
521
547
  texts.set(item.key, item.text);
522
548
  }
523
549
  const warnings: string[] = [];
@@ -535,17 +561,13 @@ function companionWarningTexts(state: HeaderDeckState): string[] {
535
561
 
536
562
  /**
537
563
  * Aggregate the deck's scattered warning signals into one notice slot
538
- * (Task 3.1). Ranked per the spec's accepted mixed render: the unfocused
539
- * open-goal hint, then the companion group (blocked, approval, warning
540
- * texts), then the diagnostics count — `·`-joined, empty when clean.
564
+ * (Task 3.1). Ranked per the spec's accepted mixed render: the companion
565
+ * group (blocked, approval, warning texts), then the diagnostics count —
566
+ * `·`-joined, empty when clean.
541
567
  */
542
568
  export function collectDeckNotices(state: HeaderDeckState): string[] {
543
569
  const header = state.header;
544
- const openGoals = header?.selection?.source === 'goal' ? 0 : (header?.openGoals ?? 0);
545
570
  const notices: string[] = [];
546
- if (openGoals > 0) {
547
- notices.push(`${openGoals} open goal${openGoals === 1 ? '' : 's'} · /goal-focus`);
548
- }
549
571
  if (header?.blocked) notices.push(BLOCKED_NOTICE);
550
572
  if (header?.approvalRequired) notices.push(APPROVAL_NOTICE);
551
573
  notices.push(...companionWarningTexts(state));
@@ -559,18 +581,28 @@ export function collectDeckNotices(state: HeaderDeckState): string[] {
559
581
  return waiver ? notices.filter((notice) => notice !== waiver.notice) : notices;
560
582
  }
561
583
 
562
- function noticeTile(state: HeaderDeckState, theme: Theme): string {
584
+ function noticeTile(state: HeaderDeckState, theme: Theme, width: number): string {
563
585
  const notices = collectDeckNotices(state);
564
586
  // Healthy sentinel (Human-directed 2026-10-19, reversing the retirement):
565
587
  // when the ladder is silent the dim ladybug holds the right end — the
566
588
  // deck's signature glyph and a reserved one (safeText strips 󰠭 from
567
589
  // published text), so the status row always speaks: ⚠ with notices,
568
- // the quiet ladybug when all is well.
569
- if (notices.length === 0) return color(theme, 'dim', '󰠭');
590
+ // the quiet ladybug when all is well. Ladybug-session-id amendment
591
+ // (Human-directed 2026-10-20): a clean frame with a known session id
592
+ // wears it between two ladybugs — blue bugs, purple id — so the deck
593
+ // doubles as a track-and-restore handle; the plain dim bug stays when
594
+ // the id is unknown or the frame is too narrow for the cluster.
595
+ if (notices.length === 0) {
596
+ const id = state.sessionShortId;
597
+ if (id !== undefined && width >= SENTINEL_ID_MIN_WIDTH) {
598
+ return `${color(theme, 'accent', SENTINEL_BUG)} ${color(theme, 'thinkingHigh', id)} ${color(theme, 'accent', SENTINEL_BUG)}`;
599
+ }
600
+ return color(theme, 'dim', SENTINEL_BUG);
601
+ }
570
602
  // Blocked carries the error severity it wore on the deleted lifecycle
571
603
  // tile; every other notice renders at warning.
572
604
  const semantic = notices.includes(BLOCKED_NOTICE) ? 'error' : 'warning';
573
- return color(theme, semantic, `⚠ ${notices.join(' · ')}`);
605
+ return color(theme, semantic, `\u26a0 ${notices.join(' · ')}`);
574
606
  }
575
607
 
576
608
  export function machineCornerSegments(
@@ -761,65 +793,12 @@ function statusAlertTile(
761
793
  return visibleWidth(count) < width ? count : '';
762
794
  }
763
795
 
764
- function formatGoalElapsed(seconds: number): string {
765
- if (seconds < 60) return `${Math.floor(seconds)}s`;
766
- const totalMinutes = Math.floor(seconds / 60);
767
- if (totalMinutes < 60) return `${totalMinutes}m ${Math.floor(seconds % 60)}s`;
768
- return `${Math.floor(totalMinutes / 60)}h ${totalMinutes % 60}m`;
769
- }
770
-
771
796
  function formatTokens(tokens: number): string {
772
797
  if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
773
798
  if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`;
774
799
  return `${Math.floor(tokens)}`;
775
800
  }
776
801
 
777
- const GOAL_STATUS_COLORS: Record<string, string> = {
778
- active: 'accent',
779
- complete: 'success',
780
- paused: 'warning',
781
- blocked: 'error',
782
- usage_limited: 'warning',
783
- budget_limited: 'warning',
784
- queued: 'dim',
785
- };
786
-
787
- function goalBadgeTile(
788
- goal: NonNullable<HeaderSelection['goal']>,
789
- theme: Theme,
790
- ): string {
791
- const semantic = GOAL_STATUS_COLORS[goal.status] ?? 'text';
792
- const elapsed =
793
- goal.elapsedSeconds !== undefined
794
- ? ` · ${formatGoalElapsed(goal.elapsedSeconds)}`
795
- : '';
796
- return `${color(theme, semantic, `● ${goal.status}`)}${color(theme, 'dim', elapsed)}`;
797
- }
798
-
799
- function goalTreeLines(
800
- goal: NonNullable<HeaderSelection['goal']>,
801
- width: number,
802
- theme: Theme,
803
- ): string[] {
804
- if (width < 56) return [];
805
- const rows: string[] = [];
806
- if (goal.tasksTotal !== undefined && goal.tasksDone !== undefined) {
807
- const active =
808
- goal.tasksActive !== undefined && goal.tasksActive > 0
809
- ? ` · ${goal.tasksActive} active`
810
- : '';
811
- rows.push(` ├ tasks ${goal.tasksDone}/${goal.tasksTotal} done${active}`);
812
- }
813
- if (goal.tokensUsed !== undefined) {
814
- const budget =
815
- goal.tokenBudget !== undefined && goal.tokenBudget > 0
816
- ? ` · budget ${Math.min(999, Math.round((goal.tokensUsed / goal.tokenBudget) * 100))}%`
817
- : '';
818
- rows.push(` └ usage ${formatTokens(goal.tokensUsed)} tokens${budget}`);
819
- }
820
- return rows.map((line) => color(theme, 'dim', line));
821
- }
822
-
823
802
  export function renderHeaderDeck(
824
803
  state: HeaderDeckState,
825
804
  width: number,
@@ -831,41 +810,35 @@ export function renderHeaderDeck(
831
810
  const lineColor = 'thinkingHigh';
832
811
  const solidRule = color(theme, lineColor, '─'.repeat(boundedWidth));
833
812
  const dottedRule = `\u001b[2m${color(theme, 'text', '┈'.repeat(boundedWidth))}\u001b[22m`;
834
- // Line 2 (four-line deck): the clock and Subject lead with Work on the
835
- // right end; goal progress rows keep their home beneath this line.
836
- // Narrow frames: the Work slot yields before the clock anchor does.
837
- const goalDetail =
838
- state.header?.selection?.source === 'goal'
839
- ? state.header.selection.goal
840
- : undefined;
841
- const titleRight =
842
- boundedWidth >= 40
843
- ? goalDetail
844
- ? goalBadgeTile(goalDetail, theme)
845
- : workTile(state, theme)
846
- : '';
813
+ // Line 2 (four-line deck): the Subject leads with Work on the right end
814
+ // (Human amendment 2026-10-23 — no clock, no chrome). Narrow frames: the
815
+ // Work slot yields before the Subject does.
816
+ const titleRight = boundedWidth >= 40 ? workTile(state, theme) : '';
847
817
  const focusLines = titleBandLines(state, boundedWidth, theme);
848
818
  const titleRows = [
849
819
  alignedSingleRow(focusLines[0] ?? '', titleRight, boundedWidth),
850
820
  ...focusLines.slice(1),
851
821
  ];
852
- const goalRows = goalDetail ? goalTreeLines(goalDetail, boundedWidth, theme) : [];
853
822
 
854
823
  // Line 3 (four-line deck): Activity left with the per-turn cost right —
855
824
  // the superseded lifecycle/suggestion/runs tiles are gone; Work,
856
825
  // Activity, and the spans rows are their replacements. The spans rows
857
826
  // keep their home directly beneath this line.
858
- const activityRow = alignedSingleRow(
859
- activityTile(state, theme),
860
- turnCostTile(state, theme),
861
- boundedWidth,
862
- );
827
+ const spanRows = spanTreeRows(state, boundedWidth, theme);
828
+ const bandSpeaks = activityBandSpeaks(state);
829
+ const activityRow = bandSpeaks
830
+ ? alignedSingleRow(
831
+ activityTile(state, theme),
832
+ turnCostTile(state, theme),
833
+ boundedWidth,
834
+ )
835
+ : '';
863
836
  // deck-reshape, swap amendment: the bottom row carries the MACHINE
864
837
  // tail on the left (yielding right-to-left into whatever width the
865
838
  // status leaves) and the status on the right — which always speaks
866
839
  // now: ⚠ notices or the dim ladybug sentinel when clean. The engine
867
840
  // group moved to line 1 right.
868
- const statusRight = noticeTile(state, theme);
841
+ const statusRight = noticeTile(state, theme, boundedWidth);
869
842
  const machineRow = machineCornerLadder(
870
843
  state,
871
844
  theme,
@@ -879,7 +852,9 @@ export function renderHeaderDeck(
879
852
  // Band adjacency (Human-directed, accepted mockups; third 2.4
880
853
  // amendment): the deck opens with a full ─ rule directly beneath the
881
854
  // line-1 strip; the internal seams stay dotted ┈ between lines 2 and
882
- // 3 and between line 3 (spans rows included) and the status row.
855
+ // 3 and between line 3 (spans rows included) and the status row —
856
+ // except the 2/3 seam retires with a silent, spanless line-3 band
857
+ // (quiet-activity-row): idle + zero cost never narrates itself.
883
858
  const identity = identityCluster(state, theme);
884
859
  const corner = deckCornerRight(state, theme, boundedWidth);
885
860
  const alert = statusAlertTile(
@@ -896,10 +871,9 @@ export function renderHeaderDeck(
896
871
  ),
897
872
  solidRule,
898
873
  ...titleRows,
899
- ...goalRows,
900
- dottedRule,
901
- activityRow,
902
- ...spanTreeRows(state, boundedWidth, theme),
874
+ ...(bandSpeaks || spanRows.length > 0 ? [dottedRule] : []),
875
+ ...(bandSpeaks ? [activityRow] : []),
876
+ ...spanRows,
903
877
  ...(engineRow.trim() !== '' ? [dottedRule, engineRow] : []),
904
878
  ];
905
879
  return rows.map((line) => fitWithoutDanglingSeparator(line, boundedWidth));