gsd-pi 2.36.0-dev.f887f4e → 2.37.0-dev.3186675

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.
Files changed (71) hide show
  1. package/dist/resources/extensions/cmux/index.js +321 -0
  2. package/dist/resources/extensions/cmux/package.json +7 -0
  3. package/dist/resources/extensions/gsd/auto-dashboard.js +334 -104
  4. package/dist/resources/extensions/gsd/auto-loop.js +29 -4
  5. package/dist/resources/extensions/gsd/auto.js +35 -5
  6. package/dist/resources/extensions/gsd/commands-cmux.js +120 -0
  7. package/dist/resources/extensions/gsd/commands-prefs-wizard.js +1 -1
  8. package/dist/resources/extensions/gsd/commands.js +51 -1
  9. package/dist/resources/extensions/gsd/docs/preferences-reference.md +25 -0
  10. package/dist/resources/extensions/gsd/git-service.js +9 -1
  11. package/dist/resources/extensions/gsd/history.js +2 -1
  12. package/dist/resources/extensions/gsd/index.js +5 -0
  13. package/dist/resources/extensions/gsd/metrics.js +4 -2
  14. package/dist/resources/extensions/gsd/notifications.js +10 -1
  15. package/dist/resources/extensions/gsd/preferences-types.js +2 -0
  16. package/dist/resources/extensions/gsd/preferences-validation.js +29 -0
  17. package/dist/resources/extensions/gsd/preferences.js +3 -0
  18. package/dist/resources/extensions/gsd/prompts/research-milestone.md +4 -3
  19. package/dist/resources/extensions/gsd/prompts/research-slice.md +3 -2
  20. package/dist/resources/extensions/gsd/session-lock.js +26 -6
  21. package/dist/resources/extensions/gsd/templates/preferences.md +6 -0
  22. package/dist/resources/extensions/search-the-web/native-search.js +45 -4
  23. package/dist/resources/extensions/shared/format-utils.js +5 -41
  24. package/dist/resources/extensions/shared/layout-utils.js +46 -0
  25. package/dist/resources/extensions/shared/mod.js +2 -1
  26. package/dist/resources/extensions/shared/terminal.js +5 -0
  27. package/dist/resources/extensions/subagent/index.js +180 -60
  28. package/package.json +1 -1
  29. package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
  30. package/packages/pi-coding-agent/dist/core/extensions/loader.js +8 -4
  31. package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
  32. package/packages/pi-coding-agent/package.json +1 -1
  33. package/packages/pi-coding-agent/src/core/extensions/loader.ts +8 -4
  34. package/packages/pi-tui/dist/terminal-image.d.ts.map +1 -1
  35. package/packages/pi-tui/dist/terminal-image.js +4 -0
  36. package/packages/pi-tui/dist/terminal-image.js.map +1 -1
  37. package/packages/pi-tui/src/terminal-image.ts +5 -0
  38. package/pkg/package.json +1 -1
  39. package/src/resources/extensions/cmux/index.ts +384 -0
  40. package/src/resources/extensions/cmux/package.json +7 -0
  41. package/src/resources/extensions/gsd/auto-dashboard.ts +363 -116
  42. package/src/resources/extensions/gsd/auto-loop.ts +66 -6
  43. package/src/resources/extensions/gsd/auto.ts +45 -5
  44. package/src/resources/extensions/gsd/commands-cmux.ts +143 -0
  45. package/src/resources/extensions/gsd/commands-prefs-wizard.ts +1 -1
  46. package/src/resources/extensions/gsd/commands.ts +54 -1
  47. package/src/resources/extensions/gsd/docs/preferences-reference.md +25 -0
  48. package/src/resources/extensions/gsd/git-service.ts +12 -1
  49. package/src/resources/extensions/gsd/history.ts +2 -1
  50. package/src/resources/extensions/gsd/index.ts +8 -0
  51. package/src/resources/extensions/gsd/metrics.ts +4 -2
  52. package/src/resources/extensions/gsd/notifications.ts +10 -1
  53. package/src/resources/extensions/gsd/preferences-types.ts +13 -0
  54. package/src/resources/extensions/gsd/preferences-validation.ts +26 -0
  55. package/src/resources/extensions/gsd/preferences.ts +4 -0
  56. package/src/resources/extensions/gsd/prompts/research-milestone.md +4 -3
  57. package/src/resources/extensions/gsd/prompts/research-slice.md +3 -2
  58. package/src/resources/extensions/gsd/session-lock.ts +41 -6
  59. package/src/resources/extensions/gsd/templates/preferences.md +6 -0
  60. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +39 -1
  61. package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +19 -0
  62. package/src/resources/extensions/gsd/tests/cmux.test.ts +122 -0
  63. package/src/resources/extensions/gsd/tests/preferences.test.ts +23 -0
  64. package/src/resources/extensions/gsd/tests/session-lock-regression.test.ts +45 -0
  65. package/src/resources/extensions/search-the-web/native-search.ts +50 -4
  66. package/src/resources/extensions/shared/format-utils.ts +5 -44
  67. package/src/resources/extensions/shared/layout-utils.ts +49 -0
  68. package/src/resources/extensions/shared/mod.ts +7 -4
  69. package/src/resources/extensions/shared/terminal.ts +5 -0
  70. package/src/resources/extensions/shared/tests/format-utils.test.ts +5 -3
  71. package/src/resources/extensions/subagent/index.ts +236 -79
