@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.
- package/docs/architecture.md +1 -1
- package/package.json +1 -1
- package/packages/pi-stardeck/README.md +4 -4
- package/packages/pi-stardeck/index.ts +10 -0
- package/packages/pi-stardeck/src/deck.ts +132 -158
- package/packages/pi-stardeck/src/gauge.ts +1 -58
- package/packages/pi-stardeck/src/title-state.ts +84 -8
- package/packages/pi-stardeck/src/title.ts +1 -0
- package/packages/pi-vim-top-border/prompt-rail.ts +0 -2
- package/packages/status/README.md +13 -24
- package/packages/status/index.ts +17 -144
- package/packages/status/src/active-work.ts +0 -9
- package/packages/status/src/ledger.ts +0 -3
- package/packages/status/src/openspec.ts +54 -1
- package/packages/status/src/publisher.ts +8 -173
- package/packages/status/src/reducer.ts +0 -21
- package/packages/status/src/title-activity.ts +18 -2
- package/packages/status/src/title-state.ts +57 -8
- package/packages/status/src/types.ts +2 -7
- package/packages/status/src/goal-block.ts +0 -377
- package/packages/status/src/goal.ts +0 -170
package/docs/architecture.md
CHANGED
|
@@ -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
|
|
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
|
@@ -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
|
|
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' | '
|
|
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,
|
|
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
|
|
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 {
|
|
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
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
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` —
|
|
180
|
-
return
|
|
192
|
+
// moment` — never an empty band while the session talks.
|
|
193
|
+
return bandWrap('— no subject', width, theme, 'dim');
|
|
181
194
|
}
|
|
182
|
-
|
|
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
|
|
192
|
-
// the collapsed frame —
|
|
193
|
-
return [
|
|
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
|
-
|
|
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
|
|
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
|
|
251
|
-
theme,
|
|
252
|
-
|
|
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
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
339
|
-
|
|
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
|
-
//
|
|
354
|
-
//
|
|
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
|
-
'
|
|
363
|
-
|
|
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 (
|
|
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,
|
|
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 (
|
|
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
|
|
539
|
-
*
|
|
540
|
-
*
|
|
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
|
-
|
|
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,
|
|
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
|
|
835
|
-
//
|
|
836
|
-
//
|
|
837
|
-
const
|
|
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
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
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
|
-
...
|
|
900
|
-
|
|
901
|
-
|
|
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));
|