omegon 0.6.0

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 (160) hide show
  1. package/.gitattributes +3 -0
  2. package/AGENTS.md +16 -0
  3. package/LICENSE +15 -0
  4. package/README.md +289 -0
  5. package/bin/pi.mjs +30 -0
  6. package/extensions/00-secrets/index.ts +1126 -0
  7. package/extensions/01-auth/auth.ts +401 -0
  8. package/extensions/01-auth/index.ts +289 -0
  9. package/extensions/auto-compact.ts +42 -0
  10. package/extensions/bootstrap/deps.ts +291 -0
  11. package/extensions/bootstrap/index.ts +811 -0
  12. package/extensions/chronos/chronos.sh +487 -0
  13. package/extensions/chronos/index.ts +148 -0
  14. package/extensions/cleave/assessment.ts +754 -0
  15. package/extensions/cleave/bridge.ts +31 -0
  16. package/extensions/cleave/conflicts.ts +250 -0
  17. package/extensions/cleave/dispatcher.ts +808 -0
  18. package/extensions/cleave/guardrails.ts +426 -0
  19. package/extensions/cleave/index.ts +3121 -0
  20. package/extensions/cleave/lifecycle-emitter.ts +20 -0
  21. package/extensions/cleave/openspec.ts +811 -0
  22. package/extensions/cleave/planner.ts +260 -0
  23. package/extensions/cleave/review.ts +579 -0
  24. package/extensions/cleave/skills.ts +355 -0
  25. package/extensions/cleave/types.ts +261 -0
  26. package/extensions/cleave/workspace.ts +861 -0
  27. package/extensions/cleave/worktree.ts +243 -0
  28. package/extensions/core-renderers.ts +253 -0
  29. package/extensions/dashboard/context-gauge.ts +58 -0
  30. package/extensions/dashboard/file-watch.ts +14 -0
  31. package/extensions/dashboard/footer.ts +1145 -0
  32. package/extensions/dashboard/git.ts +185 -0
  33. package/extensions/dashboard/index.ts +478 -0
  34. package/extensions/dashboard/memory-audit.ts +34 -0
  35. package/extensions/dashboard/overlay-data.ts +705 -0
  36. package/extensions/dashboard/overlay.ts +365 -0
  37. package/extensions/dashboard/render-utils.ts +54 -0
  38. package/extensions/dashboard/types.ts +191 -0
  39. package/extensions/dashboard/uri-helper.ts +45 -0
  40. package/extensions/debug.ts +69 -0
  41. package/extensions/defaults.ts +282 -0
  42. package/extensions/design-tree/dashboard-state.ts +161 -0
  43. package/extensions/design-tree/design-card.ts +362 -0
  44. package/extensions/design-tree/index.ts +2130 -0
  45. package/extensions/design-tree/lifecycle-emitter.ts +41 -0
  46. package/extensions/design-tree/tree.ts +1607 -0
  47. package/extensions/design-tree/types.ts +163 -0
  48. package/extensions/distill.ts +127 -0
  49. package/extensions/effort/index.ts +395 -0
  50. package/extensions/effort/tiers.ts +146 -0
  51. package/extensions/effort/types.ts +105 -0
  52. package/extensions/lib/git-state.ts +227 -0
  53. package/extensions/lib/local-models.ts +157 -0
  54. package/extensions/lib/model-preferences.ts +51 -0
  55. package/extensions/lib/model-routing.ts +720 -0
  56. package/extensions/lib/operator-fallback.ts +205 -0
  57. package/extensions/lib/operator-profile.ts +360 -0
  58. package/extensions/lib/slash-command-bridge.ts +253 -0
  59. package/extensions/lib/typebox-helpers.ts +16 -0
  60. package/extensions/local-inference/index.ts +727 -0
  61. package/extensions/mcp-bridge/README.md +220 -0
  62. package/extensions/mcp-bridge/index.ts +951 -0
  63. package/extensions/mcp-bridge/lib.ts +365 -0
  64. package/extensions/mcp-bridge/mcp.json +3 -0
  65. package/extensions/mcp-bridge/package.json +11 -0
  66. package/extensions/model-budget.ts +752 -0
  67. package/extensions/offline-driver.ts +403 -0
  68. package/extensions/openspec/archive-gate.ts +164 -0
  69. package/extensions/openspec/branch-cleanup.ts +64 -0
  70. package/extensions/openspec/dashboard-state.ts +50 -0
  71. package/extensions/openspec/index.ts +1917 -0
  72. package/extensions/openspec/lifecycle-emitter.ts +65 -0
  73. package/extensions/openspec/lifecycle-files.ts +70 -0
  74. package/extensions/openspec/lifecycle.ts +50 -0
  75. package/extensions/openspec/reconcile.ts +187 -0
  76. package/extensions/openspec/spec.ts +1385 -0
  77. package/extensions/openspec/types.ts +98 -0
  78. package/extensions/project-memory/DESIGN-global-mind.md +198 -0
  79. package/extensions/project-memory/README.md +202 -0
  80. package/extensions/project-memory/api-types.ts +382 -0
  81. package/extensions/project-memory/compaction-policy.ts +29 -0
  82. package/extensions/project-memory/core.ts +164 -0
  83. package/extensions/project-memory/embeddings.ts +230 -0
  84. package/extensions/project-memory/extraction-v2.ts +861 -0
  85. package/extensions/project-memory/factstore.ts +2177 -0
  86. package/extensions/project-memory/index.ts +3459 -0
  87. package/extensions/project-memory/injection-metrics.ts +91 -0
  88. package/extensions/project-memory/jsonl-io.ts +12 -0
  89. package/extensions/project-memory/lifecycle.ts +331 -0
  90. package/extensions/project-memory/migration.ts +293 -0
  91. package/extensions/project-memory/package.json +9 -0
  92. package/extensions/project-memory/sci-renderers.ts +7 -0
  93. package/extensions/project-memory/template.ts +103 -0
  94. package/extensions/project-memory/triggers.ts +52 -0
  95. package/extensions/project-memory/types.ts +102 -0
  96. package/extensions/render/composition/fonts/Inter-Bold.ttf +0 -0
  97. package/extensions/render/composition/fonts/Inter-Regular.ttf +0 -0
  98. package/extensions/render/composition/fonts/Tomorrow-Bold.ttf +0 -0
  99. package/extensions/render/composition/fonts/Tomorrow-Regular.ttf +0 -0
  100. package/extensions/render/composition/package-lock.json +534 -0
  101. package/extensions/render/composition/package.json +22 -0
  102. package/extensions/render/composition/render.mjs +246 -0
  103. package/extensions/render/composition/test-comp.tsx +87 -0
  104. package/extensions/render/composition/types.ts +24 -0
  105. package/extensions/render/excalidraw/UPSTREAM.md +81 -0
  106. package/extensions/render/excalidraw/elements.ts +764 -0
  107. package/extensions/render/excalidraw/index.ts +66 -0
  108. package/extensions/render/excalidraw/types.ts +223 -0
  109. package/extensions/render/excalidraw-renderer/pyproject.toml +8 -0
  110. package/extensions/render/excalidraw-renderer/render_excalidraw.py +182 -0
  111. package/extensions/render/excalidraw-renderer/render_template.html +59 -0
  112. package/extensions/render/index.ts +830 -0
  113. package/extensions/render/native-diagrams/index.ts +57 -0
  114. package/extensions/render/native-diagrams/motifs.ts +542 -0
  115. package/extensions/render/native-diagrams/raster.ts +8 -0
  116. package/extensions/render/native-diagrams/scene.ts +75 -0
  117. package/extensions/render/native-diagrams/spec.ts +204 -0
  118. package/extensions/render/native-diagrams/svg.ts +116 -0
  119. package/extensions/sci-ui.ts +304 -0
  120. package/extensions/session-log.ts +174 -0
  121. package/extensions/shared-state.ts +146 -0
  122. package/extensions/spinner-verbs.ts +91 -0
  123. package/extensions/style.ts +281 -0
  124. package/extensions/terminal-title.ts +191 -0
  125. package/extensions/tool-profile/index.ts +291 -0
  126. package/extensions/tool-profile/profiles.ts +290 -0
  127. package/extensions/types.d.ts +9 -0
  128. package/extensions/vault/index.ts +185 -0
  129. package/extensions/version-check.ts +90 -0
  130. package/extensions/view/index.ts +859 -0
  131. package/extensions/view/uri-resolver.ts +148 -0
  132. package/extensions/web-search/index.ts +182 -0
  133. package/extensions/web-search/providers.ts +121 -0
  134. package/extensions/web-ui/index.ts +110 -0
  135. package/extensions/web-ui/server.ts +265 -0
  136. package/extensions/web-ui/state.ts +462 -0
  137. package/extensions/web-ui/static/index.html +145 -0
  138. package/extensions/web-ui/types.ts +284 -0
  139. package/package.json +76 -0
  140. package/prompts/init.md +75 -0
  141. package/prompts/new-repo.md +54 -0
  142. package/prompts/oci-login.md +56 -0
  143. package/prompts/status.md +50 -0
  144. package/settings.json +4 -0
  145. package/skills/cleave/SKILL.md +218 -0
  146. package/skills/git/SKILL.md +209 -0
  147. package/skills/git/_reference/ci-validation.md +204 -0
  148. package/skills/oci/SKILL.md +338 -0
  149. package/skills/openspec/SKILL.md +346 -0
  150. package/skills/pi-extensions/SKILL.md +191 -0
  151. package/skills/pi-tui/SKILL.md +517 -0
  152. package/skills/python/SKILL.md +189 -0
  153. package/skills/rust/SKILL.md +268 -0
  154. package/skills/security/SKILL.md +206 -0
  155. package/skills/style/SKILL.md +264 -0
  156. package/skills/typescript/SKILL.md +225 -0
  157. package/skills/vault/SKILL.md +102 -0
  158. package/themes/alpharius-legacy.json +85 -0
  159. package/themes/alpharius.conf +59 -0
  160. package/themes/alpharius.json +88 -0