@@ -10,15 +10,19 @@ import type { ExtensionContext, ExtensionCommandContext, SessionMessageEntry } f
10
10
  import type { GSDState } from "./types.js";
11
11
  import { getCurrentBranch } from "./worktree.js";
12
12
  import { getActiveHook } from "./post-unit-hooks.js";
13
- import { getLedger, getProjectTotals, formatCost, formatTokenCount, formatTierSavings } from "./metrics.js";
13
+ import { getLedger, getProjectTotals } from "./metrics.js";
14
14
  import {
15
15
  resolveMilestoneFile,
16
16
  resolveSliceFile,
17
17
  } from "./paths.js";
18
18
  import { parseRoadmap, parsePlan } from "./files.js";
19
- import { readFileSync, existsSync } from "node:fs";
19
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
20
+ import { execFileSync } from "node:child_process";
20
21
  import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
21
22
  import { makeUI, GLYPH, INDENT } from "../shared/mod.js";
23
+ import { computeProgressScore } from "./progress-score.js";
24
+ import { getActiveWorktreeName } from "./worktree-command.js";
25
+ import { loadEffectiveGSDPreferences, getGlobalGSDPreferencesPath } from "./preferences.js";
22
26
 
23
27
  // ─── Dashboard Data ───────────────────────────────────────────────────────────
24
28
 
