@aemonge-dev/pi-stardeck 0.9.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aemonge-dev/pi-stardeck",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "A Vim- and tmux-inspired command deck for Pi",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -12,7 +12,7 @@ import type {
12
12
  } from './gauge.ts';
13
13
  import type { ResourceTelemetry } from './process-resources.ts';
14
14
  import { formatDeckCountdown } from './gauge.ts';
15
- import { formatDeckWorkLabel } from './title-state.ts';
15
+ import { deckWorkSegments } from './title-state.ts';
16
16
 
17
17
  export interface HeaderDeckModeRail {
18
18
  plain: string;
@@ -164,21 +164,40 @@ function wrapFocus(text: string, width: number, maximumLines: number): string[]
164
164
  return lines.slice(0, maximumLines);
165
165
  }
166
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
+
167
182
  function titleBandLines(state: HeaderDeckState, width: number, theme: Theme): string[] {
168
- // Line 2 anchor: the clock always leads the band with its icon; the
169
- // Subject follows when the title state carries one, and a fresh session
170
- // (subject null) leaves the clock leading alone — no dangling separator.
171
- 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.
172
186
  const title = state.header?.title ?? null;
173
187
  if (title) {
174
188
  const subject = safeText(title.subject ?? '');
175
189
  if (!subject) {
176
190
  // Human amendment 2026-10-19: the empty slot renders its dim
177
191
  // placeholder — mirroring the Work side's `— no change owns the
178
- // moment` — instead of leaving the clock leading alone.
179
- 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');
180
194
  }
181
- 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');
182
201
  }
183
202
  // Pre-title publishers: the legacy selection keeps the band alive.
184
203
  const selection = state.header?.selection;
@@ -187,35 +206,16 @@ function titleBandLines(state: HeaderDeckState, width: number, theme: Theme): st
187
206
  .filter(Boolean);
188
207
  if (!selection || titles.length === 0) {
189
208
  if (!state.header) {
190
- // Frozen contract: a publisher that reports nothing at all keeps
191
- // the collapsed frame — clock alone, no placeholder dashes.
192
- 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 [''];
193
212
  }
194
- // Amendment: a live session (header present, Work rendering) that
195
- // simply has no subject gets the dim placeholder — the title slot
196
- // never renders empty when the session is talking to us.
197
- return focusWrapLines('— no subject', clock, width, theme, 'dim');
213
+ return bandWrap('— no subject', width, theme, 'dim');
198
214
  }
199
215
  const selectionColor = ['accent', 'success'].includes(selection.color)
200
216
  ? 'accent'
201
217
  : selection.color;
202
- return focusWrapLines(titles.join(' › '), clock, width, theme, selectionColor);
203
- }
204
-
205
- function focusWrapLines(
206
- content: string,
207
- clock: string,
208
- width: number,
209
- theme: Theme,
210
- contentColor: string,
211
- ): string[] {
212
- const lead = `${clock} ${semanticSeparator(theme)} `;
213
- const indent = ' '.repeat(visibleWidth(lead));
214
- const contentWidth = Math.max(1, width - visibleWidth(lead));
215
- return wrapFocus(content, contentWidth, 2).map(
216
- (line, index) =>
217
- `${index === 0 ? lead : indent}${color(theme, contentColor, line)}`,
218
- );
218
+ return bandWrap(titles.join(' › '), width, theme, selectionColor);
219
219
  }
220
220
 