@@ -0,0 +1,1145 @@
1
+ /**
2
+ * Custom footer component for the unified dashboard.
3
+ *
4
+ * Implements two rendering modes:
5
+ * Layer 0 (compact): 1 line — dashboard summary only
6
+ * Layer 1 (raised): uncapped — section details, branch tree, and footer metadata
7
+ *
8
+ * Reads sharedState for design-tree, openspec, and cleave data.
9
+ * Reads footerData for git branch, extension statuses, provider count.
10
+ * Reads ExtensionContext for token stats, model, context usage.
11
+ */
12
+
13
+ import type { Component } from "@cwilson613/pi-tui";
14
+ import type { Theme, ThemeColor } from "@cwilson613/pi-coding-agent";
15
+ import type { ReadonlyFooterDataProvider } from "@cwilson613/pi-coding-agent";
16
+ import type { ExtensionContext } from "@cwilson613/pi-coding-agent";
17
+ import type { TUI } from "@cwilson613/pi-tui";
18
+ import { truncateToWidth, visibleWidth } from "@cwilson613/pi-tui";
19
+ import { leftRight, mergeColumns, padRight } from "./render-utils.ts";
20
+ import { buildBranchTreeLines, readLocalBranches } from "./git.ts";
21
+ import type { DashboardState, RecoveryCooldownSummary, RecoveryDashboardState } from "./types.ts";
22
+ import { sharedState } from "../shared-state.ts";
23
+ import { debug } from "../debug.ts";
24
+ import { linkDashboardFile, linkOpenSpecArtifact, linkOpenSpecChange } from "./uri-helper.ts";
25
+ import { designSpecBadge } from "./overlay-data.ts";
26
+ import { buildContextGaugeModel } from "./context-gauge.ts";
27
+
28
+ /**
29
+ * Box-drawing character set.
30
+ *
31
+ * When `TERM=dumb`, `PI_ASCII=1`, or `LC_ALL`/`LANG` indicates a non-UTF-8
32
+ * locale, fall back to plain ASCII characters that render on every terminal.
33
+ * Otherwise use the Unicode rounded-box set that looks nice in modern emulators.
34
+ */
35
+ const useAsciiBoxChars = (() => {
36
+ if (process.env["PI_ASCII"] === "1") return true;
37
+ if (process.env["TERM"] === "dumb") return true;
38
+ // Basic locale check: if LANG/LC_ALL/LC_CTYPE doesn't mention UTF, fall back.
39
+ const locale = (process.env["LC_ALL"] ?? process.env["LC_CTYPE"] ?? process.env["LANG"] ?? "").toUpperCase();
40
+ if (locale && !locale.includes("UTF")) return true;
41
+ return false;
42
+ })();
43
+
44
+ const BOX = useAsciiBoxChars
45
+ ? { tl: "+", tr: "+", bl: "+", br: "+", h: "-", v: "|", vr: "+", vl: "+", hd: "+", hu: "+" }
46
+ : { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│", vr: "├", vl: "┤", hd: "┬", hu: "┴" };
47
+
48
+ /**
49
+ * Format token counts to compact display (e.g. 1.2k, 45k, 1.3M)
50
+ */
51
+ function formatTokens(count: number): string {
52
+ if (count < 1000) return count.toString();
53
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
54
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
55
+ if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
56
+ return `${Math.round(count / 1000000)}M`;
57
+ }
58
+
59
+ /**
60
+ * Sanitize text for display in a single-line status.
61
+ */
62
+ function sanitizeStatusText(text: string): string {
63
+ return text
64
+ .replace(/[\r\n\t]/g, " ")
65
+ .replace(/ +/g, " ")
66
+ .trim();
67
+ }
68
+
69
+ function getRecoveryState(): RecoveryDashboardState | undefined {
70
+ return sharedState.recovery;
71
+ }
72
+
73
+ function formatCooldownRemaining(until: number, now: number = Date.now()): string {
74
+ const remainingMs = Math.max(0, until - now);
75
+ const totalSeconds = Math.ceil(remainingMs / 1000);
76
+ if (totalSeconds < 60) return `${totalSeconds}s`;
77
+ const minutes = Math.floor(totalSeconds / 60);
78
+ const seconds = totalSeconds % 60;
79
+ return seconds > 0 ? `${minutes}m${seconds}s` : `${minutes}m`;
80
+ }
81
+
82
+ function summarizeCooldown(cooldowns: RecoveryCooldownSummary[] | undefined): string | null {
83
+ if (!cooldowns || cooldowns.length === 0) return null;
84
+ const next = [...cooldowns].sort((a, b) => a.until - b.until)[0];
85
+ const target = next.scope === "provider"
86
+ ? next.provider ?? next.key
87
+ : next.modelId ? `${next.provider ?? "candidate"}/${next.modelId}` : next.key;
88
+ return `${target} ${formatCooldownRemaining(next.until)}`;
89
+ }
90
+
91
+ const CLEAVE_STALE_MS = 30_000;
92
+ /** Recovery notices auto-suppress in compact mode after this many ms with no new error. */
93
+ const RECOVERY_STALE_MS = 45_000;
94
+
95
+ type PrioritySegment = {
96
+ text: string;
97
+ priority?: "high" | "low";
98
+ };
99
+
100
+ function joinPrioritySegments(width: number, segments: PrioritySegment[], separator = " "): string {
101
+ if (width <= 0) return "";
102
+
103
+ const high = segments.filter((s) => s.text && s.priority !== "low");
104
+ const low = segments.filter((s) => s.text && s.priority === "low");
105
+ const ordered = [...high, ...low];
106
+ if (ordered.length === 0) return "";
107
+
108
+ const fitted: string[] = [];
109
+ for (const segment of ordered) {
110
+ const candidate = fitted.length === 0 ? segment.text : `${fitted.join(separator)}${separator}${segment.text}`;
111
+ if (visibleWidth(candidate) <= width) {
112
+ fitted.push(segment.text);
113
+ continue;
114
+ }
115
+
116
+ if (segment.priority === "low") {
117
+ continue;
118
+ }
119
+
120
+ const prefix = fitted.length === 0 ? "" : `${fitted.join(separator)}${separator}`;
121
+ const remaining = Math.max(1, width - visibleWidth(prefix));
122
+ if (remaining > 0) {
123
+ fitted.push(truncateToWidth(segment.text, remaining, "…"));
124
+ }
125
+ break;
126
+ }
127
+
128
+ const joined = fitted.join(separator);
129
+ return visibleWidth(joined) <= width ? joined : truncateToWidth(joined, width, "…");
130
+ }
131
+
132
+ function composePrimaryMetaLine(
133
+ width: number,
134
+ primary: string,
135
+ metadata: string[],
136
+ separator = " · ",
137
+ ): string {
138
+ return joinPrioritySegments(width, [
139
+ { text: primary, priority: "high" },
140
+ ...metadata.filter(Boolean).map((text) => ({ text, priority: "low" as const })),
141
+ ], separator);
142
+ }
143
+
144
+ export class DashboardFooter implements Component {
145
+ private tui: TUI;
146
+ private theme: Theme;
147
+ private footerData: ReadonlyFooterDataProvider;
148
+ private dashState: DashboardState;
149
+ private ctxRef: ExtensionContext | null = null;
150
+
151
+ /** Cached cumulative token stats — updated incrementally. */
152
+ private cachedTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
153
+ private cachedThinkingLevel = "off";
154
+ private lastEntryCount = 0;
155
+
156
+ constructor(
157
+ tui: TUI,
158
+ theme: Theme,
159
+ footerData: ReadonlyFooterDataProvider,
160
+ dashState: DashboardState,
161
+ ) {
162
+ this.tui = tui;
163
+ this.theme = theme;
164
+ this.footerData = footerData;
165
+ this.dashState = dashState;
166
+ }
167
+
168
+ /** Update the extension context reference (called on each event) */
169
+ setContext(ctx: ExtensionContext): void {
170
+ this.ctxRef = ctx;
171
+ }
172
+
173
+ /** No-op — theme is passed by reference */
174
+ invalidate(): void {}
175
+
176
+ dispose(): void {
177
+ this.ctxRef = null;
178
+ }
179
+
180
+ render(width: number): string[] {
181
+ debug("dashboard", "render", {
182
+ mode: this.dashState.mode,
183
+ width,
184
+ hasDT: !!sharedState.designTree,
185
+ hasOS: !!sharedState.openspec,
186
+ hasCL: !!sharedState.cleave,
187
+ hasCtx: !!this.ctxRef,
188
+ hasTheme: !!this.theme,
189
+ themeFgType: typeof this.theme?.fg,
190
+ });
191
+ try {
192
+ if (this.dashState.mode === "raised") {
193
+ return this.renderRaised(width);
194
+ }
195
+ // compact, panel, focused — all use compact footer (panel/focused show detail in overlay)
196
+ return this.renderCompact(width);
197
+ } catch (err: any) {
198
+ debug("dashboard", "render:ERROR", {
199
+ error: err?.message,
200
+ stack: err?.stack?.split("\n").slice(0, 5).join(" | "),
201
+ });
202
+ return [`[dashboard render error: ${err?.message}]`];
203
+ }
204
+ }
205
+
206
+ // ── Compact Mode (Layer 0) ────────────────────────────────────
207
+
208
+ private renderCompact(width: number): string[] {
209
+ const theme = this.theme;
210
+ const lines: string[] = [];
211
+
212
+ // Width breakpoints — expand details as space allows
213
+ const wide = width >= 120;
214
+ const ultraWide = width >= 160;
215
+
216
+ // Line 1: Dashboard summary + context gauge
217
+ const dashParts: PrioritySegment[] = [];
218
+
219
+ // Design tree summary — responsive expansion
220
+ const dt = sharedState.designTree;
221
+ if (dt && dt.nodeCount > 0) {
222
+ if (ultraWide && dt.focusedNode) {
223
+ // Ultra-wide: show focused node title inline
224
+ const statusIcon = dt.focusedNode.status === "decided" ? "●"
225
+ : dt.focusedNode.status === "implementing" ? "⚙"
226
+ : dt.focusedNode.status === "exploring" ? "◐"
227
+ : "○";
228
+ const qSuffix = dt.focusedNode.questions.length > 0
229
+ ? theme.fg("dim", ` (${dt.focusedNode.questions.length}?)`)
230
+ : "";
231
+ dashParts.push({
232
+ text: theme.fg("accent", `◈ ${dt.decidedCount}/${dt.nodeCount}`) +
233
+ ` ${statusIcon} ${dt.focusedNode.title}${qSuffix}`,
234
+ });
235
+ } else if (wide) {
236
+ // Wide: spell out counts, no node IDs (visible in raised mode)
237
+ const parts = [`${dt.decidedCount} decided`];
238
+ if (dt.exploringCount > 0) parts.push(`${dt.exploringCount} exploring`);
239
+ if (dt.implementingCount > 0) parts.push(`${dt.implementingCount} impl`);
240
+ if (dt.openQuestionCount > 0) parts.push(`${dt.openQuestionCount}?`);
241
+ dashParts.push({ text: theme.fg("accent", `◈ Design`) + theme.fg("dim", ` ${parts.join(", ")}`) });
242
+ } else {
243
+ // Narrow: terse
244
+ let dtSummary = `◈ D:${dt.decidedCount}`;
245
+ if (dt.implementingCount > 0) dtSummary += ` I:${dt.implementingCount}`;
246
+ dtSummary += `/${dt.nodeCount}`;
247
+ dashParts.push({ text: theme.fg("accent", dtSummary) });
248
+ }
249
+ }
250
+
251
+ // OpenSpec summary — responsive expansion
252
+ const os = sharedState.openspec;
253
+ if (os && os.changes.length > 0) {
254
+ const active = os.changes.filter(c => c.stage !== "archived");
255
+ if (active.length > 0) {
256
+ if (wide) {
257
+ // Wide: aggregate progress only — individual changes visible in raised mode
258
+ const totalDone = active.reduce((s, c) => s + c.tasksDone, 0);
259
+ const totalAll = active.reduce((s, c) => s + c.tasksTotal, 0);
260
+ const allDone = totalAll > 0 && totalDone >= totalAll;
261
+ const progress = totalAll > 0
262
+ ? theme.fg(allDone ? "success" : "dim", ` ${totalDone}/${totalAll}`)
263
+ : "";
264
+ const icon = allDone ? theme.fg("success", " ✓") : "";
265
+ dashParts.push({
266
+ text: theme.fg("accent", `◎ Impl`) +
267
+ theme.fg("dim", ` ${active.length} change${active.length > 1 ? "s" : ""}`) +
268
+ progress + icon,
269
+ });
270
+ } else {
271
+ dashParts.push({ text: theme.fg("accent", `◎ Impl:${active.length}`) });
272
+ }
273
+ }
274
+ }
275
+
276
+ // Cleave summary — responsive expansion
277
+ const cl = sharedState.cleave;
278
+ if (cl) {
279
+ if (cl.status === "idle") {
280
+ dashParts.push({ text: theme.fg("dim", "⚡ idle") });
281
+ } else if (cl.status === "done") {
282
+ const childInfo = wide && cl.children
283
+ ? ` ${cl.children.filter(c => c.status === "done").length}/${cl.children.length}`
284
+ : "";
285
+ dashParts.push({ text: theme.fg("success", `⚡ done${childInfo}`) });
286
+ } else if (cl.status === "failed") {
287
+ dashParts.push({ text: theme.fg("error", "⚡ fail") });
288
+ } else {
289
+ // Active dispatch — show child progress + lastLine activity hint
290
+ if (wide && cl.children && cl.children.length > 0) {
291
+ const done = cl.children.filter(c => c.status === "done").length;
292
+ const running = cl.children.filter(c => c.status === "running").length;
293
+ // Show the last active line from whichever running child has one
294
+ const activeChild = cl.children.find(c => c.status === "running" && c.lastLine);
295
+ const activityHint = activeChild?.lastLine
296
+ ? theme.fg("dim", ` ${activeChild.lastLine.slice(0, 40)}…`)
297
+ : "";
298
+ dashParts.push({
299
+ text: theme.fg("warning", `⚡ ${cl.status}`) +
300
+ theme.fg("dim", ` ${done}✓ ${running}⟳ /${cl.children.length}`) +
301
+ activityHint,
302
+ });
303
+ } else {
304
+ dashParts.push({ text: theme.fg("warning", `⚡ ${cl.status}`) });
305
+ }
306
+ }
307
+ }
308
+
309
+ const recoveryLine = this.buildRecoveryCompactSummary(width, wide);
310
+ if (recoveryLine) {
311
+ dashParts.push({ text: recoveryLine });
312
+ }
313
+
314
+ // Context gauge — wider bar at wider terminals
315
+ const barWidth = ultraWide ? 24 : wide ? 20 : 16;
316
+ const gauge = this.buildContextGauge(barWidth);
317
+ if (gauge) {
318
+ dashParts.push({ text: gauge });
319
+ }
320
+
321
+ // Compact mode should stay dashboard-first, but still expose the active
322
+ // provider/model in a terse way so multi-provider routing is visible.
323
+ const ctx = this.ctxRef;
324
+ const model = ctx?.model;
325
+ if (model && wide) {
326
+ const multiProvider = this.footerData.getAvailableProviderCount() > 1;
327
+ const driverLabel = multiProvider ? model.provider : "default";
328
+ const modelLabel = multiProvider ? `${driverLabel}/${model.id}` : model.id;
329
+ dashParts.push({
330
+ text: theme.fg("dim", "Model ") + theme.fg("muted", modelLabel),
331
+ priority: "low",
332
+ });
333
+ }
334
+
335
+ // Append /dash hint for discoverability (varies by mode)
336
+ const dashHint = this.dashState.mode === "panel"
337
+ ? theme.fg("dim", "/dashboard to close")
338
+ : theme.fg("dim", "/dash to expand");
339
+
340
+ const compactLine = joinPrioritySegments(width, [
341
+ ...dashParts,
342
+ { text: dashHint, priority: "low" },
343
+ ]);
344
+ lines.push(compactLine || truncateToWidth(dashHint, width, "…"));
345
+
346
+ // Compact mode is intentionally dashboard-only. Detailed footer metadata
347
+ // stays in raised mode so the compact footer does not look like the built-in
348
+ // footer is still leaking through.
349
+ return lines;
350
+ }
351
+
352
+ // ── Raised Mode (Layer 1) ─────────────────────────────────────
353
+
354
+ private renderRaised(width: number): string[] {
355
+ return width >= 120 ? this.renderRaisedWide(width) : this.renderRaisedStacked(width);
356
+ }
357
+
358
+ /**
359
+ * Build the git branch tree lines for the raised layout.
360
+ * Reads local branches from .git/refs/heads/ (no shell spawn).
361
+ */
362
+ private buildBranchTree(width: number): string[] {
363
+ const MAX_BRANCHES = 8;
364
+ const cwd = process.cwd();
365
+ const repoName = cwd.split("/").pop() ?? cwd;
366
+ const currentBranch = this.footerData.getGitBranch();
367
+ const allBranches = readLocalBranches(cwd);
368
+ // Cap branches fed to the tree renderer; append a hint if truncated
369
+ const truncatedBranches = allBranches.length > MAX_BRANCHES
370
+ ? allBranches.slice(0, MAX_BRANCHES)
371
+ : allBranches;
372
+ const hiddenCount = allBranches.length > MAX_BRANCHES
373
+ ? allBranches.length - MAX_BRANCHES
374
+ : 0;
375
+ const designNodes = sharedState.designTree?.nodes?.map((n) => ({
376
+ branches: n.branches ?? [],
377
+ title: n.title,
378
+ }));
379
+ const lines = buildBranchTreeLines(
380
+ { repoName, currentBranch, allBranches: truncatedBranches, designNodes },
381
+ this.theme,
382
+ );
383
+ const result = lines.map((l) => truncateToWidth(l, width, "…"));
384
+ if (hiddenCount > 0) {
385
+ result.push(
386
+ this.theme.fg("dim", ` … ${hiddenCount} more branches`) +
387
+ this.theme.fg("dim", " — /dashboard to expand"),
388
+ );
389
+ }
390
+ return result;
391
+ }
392
+
393
+ /**
394
+ * Render content + footer lines inside a rounded box with a top border label
395
+ * and a `/dash to compact` hint embedded in the bottom border.
396
+ *
397
+ * @param targetHeight Optional fixed height — pads content with blank lines
398
+ * so the total box reaches this row count. Omit (or 0)
399
+ * to render at natural content height.
400
+ */
401
+ private renderBoxed(
402
+ contentLines: string[],
403
+ footerLines: string[],
404
+ topLineContent: string,
405
+ width: number,
406
+ targetHeight = 0,
407
+ ): string[] {
408
+ const theme = this.theme;
409
+ const innerWidth = width - 4; // 2 for │ borders + 2 for padding spaces
410
+
411
+ const b = (s: string) => theme.fg("border", s);
412
+
413
+ const wrapLine = (line: string) =>
414
+ b(BOX.v) + " " + padRight(truncateToWidth(line, innerWidth, "…"), innerWidth) + " " + b(BOX.v);
415
+
416
+ // Top border overhead is 5 chars (╭─·space·╮), one more than content lines (│·space·│ = 4).
417
+ // Truncate topLineContent to width-5 so the border never exceeds terminal width.
418
+ const topMaxWidth = width - 5;
419
+ const safeTopLine = visibleWidth(topLineContent) > topMaxWidth
420
+ ? truncateToWidth(topLineContent, topMaxWidth, "…")
421
+ : topLineContent;
422
+ const topPad = Math.max(0, topMaxWidth - visibleWidth(safeTopLine));
423
+ const topBorder = b(BOX.tl) + b(BOX.h) + " " + safeTopLine + " " + b(BOX.h.repeat(topPad)) + b(BOX.tr);
424
+
425
+ const separator = b(BOX.vr) + b(BOX.h.repeat(width - 2)) + b(BOX.vl);
426
+
427
+ const dashHint = " /dash to compact · /dashboard to expand ";
428
+ const botPad = Math.max(0, width - 2 - visibleWidth(dashHint));
429
+ const bottomBorder = b(BOX.bl) + theme.fg("dim", dashHint) + b(BOX.h.repeat(botPad)) + b(BOX.br);
430
+
431
+ // Compute how many blank padding lines we need in the content area so the
432
+ // total rendered box reaches targetHeight.
433
+ // box height = 1 (top) + content + [1 separator + footer] + 1 (bottom)
434
+ const boxChrome = 1 + 1 + (footerLines.length > 0 ? 1 + footerLines.length : 0);
435
+ const paddedContentLength = targetHeight > 0
436
+ ? Math.max(contentLines.length, targetHeight - boxChrome)
437
+ : contentLines.length;
438
+
439
+ const lines: string[] = [topBorder];
440
+ for (const line of contentLines) lines.push(wrapLine(line));
441
+ // Fill blank rows up to paddedContentLength
442
+ for (let i = contentLines.length; i < paddedContentLength; i++) {
443
+ lines.push(wrapLine(""));
444
+ }
445
+ if (footerLines.length > 0) {
446
+ lines.push(separator);
447
+ for (const line of footerLines) lines.push(wrapLine(line));
448
+ }
449
+ lines.push(bottomBorder);
450
+ return lines;
451
+ }
452
+
453
+ /**
454
+ * Stacked layout for narrow terminals (<120 cols).
455
+ * All sections rendered full-width inside a corner-bounded box.
456
+ */
457
+ private renderRaisedStacked(width: number): string[] {
458
+ const innerWidth = width - 4;
459
+ const branchLines = this.buildBranchTree(innerWidth);
460
+ const [topLine = "", ...extraBranchLines] = branchLines;
461
+
462
+ // The first branch line is embedded in the top border as "╭─ [topLine] ─╮",
463
+ // which adds a 3-char prefix (╭─·). Content lines are wrapped with "│·"
464
+ // (2-char prefix). Shift continuation branch lines right by 1 to keep
465
+ // ├─/└─ connectors vertically aligned with the ┬ junction in the border.
466
+ const alignedBranchLines = extraBranchLines.map((l) => " " + l);
467
+
468
+ const contentLines = [
469
+ ...alignedBranchLines,
470
+ ...this.buildDesignTreeLines(innerWidth),
471
+ ...this.buildOpenSpecLines(innerWidth),
472
+ ...this.buildRecoveryLines(innerWidth),
473
+ ...this.buildCleaveLines(innerWidth),
474
+ ];
475
+
476
+ // Render at natural content height — the box grows upward from the footer
477
+ // as branches/specs/cleave tasks are added. Full-screen expansion lives
478
+ // in the /dashboard overlay (overlay.ts), not here.
479
+ return this.renderBoxed(contentLines, this.buildFooterZone(innerWidth), topLine, width);
480
+ }
481
+
482
+ /**
483
+ * Wide layout (≥120 cols) — two-column content inside a corner-bounded box.
484
+ * Left: Design tree + Recovery + Cleave (active work context)
485
+ * Right: Implementation (spec/task progress)
486
+ * Footer zone: shared meta, memory, footer data
487
+ */
488
+ private renderRaisedWide(width: number): string[] {
489
+ const innerWidth = width - 4;
490
+ const leftColWidth = Math.floor((innerWidth - 1) / 2);
491
+ const rightColWidth = innerWidth - leftColWidth - 1;
492
+ const colDivider = this.theme.fg("dim", BOX.v);
493
+
494
+ const branchLines = this.buildBranchTree(innerWidth);
495
+ const [topLine = "", ...extraBranchLines] = branchLines;
496
+
497
+ // Same 1-char alignment correction as renderRaisedStacked.
498
+ const alignedBranchLines = extraBranchLines.map((l) => " " + l);
499
+
500
+ const leftLines = [
501
+ ...this.buildDesignTreeLines(leftColWidth),
502
+ ...this.buildRecoveryLines(leftColWidth),
503
+ ...this.buildCleaveLines(leftColWidth),
504
+ ];
505
+ const rightLines = this.buildOpenSpecLines(rightColWidth);
506
+
507
+ const contentLines: string[] = [
508
+ ...alignedBranchLines,
509
+ ...(leftLines.length > 0 || rightLines.length > 0
510
+ ? mergeColumns(leftLines, rightLines, leftColWidth, rightColWidth, colDivider)
511
+ : []),
512
+ ];
513
+
514
+ // Same as stacked: natural content height, grows up from footer as needed.
515
+ return this.renderBoxed(contentLines, this.buildFooterZone(innerWidth), topLine, width);
516
+ }
517
+
518
+ // ── HUD Footer Zone (raised mode) ────────────────────────────
519
+
520
+ /**
521
+ * Dim section divider with a lowercase label flush-left.
522
+ * Fills the remaining inner width with ─ chars.
523
+ *
524
+ * ── context ────────────────────────────────────────────
525
+ */
526
+ private buildHudSectionDivider(label: string, innerWidth: number): string {
527
+ const prefix = `── ${label} `;
528
+ const fill = Math.max(0, innerWidth - visibleWidth(prefix));
529
+ return this.theme.fg("dim", prefix + "─".repeat(fill));
530
+ }
531
+
532
+ /**
533
+ * HUD context section — two lines:
534
+ * ▐▓▓████░░░░░░░░░░░░░░░▌ 43% / 200k T·8
535
+ * ▸ anthropic / claude-opus-4-6 · ◉ high
536
+ *
537
+ * Bar uses ▐▌ half-block delimiters (panel-slot look).
538
+ * ▸ acts as active-pointer glyph; ◉/○ for thinking on/off.
539
+ */
540
+ private buildHudContextLines(width: number): string[] {
541
+ const theme = this.theme;
542
+ const ctx = this.ctxRef;
543
+ if (!ctx) return [];
544
+
545
+ const wide = width >= 100;
546
+ const barWidth = wide ? 22 : 14;
547
+ const lines: string[] = [];
548
+
549
+ // ── Bar line ──────────────────────────────────────────────
550
+ const usage = ctx.getContextUsage();
551
+ const contextWindow = usage?.contextWindow ?? 0;
552
+ const gaugeModel = buildContextGaugeModel({
553
+ percent: usage?.percent,
554
+ contextWindow,
555
+ memoryTokenEstimate: sharedState.memoryTokenEstimate,
556
+ turns: this.dashState.turns,
557
+ }, barWidth);
558
+
559
+ const bLeft = theme.fg("dim", "▐");
560
+ const bRight = theme.fg("dim", "▌");
561
+
562
+ if (gaugeModel.state === "unknown") {
563
+ const unknownBar = theme.fg("dim", "?".repeat(barWidth));
564
+ const winStr = contextWindow > 0 ? theme.fg("dim", ` / ${formatTokens(contextWindow)}`) : "";
565
+ lines.push(` ${bLeft}${unknownBar}${bRight} ${theme.fg("dim", "?")}${winStr}`);
566
+ } else {
567
+ const percent = gaugeModel.percent ?? 0;
568
+ const otherColor: ThemeColor = percent > 70 ? "error" : percent > 45 ? "warning" : "muted";
569
+ let bar = "";
570
+ if (gaugeModel.memoryBlocks > 0) bar += theme.fg("accent", "▓".repeat(gaugeModel.memoryBlocks));
571
+ if (gaugeModel.otherBlocks > 0) bar += theme.fg(otherColor, "█".repeat(gaugeModel.otherBlocks));
572
+ if (gaugeModel.freeBlocks > 0) bar += theme.fg("border", "░".repeat(gaugeModel.freeBlocks));
573
+
574
+ const pctNum = Math.round(percent);
575
+ const pctColor: ThemeColor = percent > 70 ? "error" : percent > 45 ? "warning" : "dim";
576
+ const pctStr = theme.fg(pctColor, `${pctNum}%`);
577
+ const winStr = contextWindow > 0 ? theme.fg("dim", ` / ${formatTokens(contextWindow)}`) : "";
578
+ const turnStr = gaugeModel.turns > 0 ? ` ${theme.fg("dim", `T·${gaugeModel.turns}`)}` : "";
579
+ lines.push(` ${bLeft}${bar}${bRight} ${pctStr}${winStr}${turnStr}`);
580
+ }
581
+
582
+ // ── Provider / model / thinking line ──────────────────────
583
+ const m = ctx.model;
584
+ if (m) {
585
+ const multiProvider = this.footerData.getAvailableProviderCount() > 1;
586
+ const pointer = theme.fg("accent", "▸");
587
+ const dot = theme.fg("dim", " · ");
588
+
589
+ const providerModel = multiProvider
590
+ ? `${pointer} ${theme.fg("muted", m.provider)} ${theme.fg("dim", "/")} ${theme.fg("muted", m.id)}`
591
+ : `${pointer} ${theme.fg("muted", m.id)}`;
592
+
593
+ const parts: string[] = [providerModel];
594
+
595
+ if (m.reasoning) {
596
+ const thinkColor: ThemeColor = this.cachedThinkingLevel === "high" ? "accent"
597
+ : this.cachedThinkingLevel === "medium" ? "muted"
598
+ : "dim";
599
+ const thinkIcon = this.cachedThinkingLevel === "off"
600
+ ? theme.fg("dim", "○")
601
+ : theme.fg(thinkColor, "◉");
602
+ parts.push(`${thinkIcon} ${theme.fg(thinkColor, this.cachedThinkingLevel)}`);
603
+ }
604
+
605
+ if (this.cachedTokens.cost > 0) {
606
+ parts.push(theme.fg("dim", `$${this.cachedTokens.cost.toFixed(3)}`));
607
+ }
608
+
609
+ lines.push(truncateToWidth(` ${parts.join(dot)}`, width, "…"));
610
+ }
611
+
612
+ return lines;
613
+ }
614
+
615
+ /**
616
+ * HUD memory section — single line:
617
+ * ⌗ 1167 · inj 23 · wm 5 · ep 3 · gl 2 · ~4.2k
618
+ *
619
+ * ⌗ is the established memory glyph. Sub-labels are dim, values muted.
620
+ */
621
+ private buildHudMemoryLine(width: number): string {
622
+ const theme = this.theme;
623
+ const extStatuses = this.footerData.getExtensionStatuses();
624
+ const memStatus = extStatuses.get("memory") ?? "";
625
+ const totalMatch = memStatus.match(/(\d+)\s+facts/);
626
+ const totalFacts = totalMatch ? parseInt(totalMatch[1], 10) : null;
627
+ const metrics = sharedState.lastMemoryInjection;
628
+
629
+ if (!metrics && totalFacts === null) return "";
630
+
631
+ const sep = theme.fg("dim", " · ");
632
+ const parts: string[] = [];
633
+
634
+ if (totalFacts !== null) {
635
+ parts.push(`${theme.fg("accent", "⌗")} ${theme.fg("muted", String(totalFacts))}`);
636
+ }
637
+
638
+ if (metrics) {
639
+ if (metrics.projectFactCount > 0)
640
+ parts.push(theme.fg("dim", "inj ") + theme.fg("muted", String(metrics.projectFactCount)));
641
+ if (metrics.workingMemoryFactCount > 0)
642
+ parts.push(theme.fg("dim", "wm ") + theme.fg("muted", String(metrics.workingMemoryFactCount)));
643
+ if (metrics.episodeCount > 0)
644
+ parts.push(theme.fg("dim", "ep ") + theme.fg("muted", String(metrics.episodeCount)));
645
+ if (metrics.globalFactCount > 0)
646
+ parts.push(theme.fg("dim", "gl ") + theme.fg("muted", String(metrics.globalFactCount)));
647
+ parts.push(theme.fg("dim", `~${metrics.estimatedTokens}`));
648
+ } else {
649
+ parts.push(theme.fg("dim", "pending injection"));
650
+ }
651
+
652
+ return truncateToWidth(` ${parts.join(sep)}`, width, "…");
653
+ }
654
+
655
+ /**
656
+ * HUD system section — one or two lines:
657
+ * ⌂ ~/workspace/ai/omegon ◦ my-session
658
+ * ⚡ dispatch 3/8 · ◎ 2 active · ↑ ok
659
+ *
660
+ * Extension badges reuse the established content-section glyphs so the
661
+ * footer visually echoes the dashboard sections above it.
662
+ */
663
+ private buildHudSystemLines(width: number): string[] {
664
+ const theme = this.theme;
665
+ const ctx = this.ctxRef;
666
+ const lines: string[] = [];
667
+
668
+ // ── pwd + session ─────────────────────────────────────────
669
+ let pwd = process.cwd();
670
+ const home = process.env.HOME || process.env.USERPROFILE;
671
+ if (home && pwd.startsWith(home)) pwd = `~${pwd.slice(home.length)}`;
672
+
673
+ const pwdStr = theme.fg("dim", "⌂ ") + theme.fg("muted", pwd);
674
+ const sessionName = ctx?.sessionManager?.getSessionName?.();
675
+ const sessionStr = sessionName
676
+ ? theme.fg("dim", "◦ ") + theme.fg("muted", sessionName)
677
+ : "";
678
+
679
+ lines.push(sessionStr
680
+ ? leftRight(` ${pwdStr}`, sessionStr, width)
681
+ : truncateToWidth(` ${pwdStr}`, width, "…"),
682
+ );
683
+
684
+ // ── Extension badges ──────────────────────────────────────
685
+ const GLYPH: Record<string, string> = {
686
+ "cleave": "⚡",
687
+ "openspec": "◎",
688
+ "version-check": "↑",
689
+ "version": "↑",
690
+ "design-tree": "◈",
691
+ "dashboard": "◐",
692
+ };
693
+
694
+ const extStatuses = this.footerData.getExtensionStatuses();
695
+ const badges = Array.from(extStatuses.entries())
696
+ .filter(([name]) => name !== "memory")
697
+ .sort(([a], [b]) => a.localeCompare(b))
698
+ .map(([name, text]) => {
699
+ const glyph = GLYPH[name] ?? "▸";
700
+ return theme.fg("accent", glyph) + " " + theme.fg("dim", sanitizeStatusText(text));
701
+ });
702
+
703
+ if (badges.length > 0) {
704
+ lines.push(truncateToWidth(
705
+ ` ${badges.join(theme.fg("dim", " · "))}`,
706
+ width, "…",
707
+ ));
708
+ }
709
+
710
+ return lines;
711
+ }
712
+
713
+ /**
714
+ * Assemble the full HUD footer zone from the three named sections.
715
+ * Sections collapse when they have no data to show.
716
+ */
717
+ private buildFooterZone(width: number): string[] {
718
+ // Keep token cache current (not called in compact mode — intentional).
719
+ this._updateTokenCache();
720
+
721
+ const zone: string[] = [];
722
+
723
+ const contextLines = this.buildHudContextLines(width);
724
+ if (contextLines.length > 0) {
725
+ zone.push(this.buildHudSectionDivider("context", width));
726
+ zone.push(...contextLines);
727
+ }
728
+
729
+ const memLine = this.buildHudMemoryLine(width);
730
+ if (memLine) {
731
+ zone.push(this.buildHudSectionDivider("memory", width));
732
+ zone.push(memLine);
733
+ }
734
+
735
+ const systemLines = this.buildHudSystemLines(width);
736
+ if (systemLines.length > 0) {
737
+ zone.push(this.buildHudSectionDivider("system", width));
738
+ zone.push(...systemLines);
739
+ }
740
+
741
+ return zone;
742
+ }
743
+
744
+ // ── Section builders (shared by stacked + wide layouts) ───────
745
+
746
+ private buildRecoveryCompactSummary(width: number, wide: boolean): string {
747
+ const theme = this.theme;
748
+ const recovery = getRecoveryState();
749
+ if (!recovery) return "";
750
+
751
+ // Auto-suppress stale recovery notices in compact mode — they outlive their
752
+ // usefulness quickly and crowd out model/driver/thinking info.
753
+ if (Date.now() - recovery.timestamp > RECOVERY_STALE_MS) return "";
754
+
755
+ // Past-tense labels for auto-handled actions so they read as status, not
756
+ // directives. 'escalate' is the only case where the operator must act.
757
+ const actionColor: ThemeColor = recovery.action === "retry" ? "warning"
758
+ : recovery.action === "switch_candidate" || recovery.action === "switch_offline" ? "accent"
759
+ : recovery.action === "cooldown" ? "warning"
760
+ : recovery.action === "escalate" ? "error"
761
+ : "dim";
762
+ const actionLabel = recovery.action === "retry" ? "retried"
763
+ : recovery.action === "switch_candidate" ? "switched"
764
+ : recovery.action === "switch_offline" ? "went offline"
765
+ : recovery.action === "cooldown" ? "cooling"
766
+ : recovery.action === "escalate" ? "escalated"
767
+ : "observed";
768
+
769
+ // Compact mode: terse badge. Wide adds provider/model context.
770
+ // Escalate appends a dim command hint so the operator knows what to do.
771
+ const summary = wide ? `${recovery.provider}/${recovery.modelId}` : "";
772
+ const cooldown = summarizeCooldown(recovery.cooldowns);
773
+ const escalateHint = recovery.action === "escalate"
774
+ ? theme.fg("dim", "→ /set-model-tier")
775
+ : "";
776
+ const icon = recovery.action === "escalate" ? "⚠" : "↺";
777
+ return composePrimaryMetaLine(width,
778
+ theme.fg(actionColor, `${icon} ${actionLabel}`),
779
+ [summary ? theme.fg("dim", summary) : "", cooldown ? theme.fg("dim", cooldown) : "", escalateHint].filter(Boolean),
780
+ );
781
+ }
782
+
783
+ private buildRecoveryLines(width: number): string[] {
784
+ const theme = this.theme;
785
+ const recovery = getRecoveryState();
786
+ if (!recovery) return [];
787
+
788
+ // Collapse non-actionable states (observed/informational) in raised mode —
789
+ // they add noise without operator-relevant information.
790
+ if (recovery.action === "observe") return [];
791
+
792
+ const actionColor: ThemeColor = recovery.action === "retry" ? "warning"
793
+ : recovery.action === "switch_candidate" || recovery.action === "switch_offline" ? "accent"
794
+ : recovery.action === "cooldown" ? "warning"
795
+ : recovery.action === "escalate" ? "error"
796
+ : "dim";
797
+ const actionLabel = recovery.action === "retry" ? "retried"
798
+ : recovery.action === "switch_candidate" ? "switched candidate"
799
+ : recovery.action === "switch_offline" ? "went offline"
800
+ : recovery.action === "cooldown" ? "cooling"
801
+ : recovery.action === "escalate" ? "escalated — operator action required"
802
+ : "observed";
803
+
804
+ const recoveryIcon = recovery.action === "escalate" ? "⚠" : "↺";
805
+ const escalateHint = recovery.action === "escalate"
806
+ ? theme.fg("dim", "→ /set-model-tier to switch provider/driver")
807
+ : "";
808
+
809
+ const headerParts = [theme.fg(actionColor, actionLabel), theme.fg("dim", recovery.classification)];
810
+ const lines = [composePrimaryMetaLine(
811
+ width,
812
+ theme.fg("accent", `${recoveryIcon} Recovery`),
813
+ headerParts,
814
+ )];
815
+ if (escalateHint) lines.push(escalateHint);
816
+
817
+ const target = recovery.target?.modelId
818
+ ? `${recovery.target.provider}/${recovery.target.modelId}`
819
+ : recovery.target?.provider;
820
+ const retry = recovery.retryCount != null && recovery.maxRetries != null
821
+ ? `${recovery.retryCount}/${recovery.maxRetries} retries`
822
+ : "";
823
+ const cooldown = summarizeCooldown(recovery.cooldowns);
824
+ lines.push(composePrimaryMetaLine(
825
+ width,
826
+ ` ${sanitizeStatusText(recovery.summary)}`,
827
+ [retry ? theme.fg("dim", retry) : "", target ? theme.fg("dim", `→ ${target}`) : "", cooldown ? theme.fg("dim", `cooldown ${cooldown}`) : ""],
828
+ ));
829
+
830
+ return lines;
831
+ }
832
+
833
+ private buildDesignTreeLines(width: number): string[] {
834
+ const theme = this.theme;
835
+ const lines: string[] = [];
836
+ const dt = sharedState.designTree;
837
+ if (!dt || dt.nodeCount === 0) return lines;
838
+
839
+ lines.push(theme.fg("accent", "◈ Design Tree"));
840
+
841
+ const statusParts: string[] = [];
842
+ if (dt.decidedCount > 0) statusParts.push(theme.fg("success", `${dt.decidedCount} decided`));
843
+ if (dt.implementingCount > 0) statusParts.push(theme.fg("accent", `${dt.implementingCount} implementing`));
844
+ if (dt.exploringCount > 0) statusParts.push(theme.fg("muted", `${dt.exploringCount} exploring`));
845
+ if (dt.blockedCount > 0) statusParts.push(theme.fg("error", `${dt.blockedCount} blocked`));
846
+ if (dt.deferredCount > 0) statusParts.push(theme.fg("dim", `${dt.deferredCount} deferred`));
847
+ if (dt.openQuestionCount > 0) statusParts.push(theme.fg("dim", `${dt.openQuestionCount}?`));
848
+
849
+ if (statusParts.length > 0) {
850
+ lines.push(" " + statusParts.join(" · "));
851
+ }
852
+
853
+ // Pipeline funnel row (after the status-counts line)
854
+ if (dt.designPipeline) {
855
+ const p = dt.designPipeline;
856
+ const funnelParts: string[] = [];
857
+ if (p.designing > 0) funnelParts.push(theme.fg("accent", `${p.designing} designing`));
858
+ if (p.decided > 0) funnelParts.push(theme.fg("success", `${p.decided} decided`));
859
+ if (p.implementing > 0) funnelParts.push(theme.fg("warning", `${p.implementing} implementing`));
860
+ if (p.done > 0) funnelParts.push(theme.fg("success", `${p.done} done`));
861
+ if (funnelParts.length > 0) {
862
+ lines.push(theme.fg("dim", " → ") + funnelParts.join(theme.fg("dim", " · ")));
863
+ }
864
+ if (p.needsSpec > 0) {
865
+ lines.push(theme.fg("dim", " ") + theme.fg("warning", `✦ ${p.needsSpec} need spec`));
866
+ }
867
+ }
868
+
869
+ // Focused node gets priority display
870
+ if (dt.focusedNode) {
871
+ const statusIcon = this.nodeStatusIcon(dt.focusedNode.status);
872
+ const qCount = dt.focusedNode.questions.length > 0
873
+ ? theme.fg("dim", ` — ${dt.focusedNode.questions.length} open questions`)
874
+ : "";
875
+ const branchExtra = (dt.focusedNode.branchCount ?? 0) > 1
876
+ ? theme.fg("dim", ` +${dt.focusedNode.branchCount! - 1}`)
877
+ : "";
878
+ const branchInfo = dt.focusedNode.status === "implementing" && dt.focusedNode.branch
879
+ ? theme.fg("dim", dt.focusedNode.branch) + branchExtra
880
+ : "";
881
+ const linkedTitle = linkDashboardFile(dt.focusedNode.title, dt.focusedNode.filePath);
882
+ // TODO(types-and-emission): DesignTreeFocusedNode lacks designSpec/assessmentResult fields.
883
+ // Once the sibling task adds those fields, call designSpecBadge here and append to the line.
884
+ lines.push(composePrimaryMetaLine(
885
+ width,
886
+ ` ${statusIcon} ${linkedTitle}`,
887
+ [branchInfo, qCount],
888
+ ));
889
+ }
890
+
891
+ // Implementing nodes (if no focused node)
892
+ const MAX_IMPL_NODES = 4;
893
+ if (dt.implementingNodes && dt.implementingNodes.length > 0 && !dt.focusedNode) {
894
+ for (const n of dt.implementingNodes.slice(0, MAX_IMPL_NODES)) {
895
+ const branchSuffix = n.branch ? theme.fg("dim", n.branch) : "";
896
+ const linkedTitle = linkDashboardFile(n.title, n.filePath);
897
+ lines.push(composePrimaryMetaLine(
898
+ width,
899
+ ` ${theme.fg("accent", "⚙")} ${linkedTitle}`,
900
+ [branchSuffix],
901
+ ));
902
+ }
903
+ if (dt.implementingNodes.length > MAX_IMPL_NODES) {
904
+ lines.push(
905
+ theme.fg("dim", ` … ${dt.implementingNodes.length - MAX_IMPL_NODES} more`) +
906
+ theme.fg("dim", " — /dashboard to expand"),
907
+ );
908
+ }
909
+ }
910
+
911
+ // If no focused node and no implementing nodes, show all nodes (up to MAX_NODES)
912
+ if (!dt.focusedNode && (!dt.implementingNodes || dt.implementingNodes.length === 0) && dt.nodes) {
913
+ const MAX_NODES = 6;
914
+ for (const n of dt.nodes.slice(0, MAX_NODES)) {
915
+ const icon = this.nodeStatusIcon(n.status);
916
+ const badge = designSpecBadge(n.designSpec, n.assessmentResult, (c, t) => theme.fg(c as any, t));
917
+ const badgeSep = badge ? " " : "";
918
+ const linkedId = linkDashboardFile(theme.fg("dim", n.id), n.filePath);
919
+ const qSuffix = n.questionCount > 0 ? theme.fg("dim", ` (${n.questionCount}?)`) : "";
920
+ const linkSuffix = n.openspecChange ? theme.fg("dim", " &") : "";
921
+ lines.push(composePrimaryMetaLine(
922
+ width,
923
+ ` ${icon}${badgeSep}${badge} ${linkedId}`,
924
+ [qSuffix + linkSuffix],
925
+ ));
926
+ }
927
+ if (dt.nodes.length > MAX_NODES) {
928
+ lines.push(
929
+ theme.fg("dim", ` … ${dt.nodes.length - MAX_NODES} more`) +
930
+ theme.fg("dim", " — /dashboard to expand"),
931
+ );
932
+ }
933
+ }
934
+
935
+ return lines;
936
+ }
937
+
938
+ private nodeStatusIcon(status: string): string {
939
+ const theme = this.theme;
940
+ switch (status) {
941
+ case "decided": return theme.fg("success", "●");
942
+ case "implementing": return theme.fg("accent", "⚙");
943
+ case "implemented": return theme.fg("success", "✓");
944
+ case "exploring": return theme.fg("accent", "◐");
945
+ case "blocked": return theme.fg("error", "✕");
946
+ case "deferred": return theme.fg("dim", "⊘");
947
+ case "seed": return theme.fg("muted", "○");
948
+ default: return theme.fg("muted", "○");
949
+ }
950
+ }
951
+
952
+ private buildOpenSpecLines(width: number): string[] {
953
+ const theme = this.theme;
954
+ const lines: string[] = [];
955
+ const os = sharedState.openspec;
956
+ if (!os || os.changes.length === 0) return lines;
957
+
958
+ const active = os.changes.filter(c => c.stage !== "archived");
959
+ if (active.length === 0) return lines;
960
+
961
+ const totalDone = active.reduce((s, c) => s + c.tasksDone, 0);
962
+ const totalAll = active.reduce((s, c) => s + c.tasksTotal, 0);
963
+ const allComplete = totalAll > 0 && totalDone >= totalAll;
964
+ const aggregateProgress = totalAll > 0
965
+ ? theme.fg(allComplete ? "success" : "dim", ` ${totalDone}/${totalAll}`)
966
+ : "";
967
+ lines.push(
968
+ theme.fg("accent", "◎ Implementation") +
969
+ theme.fg("dim", ` ${active.length} change${active.length > 1 ? "s" : ""}`) +
970
+ aggregateProgress,
971
+ );
972
+
973
+ const MAX_CHANGES = 5;
974
+ for (const c of active.slice(0, MAX_CHANGES)) {
975
+ const done = c.tasksTotal > 0 && c.tasksDone >= c.tasksTotal;
976
+ const icon = done ? theme.fg("success", "✓") : theme.fg("dim", "◦");
977
+
978
+ const stageColor = c.stage === "verifying" ? "warning"
979
+ : c.stage === "implementing" ? "accent"
980
+ : c.stage === "ready" ? "success"
981
+ : "dim";
982
+ const stageLabel = c.stage === "implementing" ? "impl"
983
+ : c.stage === "verifying" ? "verify"
984
+ : c.stage === "specified" ? "spec"
985
+ : c.stage === "planned" ? "plan"
986
+ : c.stage;
987
+
988
+ // Build a single compact metadata tag: "6/14 impl" or just "impl"
989
+ // Avoids double-separator noise from combining pre-punctuated segments.
990
+ const meta = [
991
+ c.tasksTotal > 0 ? theme.fg(done ? "success" : "dim", `${c.tasksDone}/${c.tasksTotal}`) : "",
992
+ stageLabel ? theme.fg(stageColor, stageLabel) : "",
993
+ ].filter(Boolean).join(" ");
994
+
995
+ const linkedName = linkOpenSpecChange(c.name, c.path);
996
+ lines.push(composePrimaryMetaLine(
997
+ width,
998
+ ` ${icon} ${linkedName}`,
999
+ meta ? [meta] : [],
1000
+ ));
1001
+ }
1002
+ if (active.length > MAX_CHANGES) {
1003
+ lines.push(
1004
+ theme.fg("dim", ` … ${active.length - MAX_CHANGES} more`) +
1005
+ theme.fg("dim", " — /dashboard to expand"),
1006
+ );
1007
+ }
1008
+
1009
+ return lines;
1010
+ }
1011
+
1012
+ private buildCleaveLines(width: number): string[] {
1013
+ const theme = this.theme;
1014
+ const lines: string[] = [];
1015
+ const cl = sharedState.cleave;
1016
+ if (!cl || cl.status === "idle") return lines;
1017
+
1018
+ const isTerminalState = cl.status === "done" || cl.status === "failed";
1019
+ if (isTerminalState && cl.updatedAt && (Date.now() - cl.updatedAt) > CLEAVE_STALE_MS) {
1020
+ return lines;
1021
+ }
1022
+
1023
+ const statusColor: ThemeColor = cl.status === "done" ? "success"
1024
+ : cl.status === "failed" ? "error"
1025
+ : "warning";
1026
+
1027
+ // ── Header: ⚡ Cleave dispatching 2/4 ✓ ──────────────────
1028
+ const children = cl.children ?? [];
1029
+ const doneCount = children.filter(c => c.status === "done").length;
1030
+ const failCount = children.filter(c => c.status === "failed").length;
1031
+ const countSuffix = children.length > 0
1032
+ ? [
1033
+ theme.fg("dim", `${doneCount}/${children.length}`),
1034
+ ...(failCount > 0 ? [theme.fg("error", `${failCount}✕`)] : []),
1035
+ ]
1036
+ : [];
1037
+ lines.push(composePrimaryMetaLine(
1038
+ width,
1039
+ theme.fg("accent", "⚡ Cleave"),
1040
+ [theme.fg(statusColor, cl.status), ...countSuffix],
1041
+ ));
1042
+
1043
+ // ── Per-child rows + activity ────────────────────────────────
1044
+ for (const child of children) {
1045
+ const isRunning = child.status === "running";
1046
+ const icon = child.status === "done" ? theme.fg("success", "✓")
1047
+ : child.status === "failed" ? theme.fg("error", "✕")
1048
+ : isRunning ? theme.fg("warning", "⟳")
1049
+ : theme.fg("dim", "○");
1050
+
1051
+ const elapsedSec = isRunning && child.startedAt
1052
+ ? Math.round((Date.now() - child.startedAt) / 1000)
1053
+ : (child.elapsed != null ? Math.round(child.elapsed / 1000) : null);
1054
+ const elapsed = elapsedSec != null ? theme.fg("dim", ` ${elapsedSec}s`) : "";
1055
+
1056
+ lines.push(truncateToWidth(` ${icon} ${theme.fg("muted", child.label)}${elapsed}`, width, "…"));
1057
+
1058
+ // Show last 2 ring-buffer lines for running children
1059
+ if (isRunning && child.recentLines && child.recentLines.length > 0) {
1060
+ const tail = child.recentLines.slice(-2);
1061
+ for (const l of tail) {
1062
+ lines.push(truncateToWidth(` ${theme.fg("dim", l)}`, width, "…"));
1063
+ }
1064
+ }
1065
+ }
1066
+
1067
+ return lines;
1068
+ }
1069
+
1070
+ // ── Context Gauge (compact mode only) ────────────────────────
1071
+
1072
+ private buildContextGauge(barWidth: number): string {
1073
+ const theme = this.theme;
1074
+ const ctx = this.ctxRef;
1075
+ if (!ctx) return "";
1076
+
1077
+ const usage = ctx.getContextUsage();
1078
+ const contextWindow = usage?.contextWindow ?? 0;
1079
+ const model = buildContextGaugeModel({
1080
+ percent: usage?.percent,
1081
+ contextWindow,
1082
+ memoryTokenEstimate: sharedState.memoryTokenEstimate,
1083
+ turns: this.dashState.turns,
1084
+ }, barWidth);
1085
+
1086
+ if (model.state === "unknown") {
1087
+ const unknownBar = theme.fg("dim", "?".repeat(barWidth));
1088
+ const windowStr = contextWindow > 0 ? theme.fg("dim", `/${formatTokens(contextWindow)}`) : "";
1089
+ const turnLabel = model.turns > 0 ? `${theme.fg("dim", `T${model.turns}`)} ` : "";
1090
+ return `${turnLabel}${unknownBar} ${theme.fg("dim", "?")}${windowStr}`;
1091
+ }
1092
+
1093
+ const percent = model.percent ?? 0;
1094
+
1095
+ // Severity color for non-memory context pressure
1096
+ const otherColor: ThemeColor = percent > 70 ? "error" : percent > 45 ? "warning" : "muted";
1097
+
1098
+ let bar = "";
1099
+ if (model.memoryBlocks > 0) bar += theme.fg("accent", "▓".repeat(model.memoryBlocks));
1100
+ if (model.otherBlocks > 0) bar += theme.fg(otherColor, "█".repeat(model.otherBlocks));
1101
+ if (model.freeBlocks > 0) bar += theme.fg("border", "░".repeat(model.freeBlocks));
1102
+
1103
+ const pctStr = `${Math.round(percent)}%`;
1104
+ const pctColored = percent > 70 ? theme.fg("error", pctStr)
1105
+ : percent > 45 ? theme.fg("warning", pctStr)
1106
+ : theme.fg("dim", pctStr);
1107
+ const windowStr = contextWindow > 0 ? theme.fg("dim", `/${formatTokens(contextWindow)}`) : "";
1108
+
1109
+ const turnLabel = model.turns > 0 ? `${theme.fg("dim", `T${model.turns}`)} ` : "";
1110
+ return `${turnLabel}${bar} ${pctColored}${windowStr}`;
1111
+ }
1112
+
1113
+ // ── Token cache ───────────────────────────────────────────────
1114
+
1115
+ /**
1116
+ * Incrementally scan new session entries and update the cached token/cost
1117
+ * accumulators and last-seen thinking level. Safe to call repeatedly; only
1118
+ * processes entries beyond `lastEntryCount`.
1119
+ */
1120
+ private _updateTokenCache(): void {
1121
+ const ctx = this.ctxRef;
1122
+ if (!ctx) return;
1123
+ try {
1124
+ const entries = ctx.sessionManager.getEntries();
1125
+ for (let i = this.lastEntryCount; i < entries.length; i++) {
1126
+ const entry = entries[i] as any;
1127
+ if (entry.type === "message" && entry.message?.role === "assistant") {
1128
+ const usage = entry.message.usage;
1129
+ if (usage) {
1130
+ this.cachedTokens.input += usage.input || 0;
1131
+ this.cachedTokens.output += usage.output || 0;
1132
+ this.cachedTokens.cacheRead += usage.cacheRead || 0;
1133
+ this.cachedTokens.cacheWrite += usage.cacheWrite || 0;
1134
+ this.cachedTokens.cost += usage.cost?.total || 0;
1135
+ }
1136
+ }
1137
+ if (entry.type === "thinking_level_change" && entry.thinkingLevel) {
1138
+ this.cachedThinkingLevel = entry.thinkingLevel;
1139
+ }
1140
+ }
1141
+ this.lastEntryCount = entries.length;
1142
+ } catch { /* session may not be ready */ }
1143
+ }
1144
+
1145
+ }