@@ -204,6 +208,13 @@ export function estimateTimeRemaining(): string | null {
204
208
 
205
209
  // ─── Slice Progress Cache ─────────────────────────────────────────────────────
206
210
 
211
+ /** Cached task detail for the widget task checklist */
212
+ interface CachedTaskDetail {
213
+ id: string;
214
+ title: string;
215
+ done: boolean;
216
+ }
217
+
207
218
  /** Cached slice progress for the widget — avoid async in render */
208
219
  let cachedSliceProgress: {
209
220
  done: number;
@@ -211,6 +222,8 @@ let cachedSliceProgress: {
211
222
  milestoneId: string;
212
223
  /** Real task progress for the active slice, if its plan file exists */
213
224
  activeSliceTasks: { done: number; total: number } | null;
225
+ /** Full task list for the active slice checklist */
226
+ taskDetails: CachedTaskDetail[] | null;
214
227
  } | null = null;
215
228
 
216
229
  export function updateSliceProgressCache(base: string, mid: string, activeSid?: string): void {
@@ -221,6 +234,7 @@ export function updateSliceProgressCache(base: string, mid: string, activeSid?:
221
234
  const roadmap = parseRoadmap(content);
222
235
 
223
236
  let activeSliceTasks: { done: number; total: number } | null = null;
237
+ let taskDetails: CachedTaskDetail[] | null = null;
224
238
  if (activeSid) {
225
239
  try {
226
240
  const planFile = resolveSliceFile(base, mid, activeSid, "PLAN");
@@ -231,6 +245,7 @@ export function updateSliceProgressCache(base: string, mid: string, activeSid?:
231
245
  done: plan.tasks.filter(t => t.done).length,
232
246
  total: plan.tasks.length,
233
247
  };
248
+ taskDetails = plan.tasks.map(t => ({ id: t.id, title: t.title, done: t.done }));
234
249
  }
235
250
  } catch {
236
251
  // Non-fatal — just omit task count
@@ -242,13 +257,14 @@ export function updateSliceProgressCache(base: string, mid: string, activeSid?:
242
257
  total: roadmap.slices.length,
243
258
  milestoneId: mid,
244
259
  activeSliceTasks,
260
+ taskDetails,
245
261
  };
246
262
  } catch {
247
263
  // Non-fatal — widget just won't show progress bar
248
264
  }
249
265
  }
250
266
 
251
- export function getRoadmapSlicesSync(): { done: number; total: number; activeSliceTasks: { done: number; total: number } | null } | null {
267
+ export function getRoadmapSlicesSync(): { done: number; total: number; activeSliceTasks: { done: number; total: number } | null; taskDetails: CachedTaskDetail[] | null } | null {
252
268
  return cachedSliceProgress;
253
269
  }
254
270
 
@@ -256,6 +272,41 @@ export function clearSliceProgressCache(): void {
256
272
  cachedSliceProgress = null;
257
273
  }
258
274
 
275
+ // ─── Last Commit Cache ────────────────────────────────────────────────────────
276
+
277
+ /** Cached last commit info — refreshed on the 15s timer, not every render */
278
+ let cachedLastCommit: { timeAgo: string; message: string } | null = null;
279
+ let lastCommitFetchedAt = 0;
280
+
281
+ function refreshLastCommit(basePath: string): void {
282
+ try {
283
+ const raw = execFileSync("git", ["log", "-1", "--format=%cr|%s"], {
284
+ cwd: basePath,
285
+ encoding: "utf-8",
286
+ stdio: ["pipe", "pipe", "pipe"],
287
+ timeout: 3000,
288
+ }).trim();
289
+ const sep = raw.indexOf("|");
290
+ if (sep > 0) {
291
+ cachedLastCommit = {
292
+ timeAgo: raw.slice(0, sep).replace(/ ago$/, "").replace(/ /g, ""),
293
+ message: raw.slice(sep + 1),
294
+ };
295
+ }
296
+ lastCommitFetchedAt = Date.now();
297
+ } catch {
298
+ // Non-fatal — just skip last commit display
299
+ }
300
+ }
301
+
302
+ function getLastCommit(basePath: string): { timeAgo: string; message: string } | null {
303
+ // Refresh at most every 15 seconds
304
+ if (Date.now() - lastCommitFetchedAt > 15_000) {
305
+ refreshLastCommit(basePath);
306
+ }
307
+ return cachedLastCommit;
308
+ }
309
+
259
310
  // ─── Footer Factory ───────────────────────────────────────────────────────────
260
311
 
261
312
  /**
@@ -269,6 +320,67 @@ export const hideFooter = () => ({
269
320
  dispose() {},
270
321
  });
271
322
 
323
+ // ─── Widget Display Mode ──────────────────────────────────────────────────────
324
+
325
+ /** Widget display modes: full → small → min → off → full */
326
+ export type WidgetMode = "full" | "small" | "min" | "off";
327
+ const WIDGET_MODES: WidgetMode[] = ["full", "small", "min", "off"];
328
+ let widgetMode: WidgetMode = "full";
329
+ let widgetModeInitialized = false;
330
+
331
+ /** Load widget mode from preferences (once). */
332
+ function ensureWidgetModeLoaded(): void {
333
+ if (widgetModeInitialized) return;
334
+ widgetModeInitialized = true;
335
+ try {
336
+ const loaded = loadEffectiveGSDPreferences();
337
+ const saved = loaded?.preferences?.widget_mode;
338
+ if (saved && WIDGET_MODES.includes(saved as WidgetMode)) {
339
+ widgetMode = saved as WidgetMode;
340
+ }
341
+ } catch { /* non-fatal — use default */ }
342
+ }
343
+
344
+ /** Persist widget mode to global preferences YAML. */
345
+ function persistWidgetMode(mode: WidgetMode): void {
346
+ try {
347
+ const prefsPath = getGlobalGSDPreferencesPath();
348
+ let content = "";
349
+ if (existsSync(prefsPath)) {
350
+ content = readFileSync(prefsPath, "utf-8");
351
+ }
352
+ const line = `widget_mode: ${mode}`;
353
+ const re = /^widget_mode:\s*\S+/m;
354
+ if (re.test(content)) {
355
+ content = content.replace(re, line);
356
+ } else {
357
+ content = content.trimEnd() + "\n" + line + "\n";
358
+ }
359
+ writeFileSync(prefsPath, content, "utf-8");
360
+ } catch { /* non-fatal — mode still set in memory */ }
361
+ }
362
+
363
+ /** Cycle to the next widget mode. Returns the new mode. */
364
+ export function cycleWidgetMode(): WidgetMode {
365
+ ensureWidgetModeLoaded();
366
+ const idx = WIDGET_MODES.indexOf(widgetMode);
367
+ widgetMode = WIDGET_MODES[(idx + 1) % WIDGET_MODES.length];
368
+ persistWidgetMode(widgetMode);
369
+ return widgetMode;
370
+ }
371
+
372
+ /** Set widget mode directly. */
373
+ export function setWidgetMode(mode: WidgetMode): void {
374
+ widgetMode = mode;
375
+ persistWidgetMode(widgetMode);
376
+ }
377
+
378
+ /** Get current widget mode. */
379
+ export function getWidgetMode(): WidgetMode {
380
+ ensureWidgetModeLoaded();
381
+ return widgetMode;
382
+ }
383
+
272
384
  // ─── Progress Widget ──────────────────────────────────────────────────────────
273
385
 
274
386
  /** State accessors passed to updateProgressWidget to avoid direct global access */
@@ -295,19 +407,32 @@ export function updateProgressWidget(
295
407
  const mid = state.activeMilestone;
296
408
  const slice = state.activeSlice;
297
409
  const task = state.activeTask;
298
- const next = peekNext(unitType, state);
410
+ const isHook = unitType.startsWith("hook/");
299
411
 
300
412
  // Cache git branch at widget creation time (not per render)
301
413
  let cachedBranch: string | null = null;
302
414
  try { cachedBranch = getCurrentBranch(accessors.getBasePath()); } catch { /* not in git repo */ }
303
415
 
304
- // Cache pwd with ~ substitution
305
- let widgetPwd = process.cwd();
306
- const widgetHome = process.env.HOME || process.env.USERPROFILE;
307
- if (widgetHome && widgetPwd.startsWith(widgetHome)) {
308
- widgetPwd = `~${widgetPwd.slice(widgetHome.length)}`;
416
+ // Cache short pwd (last 2 path segments only) + worktree/branch info
417
+ let widgetPwd: string;
418
+ {
419
+ let fullPwd = process.cwd();
420
+ const widgetHome = process.env.HOME || process.env.USERPROFILE;
421
+ if (widgetHome && fullPwd.startsWith(widgetHome)) {
422
+ fullPwd = `~${fullPwd.slice(widgetHome.length)}`;
423
+ }
424
+ const parts = fullPwd.split("/");
425
+ widgetPwd = parts.length > 2 ? parts.slice(-2).join("/") : fullPwd;
426
+ }
427
+ const worktreeName = getActiveWorktreeName();
428
+ if (worktreeName && cachedBranch) {
429
+ widgetPwd = `${widgetPwd} (\u2387 ${cachedBranch})`;
430
+ } else if (cachedBranch) {
431
+ widgetPwd = `${widgetPwd} (${cachedBranch})`;
309
432
  }
310
- if (cachedBranch) widgetPwd = `${widgetPwd} (${cachedBranch})`;
433
+
434
+ // Pre-fetch last commit for display
435
+ refreshLastCommit(accessors.getBasePath());
311
436
 
312
437
  ctx.ui.setWidget("gsd-progress", (tui, theme) => {
313
438
  let pulseBright = true;
@@ -347,152 +472,267 @@ export function updateProgressWidget(
347
472
  : theme.fg("dim", GLYPH.statusPending);
348
473
  const elapsed = formatAutoElapsed(accessors.getAutoStartTime());
349
474
  const modeTag = accessors.isStepMode() ? "NEXT" : "AUTO";
350
- const headerLeft = `${pad}${dot} ${theme.fg("accent", theme.bold("GSD"))} ${theme.fg("success", modeTag)}`;
351
- const headerRight = elapsed ? theme.fg("dim", elapsed) : "";
352
- lines.push(rightAlign(headerLeft, headerRight, width));
353
475
 
354
- lines.push("");
476
+ // Health indicator in header
477
+ const score = computeProgressScore();
478
+ const healthColor = score.level === "green" ? "success"
479
+ : score.level === "yellow" ? "warning"
480
+ : "error";
481
+ const healthIcon = score.level === "green" ? GLYPH.statusActive
482
+ : score.level === "yellow" ? "!"
483
+ : "x";
484
+ const healthStr = ` ${theme.fg(healthColor, healthIcon)} ${theme.fg(healthColor, score.summary)}`;
485
+
486
+ const headerLeft = `${pad}${dot} ${theme.fg("accent", theme.bold("GSD"))} ${theme.fg("success", modeTag)}${healthStr}`;
487
+
488
+ // ETA in header right, after elapsed
489
+ const eta = estimateTimeRemaining();
490
+ const etaShort = eta ? eta.replace(" remaining", " left") : null;
491
+ const headerRight = elapsed
492
+ ? (etaShort
493
+ ? `${theme.fg("dim", elapsed)} ${theme.fg("dim", "·")} ${theme.fg("dim", etaShort)}`
494
+ : theme.fg("dim", elapsed))
495
+ : "";
496
+ lines.push(rightAlign(headerLeft, headerRight, width));
355
497
 
356
- if (mid) {
357
- lines.push(truncateToWidth(`${pad}${theme.fg("dim", mid.title)}`, width));
498
+ // ── Gather stats (needed by multiple modes) ─────────────────────
499
+ const cmdCtx = accessors.getCmdCtx();
500
+ let totalInput = 0;
501
+ let totalCacheRead = 0;
502
+ if (cmdCtx) {
503
+ for (const entry of cmdCtx.sessionManager.getEntries()) {
504
+ if (entry.type === "message") {
505
+ const msgEntry = entry as SessionMessageEntry;
506
+ if (msgEntry.message?.role === "assistant") {
507
+ const u = (msgEntry.message as any).usage;
508
+ if (u) {
509
+ totalInput += u.input || 0;
510
+ totalCacheRead += u.cacheRead || 0;
511
+ }
512
+ }
513
+ }
514
+ }
515
+ }
516
+ const mLedger = getLedger();
517
+ const autoTotals = mLedger ? getProjectTotals(mLedger.units) : null;
518
+ const cumulativeCost = autoTotals?.cost ?? 0;
519
+ const cxUsage = cmdCtx?.getContextUsage?.();
520
+ const cxWindow = cxUsage?.contextWindow ?? cmdCtx?.model?.contextWindow ?? 0;
521
+ const cxPctVal = cxUsage?.percent ?? 0;
522
+ const cxPct = cxUsage?.percent !== null ? cxPctVal.toFixed(1) : "?";
523
+
524
+ // Model display — shown in context section, not stats
525
+ const modelId = cmdCtx?.model?.id ?? "";
526
+ const modelProvider = cmdCtx?.model?.provider ?? "";
527
+ const modelDisplay = modelProvider && modelId
528
+ ? `${modelProvider}/${modelId}`
529
+ : modelId;
530
+
531
+ // ── Mode: off — return empty ──────────────────────────────────
532
+ if (widgetMode === "off") {
533
+ cachedLines = [];
534
+ cachedWidth = width;
535
+ return [];
358
536
  }
359
537
 
360
- if (slice && unitType !== "research-milestone" && unitType !== "plan-milestone") {
361
- lines.push(truncateToWidth(
362
- `${pad}${theme.fg("text", theme.bold(`${slice.id}: ${slice.title}`))}`,
363
- width,
364
- ));
538
+ // ── Mode: min header line only ──────────────────────────────
539
+ if (widgetMode === "min") {
540
+ lines.push(...ui.bar());
541
+ cachedLines = lines;
542
+ cachedWidth = width;
543
+ return lines;
365
544
  }
366
545
 
367
- lines.push("");
546
+ // ── Mode: small — header + progress bar + compact stats ───────
547
+ if (widgetMode === "small") {
548
+ lines.push("");
368
549
 
369
- const target = task ? `${task.id}: ${task.title}` : unitId;
370
- const actionLeft = `${pad}${theme.fg("accent", "▸")} ${theme.fg("accent", verb)} ${theme.fg("text", target)}`;
371
- const tierTag = tierBadge ? theme.fg("dim", `[${tierBadge}] `) : "";
372
- const phaseBadge = `${tierTag}${theme.fg("dim", phaseLabel)}`;
373
- lines.push(rightAlign(actionLeft, phaseBadge, width));
374
- lines.push("");
550
+ // Action line
551
+ const target = task ? `${task.id}: ${task.title}` : unitId;
552
+ const actionLeft = `${pad}${theme.fg("accent", "▸")} ${theme.fg("accent", verb)} ${theme.fg("text", target)}`;
553
+ lines.push(rightAlign(actionLeft, theme.fg("dim", phaseLabel), width));
375
554
 
376
- if (mid) {
377
- const roadmapSlices = getRoadmapSlicesSync();
555
+ // Progress bar
556
+ const roadmapSlices = mid ? getRoadmapSlicesSync() : null;
378
557
  if (roadmapSlices) {
379
558
  const { done, total, activeSliceTasks } = roadmapSlices;
380
- const barWidth = Math.max(8, Math.min(24, Math.floor(width * 0.3)));
559
+ const barWidth = Math.max(6, Math.min(18, Math.floor(width * 0.25)));
381
560
  const pct = total > 0 ? done / total : 0;
382
561
  const filled = Math.round(pct * barWidth);
383
- const bar = theme.fg("success", "".repeat(filled))
384
- + theme.fg("dim", "".repeat(barWidth - filled));
385
-
386
- let meta = theme.fg("dim", `${done}/${total} slices`);
387
-
562
+ const bar = theme.fg("success", "".repeat(filled))
563
+ + theme.fg("dim", "".repeat(barWidth - filled));
564
+ let meta = `${theme.fg("text", `${done}`)}${theme.fg("dim", `/${total} slices`)}`;
388
565
  if (activeSliceTasks && activeSliceTasks.total > 0) {
389
- const taskNum = Math.min(activeSliceTasks.done + 1, activeSliceTasks.total);
390
- meta += theme.fg("dim", ` · task ${taskNum}/${activeSliceTasks.total}`);
391
- }
392
-
393
- // ETA estimate
394
- const eta = estimateTimeRemaining();
395
- if (eta) {
396
- meta += theme.fg("dim", ` · ${eta}`);
566
+ const tn = Math.min(activeSliceTasks.done + 1, activeSliceTasks.total);
567
+ meta += `${theme.fg("dim", " · task ")}${theme.fg("accent", `${tn}`)}${theme.fg("dim", `/${activeSliceTasks.total}`)}`;
397
568
  }
569
+ lines.push(`${pad}${bar} ${meta}`);
570
+ }
398
571
 
399
- lines.push(truncateToWidth(`${pad}${bar} ${meta}`, width));
572
+ // Compact stats: cost + context only
573
+ const smallStats: string[] = [];
574
+ if (cumulativeCost) smallStats.push(theme.fg("warning", `$${cumulativeCost.toFixed(2)}`));
575
+ const cxDisplay = `${cxPct}%ctx`;
576
+ if (cxPctVal > 90) smallStats.push(theme.fg("error", cxDisplay));
577
+ else if (cxPctVal > 70) smallStats.push(theme.fg("warning", cxDisplay));
578
+ else smallStats.push(theme.fg("dim", cxDisplay));
579
+ if (smallStats.length > 0) {
580
+ lines.push(rightAlign("", smallStats.join(theme.fg("dim", " ")), width));
400
581
  }
582
+
583
+ lines.push(...ui.bar());
584
+ cachedLines = lines;
585
+ cachedWidth = width;
586
+ return lines;
401
587
  }
402
588
 
589
+ // ── Mode: full — complete two-column layout ───────────────────
403
590
  lines.push("");
404
591
 
405
- if (next) {
592
+ // Context section: milestone + slice + model
593
+ const hasContext = !!(mid || (slice && unitType !== "research-milestone" && unitType !== "plan-milestone"));
594
+ if (mid) {
595
+ const modelTag = modelDisplay ? theme.fg("muted", ` ${modelDisplay}`) : "";
596
+ lines.push(truncateToWidth(`${pad}${theme.fg("dim", mid.title)}${modelTag}`, width));
597
+ }
598
+ if (slice && unitType !== "research-milestone" && unitType !== "plan-milestone") {
406
599
  lines.push(truncateToWidth(
407
- `${pad}${theme.fg("dim", "→")} ${theme.fg("dim", `then ${next}`)}`,
600
+ `${pad}${theme.fg("text", theme.bold(`${slice.id}: ${slice.title}`))}`,
408
601
  width,
409
602
  ));
410
603
  }
604
+ if (hasContext) lines.push("");
605
+
606
+ const target = task ? `${task.id}: ${task.title}` : unitId;
607
+ const actionLeft = `${pad}${theme.fg("accent", "▸")} ${theme.fg("accent", verb)} ${theme.fg("text", target)}`;
608
+ const tierTag = tierBadge ? theme.fg("dim", `[${tierBadge}] `) : "";
609
+ const phaseBadge = `${tierTag}${theme.fg("dim", phaseLabel)}`;
610
+ lines.push(rightAlign(actionLeft, phaseBadge, width));
411
611
 
412
- // ── Footer info (pwd, tokens, cost, context, model) ──────────────
413
612
  lines.push("");
414
- lines.push(truncateToWidth(theme.fg("dim", `${pad}${widgetPwd}`), width, theme.fg("dim", "…")));
415
613
 
416
- // Token stats from current unit session + cumulative cost from metrics
417
- {
418
- const cmdCtx = accessors.getCmdCtx();
419
- let totalInput = 0, totalOutput = 0;
420
- let totalCacheRead = 0, totalCacheWrite = 0;
421
- if (cmdCtx) {
422
- for (const entry of cmdCtx.sessionManager.getEntries()) {
423
- if (entry.type === "message") {
424
- const msgEntry = entry as SessionMessageEntry;
425
- if (msgEntry.message?.role === "assistant") {
426
- const u = (msgEntry.message as any).usage;
427
- if (u) {
428
- totalInput += u.input || 0;
429
- totalOutput += u.output || 0;
430
- totalCacheRead += u.cacheRead || 0;
431
- totalCacheWrite += u.cacheWrite || 0;
432
- }
433
- }
434
- }
435
- }
614
+ // Two-column body
615
+ const minTwoColWidth = 76;
616
+ const roadmapSlices = mid ? getRoadmapSlicesSync() : null;
617
+ const taskDetailsCol = roadmapSlices?.taskDetails ?? null;
618
+ const useTwoCol = width >= minTwoColWidth && taskDetailsCol !== null && taskDetailsCol.length > 0;
619
+ const leftColWidth = useTwoCol
620
+ ? Math.floor(width * (width >= 100 ? 0.45 : 0.50))
621
+ : width;
622
+
623
+ const leftLines: string[] = [];
624
+
625
+ if (roadmapSlices) {
626
+ const { done, total, activeSliceTasks } = roadmapSlices;
627
+ const barWidth = Math.max(6, Math.min(18, Math.floor(leftColWidth * 0.4)));
628
+ const pct = total > 0 ? done / total : 0;
629
+ const filled = Math.round(pct * barWidth);
630
+ const bar = theme.fg("success", "━".repeat(filled))
631
+ + theme.fg("dim", "─".repeat(barWidth - filled));
632
+
633
+ let meta = `${theme.fg("text", `${done}`)}${theme.fg("dim", `/${total} slices`)}`;
634
+ if (activeSliceTasks && activeSliceTasks.total > 0) {
635
+ const taskNum = isHook
636
+ ? Math.max(activeSliceTasks.done, 1)
637
+ : Math.min(activeSliceTasks.done + 1, activeSliceTasks.total);
638
+ meta += `${theme.fg("dim", " · task ")}${theme.fg("accent", `${taskNum}`)}${theme.fg("dim", `/${activeSliceTasks.total}`)}`;
436
639
  }
437
- const mLedger = getLedger();
438
- const autoTotals = mLedger ? getProjectTotals(mLedger.units) : null;
439
- const cumulativeCost = autoTotals?.cost ?? 0;
640
+ leftLines.push(`${pad}${bar} ${meta}`);
641
+ }
440
642
 
441
- const cxUsage = cmdCtx?.getContextUsage?.();
442
- const cxWindow = cxUsage?.contextWindow ?? cmdCtx?.model?.contextWindow ?? 0;
443
- const cxPctVal = cxUsage?.percent ?? 0;
444
- const cxPct = cxUsage?.percent !== null ? cxPctVal.toFixed(1) : "?";
643
+ // Build right column: task checklist
644
+ const rightLines: string[] = [];
645
+ const maxVisibleTasks = 8;
646
+
647
+ function formatTaskLine(t: { id: string; title: string; done: boolean }, isCurrent: boolean): string {
648
+ const glyph = t.done
649
+ ? theme.fg("success", "*")
650
+ : isCurrent
651
+ ? theme.fg("accent", ">")
652
+ : theme.fg("dim", ".");
653
+ const id = isCurrent
654
+ ? theme.fg("accent", t.id)
655
+ : t.done
656
+ ? theme.fg("muted", t.id)
657
+ : theme.fg("dim", t.id);
658
+ const title = isCurrent
659
+ ? theme.fg("text", t.title)
660
+ : t.done
661
+ ? theme.fg("muted", t.title)
662
+ : theme.fg("text", t.title);
663
+ return `${glyph} ${id}: ${title}`;
664
+ }
445
665
 
446
- const sp: string[] = [];
447
- if (totalInput) sp.push(`↑${formatWidgetTokens(totalInput)}`);
448
- if (totalOutput) sp.push(`↓${formatWidgetTokens(totalOutput)}`);
449
- if (totalCacheRead) sp.push(`R${formatWidgetTokens(totalCacheRead)}`);
450
- if (totalCacheWrite) sp.push(`W${formatWidgetTokens(totalCacheWrite)}`);
451
- // Cache hit rate for current unit
452
- if (totalCacheRead + totalInput > 0) {
453
- const hitRate = Math.round((totalCacheRead / (totalCacheRead + totalInput)) * 100);
454
- sp.push(`\u26A1${hitRate}%`);
666
+ if (useTwoCol && taskDetailsCol) {
667
+ for (const t of taskDetailsCol.slice(0, maxVisibleTasks)) {
668
+ rightLines.push(formatTaskLine(t, !!(task && t.id === task.id)));
669
+ }
670
+ if (taskDetailsCol.length > maxVisibleTasks) {
671
+ rightLines.push(theme.fg("dim", ` +${taskDetailsCol.length - maxVisibleTasks} more`));
455
672
  }
456
- if (cumulativeCost) sp.push(`$${cumulativeCost.toFixed(3)}`);
457
-
458
- const cxDisplay = cxPct === "?"
459
- ? `?/${formatWidgetTokens(cxWindow)}`
460
- : `${cxPct}%/${formatWidgetTokens(cxWindow)}`;
461
- if (cxPctVal > 90) {
462
- sp.push(theme.fg("error", cxDisplay));
463
- } else if (cxPctVal > 70) {
464
- sp.push(theme.fg("warning", cxDisplay));
465
- } else {
466
- sp.push(cxDisplay);
673
+ } else if (!useTwoCol && taskDetailsCol && taskDetailsCol.length > 0) {
674
+ for (const t of taskDetailsCol.slice(0, maxVisibleTasks)) {
675
+ leftLines.push(`${pad}${formatTaskLine(t, !!(task && t.id === task.id))}`);
467
676
  }
677
+ }
468
678
 
469
- const sLeft = sp.map(p => p.includes("\x1b[") ? p : theme.fg("dim", p))
470
- .join(theme.fg("dim", " "));
471
-
472
- const modelId = cmdCtx?.model?.id ?? "";
473
- const modelProvider = cmdCtx?.model?.provider ?? "";
474
- const modelPhase = phaseLabel ? theme.fg("dim", `[${phaseLabel}] `) : "";
475
- const modelDisplay = modelProvider && modelId
476
- ? `${modelProvider}/${modelId}`
477
- : modelId;
478
- const sRight = modelDisplay
479
- ? `${modelPhase}${theme.fg("dim", modelDisplay)}`
480
- : "";
481
- lines.push(rightAlign(`${pad}${sLeft}`, sRight, width));
482
-
483
- // Dynamic routing savings summary
484
- if (mLedger && mLedger.units.some(u => u.tier)) {
485
- const savings = formatTierSavings(mLedger.units);
486
- if (savings) {
487
- lines.push(truncateToWidth(theme.fg("dim", `${pad}${savings}`), width));
679
+ // Compose columns
680
+ if (useTwoCol) {
681
+ const maxRows = Math.max(leftLines.length, rightLines.length);
682
+ if (maxRows > 0) {
683
+ lines.push("");
684
+ for (let i = 0; i < maxRows; i++) {
685
+ const left = padToWidth(truncateToWidth(leftLines[i] ?? "", leftColWidth), leftColWidth);
686
+ const right = rightLines[i] ?? "";
687
+ lines.push(`${left}${right}`);
488
688
  }
489
689
  }
690
+ } else {
691
+ if (leftLines.length > 0) {
692
+ lines.push("");
693
+ for (const l of leftLines) lines.push(truncateToWidth(l, width));
694
+ }
490
695
  }
491
696
 
697
+ // ── Footer: simplified stats + pwd + last commit + hints ────────
698
+ lines.push("");
699
+ {
700
+ const sp: string[] = [];
701
+ if (totalCacheRead + totalInput > 0) {
702
+ const hitRate = Math.round((totalCacheRead / (totalCacheRead + totalInput)) * 100);
703
+ const hitColor = hitRate >= 70 ? "success" : hitRate >= 40 ? "warning" : "error";
704
+ sp.push(theme.fg(hitColor, `${hitRate}%hit`));
705
+ }
706
+ if (cumulativeCost) sp.push(theme.fg("warning", `$${cumulativeCost.toFixed(2)}`));
707
+
708
+ const cxDisplay = `${cxPct}%/${formatWidgetTokens(cxWindow)}`;
709
+ if (cxPctVal > 90) sp.push(theme.fg("error", cxDisplay));
710
+ else if (cxPctVal > 70) sp.push(theme.fg("warning", cxDisplay));
711
+ else sp.push(cxDisplay);
712
+
713
+ const statsLine = sp.map(p => p.includes("\x1b[") ? p : theme.fg("dim", p))
714
+ .join(theme.fg("dim", " "));
715
+ if (statsLine) {
716
+ lines.push(rightAlign("", statsLine, width));
717
+ }
718
+ }
719
+ // PWD line with last commit info right-aligned
720
+ const lastCommit = getLastCommit(accessors.getBasePath());
721
+ const commitStr = lastCommit
722
+ ? theme.fg("dim", `${lastCommit.timeAgo} ago: ${lastCommit.message}`)
723
+ : "";
724
+ const pwdStr = theme.fg("dim", widgetPwd);
725
+ if (commitStr) {
726
+ lines.push(rightAlign(`${pad}${pwdStr}`, truncateToWidth(commitStr, Math.floor(width * 0.45)), width));
727
+ } else {
728
+ lines.push(`${pad}${pwdStr}`);
729
+ }
730
+ // Hints line
492
731
  const hintParts: string[] = [];
493
732
  hintParts.push("esc pause");
494
733
  hintParts.push(process.platform === "darwin" ? "⌃⌥G dashboard" : "Ctrl+Alt+G dashboard");
495
- lines.push(...ui.hints(hintParts));
734
+ const hintStr = theme.fg("dim", hintParts.join(" | "));
735
+ lines.push(rightAlign("", hintStr, width));
496
736
 
497
737
  lines.push(...ui.bar());
498
738
 
@@ -521,3 +761,10 @@ function rightAlign(left: string, right: string, width: number): string {
521
761
  const gap = Math.max(1, width - leftVis - rightVis);
522
762
  return truncateToWidth(left + " ".repeat(gap) + right, width);
523
763
  }
764
+
765
+ /** Pad a string with trailing spaces to fill exactly `colWidth` (ANSI-aware). */
766
+ function padToWidth(s: string, colWidth: number): string {
767
+ const vis = visibleWidth(s);
768
+ if (vis >= colWidth) return truncateToWidth(s, colWidth);
769
+ return s + " ".repeat(colWidth - vis);
770
+ }