221
221
  function semanticSeparator(theme: Theme): string {
@@ -246,22 +246,9 @@ function workTile(state: HeaderDeckState, theme: Theme): string {
246
246
  if (!title) return '';
247
247
  const work = title.work;
248
248
  if (!work) return color(theme, 'dim', '— no change owns the moment');
249
- return color(
250
- theme,
251
- work.total > 0 && work.done >= work.total ? 'success' : 'text',
252
- formatDeckWorkLabel(work),
253
- );
254
- }
255
-
256
- // Relocation phase (Human-directed): the clock leads the title band beside
257
- // the anchor, runs ride the lifecycle row, and Work keeps the title's
258
- // right end — real values when live, the dim hint when idle.
259
- function timerTile(state: HeaderDeckState, theme: Theme): string {
260
- const elapsed = state.elapsedMs;
261
- const idle = state.idleMs;
262
- return elapsed != null
263
- ? color(theme, 'accent', formatDeckElapsed(elapsed))
264
- : color(theme, 'dim', formatDeckElapsed(idle ?? 0));
249
+ return deckWorkSegments(work)
250
+ .map((segment) => color(theme, segment.color, segment.text, segment.bold === true))
251
+ .join('');
265
252
  }
266
253
 
267
254
  function spanElapsed(ms: number): string {
@@ -381,18 +368,21 @@ function formatTurnElapsed(ms: number): string {
381
368
  }
382
369
 
383
370
  function turnCostTile(state: HeaderDeckState, theme: Theme): string {
384
- // Line 3 right: the per-turn cost meter. Idle renders the accepted
385
- // `— · 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.
386
375
  const turn = state.header?.turn ?? null;
387
376
  if (!turn) return '';
388
377
  if (turn.tokens <= 0 && turn.elapsedMs <= 0) {
389
378
  return color(theme, 'dim', '— · 0 tok');
390
379
  }
391
- return color(
380
+ return `${color(theme, 'customMessageLabel', '󰠭')} ${color(
392
381
  theme,
393
- 'accent',
394
- `this turn ${formatTokens(turn.tokens)} · ${formatTurnElapsed(turn.elapsedMs)}`,
395
- );
382
+ 'warning',
383
+ formatTurnElapsed(turn.elapsedMs),
384
+ true,
385
+ )} ${color(theme, 'dim', '·')} ${color(theme, 'warning', formatTokens(turn.tokens))}`;
396
386
  }
397
387
 
398
388
  function gitRight(state: HeaderDeckState, theme: Theme): string {
@@ -820,9 +810,9 @@ export function renderHeaderDeck(
820
810
  const lineColor = 'thinkingHigh';
821
811
  const solidRule = color(theme, lineColor, '─'.repeat(boundedWidth));
822
812
  const dottedRule = `\u001b[2m${color(theme, 'text', '┈'.repeat(boundedWidth))}\u001b[22m`;
823
- // Line 2 (four-line deck): the clock and Subject lead with Work on the
824
- // right end. Narrow frames: the Work slot yields before the clock anchor
825
- // does.
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.
826
816
  const titleRight = boundedWidth >= 40 ? workTile(state, theme) : '';
827
817
  const focusLines = titleBandLines(state, boundedWidth, theme);
828
818
  const titleRows = [
@@ -42,8 +42,10 @@ const TURN_ELAPSED_MAX = (99 * 60 + 59) * 1_000 + 999;
42
42
  export type DeckTitleWorkState = {
43
43
  change: string;
44
44
  task?: string;
45
- done: number;
46
- total: number;
45
+ done?: number;
46
+ total?: number;
47
+ schemaName?: string;
48
+ ladder?: { done: number; total: number; rung?: string };
47
49
  };
48
50
 
49
51
  export type DeckTitleActivityState = {
@@ -116,12 +118,47 @@ function parseTitleWork(value: unknown): DeckTitleWorkState | null | undefined {
116
118
  if (value === null) return null;
117
119
  if (!isRecord(value)) return undefined;
118
120
  const change = boundedText(value.change, IDENTIFIER_MAX);
119
- const done = boundedTotal(value.done);
120
- const total = boundedTotal(value.total);
121
- if (!change || done === null || total === null) return undefined;
121
+ if (!change) return undefined;
122
+ // Counts are optional as a pair: absent means unknown (never fabricated
123
+ // zeros); present-but-invalid rejects the object.
124
+ const hasCounts = 'done' in value && 'total' in value;
125
+ if ('done' in value !== 'total' in value) return undefined;
126
+ const boundedDone = hasCounts ? boundedTotal(value.done) : undefined;
127
+ const boundedTotalValue = hasCounts ? boundedTotal(value.total) : undefined;
128
+ if (hasCounts && (boundedDone === null || boundedTotalValue === null)) {
129
+ return undefined;
130
+ }
122
131
  const task = boundedText(value.task, TASK_ID_MAX, { required: false });
123
132
  if (task === null) return undefined;
124
- return task === undefined ? { change, done, total } : { change, task, done, total };
133
+ const schemaName = boundedText(value.schemaName, IDENTIFIER_MAX, {
134
+ required: false,
135
+ });
136
+ if (schemaName === null) return undefined;
137
+ let ladder: { done: number; total: number; rung?: string } | undefined;
138
+ if ('ladder' in value) {
139
+ if (!isRecord(value.ladder)) return undefined;
140
+ const ladderDone = boundedTotal(value.ladder.done);
141
+ const ladderTotal = boundedTotal(value.ladder.total);
142
+ if (ladderDone === null || ladderTotal === null) return undefined;
143
+ const ladderRung = boundedText(value.ladder.rung, 32, { required: false });
144
+ if (ladderRung === null) return undefined;
145
+ ladder = {
146
+ done: ladderDone,
147
+ total: ladderTotal,
148
+ ...(ladderRung ? { rung: ladderRung } : {}),
149
+ };
150
+ }
151
+ const counts =
152
+ typeof boundedDone === 'number' && typeof boundedTotalValue === 'number'
153
+ ? { done: boundedDone, total: boundedTotalValue }
154
+ : {};
155
+ const extras = {
156
+ ...(schemaName ? { schemaName } : {}),
157
+ ...(ladder ? { ladder } : {}),
158
+ };
159
+ return task === undefined
160
+ ? { change, ...counts, ...extras }
161
+ : { change, task, ...counts, ...extras };
125
162
  }
126
163
 
127
164
  function parseTitleWaiver(value: unknown): DeckTitleWaiverState | null | undefined {
@@ -189,7 +226,46 @@ export function parseDeckTurnCost(value: unknown): DeckTurnCost | null {
189
226
  }
190
227
  }
191
228
 
192
- /** `change · done/total` — the Work label from the accepted mockups. */
229
+ /** One colored piece of the Work label. */
230
+ export type DeckWorkSegment = { text: string; color: string; bold?: boolean };
231
+
232
+ /**
233
+ * The Work label as colored segments — the human-chosen design: `change ⟩
234
+ * done/total rung › 󰄱 done/total` in one hue per state (accent, success at
235
+ * complete with a filled checkbox and bold counts). The rung name comes
236
+ * straight from OpenSpec's status artifacts — never an invented phase
237
+ * word. Separators are chrome with hard deck-wide colors — `⟩`
238
+ * thinkingHigh-bold like every semantic separator, `›` accent like every
239
+ * minor one. Unknown facts degrade honestly; the bare change is the floor.
240
+ */
241
+ export function deckWorkSegments(work: DeckTitleWorkState): DeckWorkSegment[] {
242
+ const complete =
243
+ work.done !== undefined &&
244
+ work.total !== undefined &&
245
+ work.total > 0 &&
246
+ work.done >= work.total;
247
+ const base = complete ? 'success' : 'accent';
248
+ const segments: DeckWorkSegment[] = [{ text: work.change, color: base }];
249
+ const ladder = work.ladder;
250
+ if (ladder) {
251
+ segments.push({ text: ' ⟩ ', color: 'thinkingHigh', bold: true });
252
+ const group = `${ladder.done}/${ladder.total}${ladder.rung ? ` ${ladder.rung}` : ''}`;
253
+ segments.push({ text: group, color: base });
254
+ }
255
+ if (work.done !== undefined && work.total !== undefined) {
256
+ segments.push({ text: ' › ', color: 'accent' });
257
+ segments.push({
258
+ text: `${complete ? '✔' : '󰄱'} ${work.done}/${work.total}`,
259
+ color: base,
260
+ ...(complete ? { bold: true } : {}),
261
+ });
262
+ }
263
+ return segments;
264
+ }
265
+
266
+ /** The plain-text Work label — the segment join without colors. */
193
267
  export function formatDeckWorkLabel(work: DeckTitleWorkState): string {
194
- return `${work.change} · ${work.done}/${work.total}`;
268
+ return deckWorkSegments(work)
269
+ .map((segment) => segment.text)
270
+ .join('');
195
271
  }
@@ -204,12 +204,19 @@ export function parseApplyInstructions(value: unknown): ApplyInstructionsResult
204
204
  );
205
205
  }
206
206
 
207
- const progress = isRecord(value.progress)
207
+ const state = typeof value.state === 'string' ? value.state : undefined;
208
+ const reportedProgress = isRecord(value.progress)
208
209
  ? {
209
210
  total: finiteNumber(value.progress.total),
210
211
  complete: finiteNumber(value.progress.complete),
211
212
  }
212
213
  : undefined;
214
+ // A blocked apply (mid-ladder, artifacts missing) reports zero progress;
215
+ // those zeros must never shadow the honest `openspec list` counts.
216
+ const progress =
217
+ state !== 'blocked' && (reportedProgress?.total ?? 0) > 0
218
+ ? reportedProgress
219
+ : undefined;
213
220
 
214
221
  return { tasks, tasksPath, progress };
215
222
  }
@@ -227,6 +234,33 @@ function chooseChange(
227
234
  return selected;
228
235
  }
229
236
 
237
+ function parseOpenSpecStatus(value: unknown): {
238
+ schemaName?: string;
239
+ ladder?: { done: number; total: number; rung?: string };
240
+ } {
241
+ if (!isRecord(value)) return {};
242
+ const schemaName =
243
+ typeof value.schemaName === 'string' && value.schemaName.trim()
244
+ ? value.schemaName.trim()
245
+ : undefined;
246
+ let ladder: { done: number; total: number; rung?: string } | undefined;
247
+ if (Array.isArray(value.artifacts) && value.artifacts.length > 0) {
248
+ let done = 0;
249
+ let rung: string | undefined;
250
+ for (const artifact of value.artifacts) {
251
+ if (!isRecord(artifact)) continue;
252
+ const id = typeof artifact.id === 'string' ? artifact.id : undefined;
253
+ if (rung === undefined && id) rung = id;
254
+ if (artifact.status === 'done') {
255
+ done += 1;
256
+ if (id) rung = id;
257
+ }
258
+ }
259
+ ladder = { done, total: value.artifacts.length, ...(rung ? { rung } : {}) };
260
+ }
261
+ return { ...(schemaName ? { schemaName } : {}), ...(ladder ? { ladder } : {}) };
262
+ }
263
+
230
264
  async function runJson(
231
265
  runner: CommandRunner,
232
266
  args: string[],
@@ -326,6 +360,23 @@ export async function collectOpenSpec(options: {
326
360
  // The list result is still useful when an optional title lookup fails.
327
361
  }
328
362
 
363
+ let schemaName: string | undefined;
364
+ let ladder: OpenSpecState['ladder'];
365
+ try {
366
+ const status = parseOpenSpecStatus(
367
+ await runJson(
368
+ options.runner,
369
+ ['status', '--change', change.name, '--json'],
370
+ projectRoot,
371
+ 'openspec status',
372
+ ),
373
+ );
374
+ schemaName = status.schemaName;
375
+ ladder = status.ladder;
376
+ } catch {
377
+ // Ladder facts are optional; the list counts remain valid without them.
378
+ }
379
+
329
380
  let tasks: OpenSpecTask[] = [];
330
381
  let tasksPath: string | undefined;
331
382
  let instructionProgress: ApplyInstructionsResult['progress'];
@@ -383,6 +434,8 @@ export async function collectOpenSpec(options: {
383
434
  allTasks: tasks,
384
435
  ...(tasksPath ? { tasksPath } : {}),
385
436
  ...(hierarchy ? { hierarchy } : {}),
437
+ ...(schemaName ? { schemaName } : {}),
438
+ ...(ladder ? { ladder } : {}),
386
439
  meta: {
387
440
  source: 'openspec-cli',
388
441
  refreshedAt: now,
@@ -38,8 +38,10 @@ const NOTICE_MAX = 180;
38
38
  export type TitleWorkState = {
39
39
  change: string;
40
40
  task?: string;
41
- done: number;
42
- total: number;
41
+ done?: number;
42
+ total?: number;
43
+ schemaName?: string;
44
+ ladder?: { done: number; total: number; rung?: string };
43
45
  };
44
46
 
45
47
  export type TitleActivityState = {
@@ -109,12 +111,47 @@ function parseTitleWork(value: unknown): TitleWorkState | null | undefined {
109
111
  if (value === null) return null;
110
112
  if (!isRecord(value)) return undefined;
111
113
  const change = boundedText(value.change, IDENTIFIER_MAX);
112
- const done = boundedTotal(value.done);
113
- const total = boundedTotal(value.total);
114
- if (!change || done === null || total === null) return undefined;
114
+ if (!change) return undefined;
115
+ // Counts are optional as a pair: absent means unknown (never fabricated
116
+ // zeros); present-but-invalid rejects the object.
117
+ const hasCounts = 'done' in value && 'total' in value;
118
+ if ('done' in value !== 'total' in value) return undefined;
119
+ const boundedDone = hasCounts ? boundedTotal(value.done) : undefined;
120
+ const boundedTotalValue = hasCounts ? boundedTotal(value.total) : undefined;
121
+ if (hasCounts && (boundedDone === null || boundedTotalValue === null)) {
122
+ return undefined;
123
+ }
115
124
  const task = boundedText(value.task, TASK_ID_MAX, { required: false });
116
125
  if (task === null) return undefined;
117
- return task === undefined ? { change, done, total } : { change, task, done, total };
126
+ const schemaName = boundedText(value.schemaName, IDENTIFIER_MAX, {
127
+ required: false,
128
+ });
129
+ if (schemaName === null) return undefined;
130
+ let ladder: { done: number; total: number; rung?: string } | undefined;
131
+ if ('ladder' in value) {
132
+ if (!isRecord(value.ladder)) return undefined;
133
+ const ladderDone = boundedTotal(value.ladder.done);
134
+ const ladderTotal = boundedTotal(value.ladder.total);
135
+ if (ladderDone === null || ladderTotal === null) return undefined;
136
+ const ladderRung = boundedText(value.ladder.rung, 32, { required: false });
137
+ if (ladderRung === null) return undefined;
138
+ ladder = {
139
+ done: ladderDone,
140
+ total: ladderTotal,
141
+ ...(ladderRung ? { rung: ladderRung } : {}),
142
+ };
143
+ }
144
+ const counts =
145
+ typeof boundedDone === 'number' && typeof boundedTotalValue === 'number'
146
+ ? { done: boundedDone, total: boundedTotalValue }
147
+ : {};
148
+ const extras = {
149
+ ...(schemaName ? { schemaName } : {}),
150
+ ...(ladder ? { ladder } : {}),
151
+ };
152
+ return task === undefined
153
+ ? { change, ...counts, ...extras }
154
+ : { change, task, ...counts, ...extras };
118
155
  }
119
156
 
120
157
  function parseTitleWaiver(value: unknown): TitleWaiverState | null | undefined {
@@ -180,16 +217,28 @@ export function deriveTitleWork(
180
217
  done: boundedCount(state.hierarchy?.tasks?.completed, state.completedTasks),
181
218
  total: boundedCount(state.hierarchy?.tasks?.total, state.totalTasks),
182
219
  });
220
+ // Human steering 2026-10-23: no invented phase words — the ladder's
221
+ // rung name comes straight from OpenSpec.
222
+ const extras = (state: OpenSpecState | undefined) => {
223
+ if (!state) return {};
224
+ return {
225
+ ...(state.schemaName ? { schemaName: state.schemaName } : {}),
226
+ ...(state.ladder ? { ladder: state.ladder } : {}),
227
+ };
228
+ };
183
229
  if (focus.mode === 'task') {
184
230
  const state = openspec && openspec.changeId === focus.change ? openspec : undefined;
231
+ const taskCounts = state ? counts(state) : undefined;
185
232
  return {
186
233
  change: focus.change,
187
234
  task: focus.taskId,
188
- ...(state ? counts(state) : { done: 0, total: 0 }),
235
+ ...(taskCounts ?? {}),
236
+ ...extras(state),
189
237
  };
190
238
  }
191
239
  if (!openspec) return null;
192
- return { change: openspec.changeId, ...counts(openspec) };
240
+ const taskCounts = counts(openspec);
241
+ return { change: openspec.changeId, ...taskCounts, ...extras(openspec) };
193
242
  }
194
243
 
195
244
  /**
@@ -89,6 +89,8 @@ export interface OpenSpecState {
89
89
  allTasks?: OpenSpecTask[];
90
90
  tasksPath?: string;
91
91
  hierarchy?: OpenSpecHierarchyProgress;
92
+ schemaName?: string;
93
+ ladder?: { done: number; total: number; rung?: string };
92
94
  meta: SourceMeta;
93
95
  }
94
96