@pellux/goodvibes-tui 0.29.0 → 1.1.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 (83) hide show
  1. package/CHANGELOG.md +69 -98
  2. package/README.md +16 -7
  3. package/docs/foundation-artifacts/operator-contract.json +1 -1
  4. package/package.json +2 -2
  5. package/src/cli/ensure-goodvibes-gitignore.ts +32 -0
  6. package/src/cli/entrypoint.ts +2 -0
  7. package/src/core/conversation-line-cache.ts +432 -0
  8. package/src/core/conversation-rendering.ts +11 -3
  9. package/src/core/conversation.ts +90 -4
  10. package/src/core/stream-event-wiring.ts +104 -2
  11. package/src/core/stream-stall-watchdog.ts +41 -17
  12. package/src/export/cost-utils.ts +90 -8
  13. package/src/input/command-registry.ts +16 -0
  14. package/src/input/commands/diff-runtime.ts +61 -30
  15. package/src/input/commands/session-content.ts +12 -0
  16. package/src/input/commands/session-workflow.ts +3 -0
  17. package/src/input/commands/share-runtime.ts +9 -2
  18. package/src/input/handler-content-actions.ts +30 -11
  19. package/src/input/handler-feed-routes.ts +63 -1
  20. package/src/input/handler-feed.ts +12 -0
  21. package/src/input/handler-interactions.ts +2 -0
  22. package/src/input/handler-types.ts +1 -0
  23. package/src/input/handler.ts +4 -0
  24. package/src/input/input-history.ts +17 -4
  25. package/src/main.ts +27 -23
  26. package/src/panels/agent-inspector-shared.ts +33 -10
  27. package/src/panels/base-panel.ts +1 -1
  28. package/src/panels/builtin/development.ts +1 -1
  29. package/src/panels/cockpit-read-model.ts +16 -11
  30. package/src/panels/cost-tracker-panel.ts +28 -5
  31. package/src/panels/debug-panel.ts +11 -5
  32. package/src/panels/diff-panel.ts +122 -22
  33. package/src/panels/docs-panel.ts +9 -0
  34. package/src/panels/file-explorer-panel.ts +9 -0
  35. package/src/panels/git-panel.ts +11 -11
  36. package/src/panels/knowledge-graph-panel.ts +9 -0
  37. package/src/panels/local-auth-panel.ts +10 -0
  38. package/src/panels/panel-list-panel.ts +9 -0
  39. package/src/panels/project-planning-panel.ts +10 -0
  40. package/src/panels/provider-health-tracker.ts +5 -1
  41. package/src/panels/provider-health-views.ts +8 -1
  42. package/src/panels/scrollable-list-panel.ts +9 -0
  43. package/src/panels/session-browser-panel.ts +9 -0
  44. package/src/panels/token-budget-panel.ts +5 -3
  45. package/src/panels/types.ts +13 -0
  46. package/src/panels/work-plan-panel.ts +10 -0
  47. package/src/renderer/agent-detail-modal.ts +42 -12
  48. package/src/renderer/code-block.ts +58 -28
  49. package/src/renderer/context-inspector.ts +3 -6
  50. package/src/renderer/conversation-surface.ts +1 -21
  51. package/src/renderer/diff-view.ts +6 -4
  52. package/src/renderer/fullscreen-primitives.ts +1 -1
  53. package/src/renderer/fullscreen-workspace.ts +2 -7
  54. package/src/renderer/hint-grammar.ts +1 -1
  55. package/src/renderer/history-search-overlay.ts +10 -16
  56. package/src/renderer/markdown.ts +9 -1
  57. package/src/renderer/model-picker-overlay.ts +8 -499
  58. package/src/renderer/model-workspace.ts +9 -24
  59. package/src/renderer/overlay-box.ts +7 -9
  60. package/src/renderer/process-indicator.ts +13 -25
  61. package/src/renderer/process-modal.ts +17 -2
  62. package/src/renderer/profile-picker-modal.ts +13 -14
  63. package/src/renderer/prompt-content-width.ts +16 -0
  64. package/src/renderer/selection-modal-overlay.ts +3 -1
  65. package/src/renderer/semantic-diff.ts +1 -1
  66. package/src/renderer/settings-modal-helpers.ts +10 -34
  67. package/src/renderer/shell-surface.ts +29 -9
  68. package/src/renderer/surface-layout.ts +0 -12
  69. package/src/renderer/term-caps.ts +1 -1
  70. package/src/renderer/tool-call.ts +6 -20
  71. package/src/renderer/ui-factory.ts +98 -14
  72. package/src/renderer/ui-primitives.ts +18 -1
  73. package/src/runtime/bootstrap-command-context.ts +3 -0
  74. package/src/runtime/bootstrap-command-parts.ts +3 -1
  75. package/src/runtime/bootstrap-core.ts +5 -0
  76. package/src/runtime/bootstrap-hook-bridge.ts +5 -0
  77. package/src/runtime/bootstrap-shell.ts +12 -1
  78. package/src/runtime/render-scheduler.ts +80 -0
  79. package/src/utils/splash-lines.ts +10 -2
  80. package/src/version.ts +1 -1
  81. package/src/renderer/file-tree.ts +0 -153
  82. package/src/renderer/progress.ts +0 -100
  83. package/src/renderer/status-token.ts +0 -67
@@ -114,6 +114,7 @@ export type CreateBootstrapCommandContextOptions = {
114
114
  sessionLineageTracker?: import('@pellux/goodvibes-sdk/platform/core').SessionLineageTracker;
115
115
  wrfcController?: import('@pellux/goodvibes-sdk/platform/agents').WrfcController;
116
116
  componentHealthMonitor: import('@/runtime/index.ts').ComponentHealthMonitor;
117
+ hydrateSessionUsage?: () => void;
117
118
  };
118
119
 
119
120
  export function createBootstrapCommandContext(
@@ -167,6 +168,7 @@ export function createBootstrapCommandContext(
167
168
  sessionLineageTracker,
168
169
  wrfcController,
169
170
  changeTracker,
171
+ hydrateSessionUsage,
170
172
  planManager,
171
173
  adaptivePlanner,
172
174
  sessionOrchestration,
@@ -223,6 +225,7 @@ export function createBootstrapCommandContext(
223
225
  sessionLineageTracker,
224
226
  wrfcController,
225
227
  changeTracker,
228
+ hydrateSessionUsage,
226
229
  });
227
230
  const provider = createBootstrapCommandProviderSection({
228
231
  providerRegistry,
@@ -109,6 +109,7 @@ export interface BootstrapCommandSectionOptions {
109
109
  readonly sessionLineageTracker?: import('@pellux/goodvibes-sdk/platform/core').SessionLineageTracker;
110
110
  readonly wrfcController?: import('@pellux/goodvibes-sdk/platform/agents').WrfcController;
111
111
  readonly changeTracker?: import('@pellux/goodvibes-sdk/platform/sessions').SessionChangeTracker;
112
+ readonly hydrateSessionUsage?: () => void;
112
113
  readonly agentManager?: ShellAgentManagerService;
113
114
  readonly modeManager?: ShellModeManagerService;
114
115
  readonly automationManager?: ShellAutomationManagerRuntimeService;
@@ -300,7 +301,7 @@ export function createBootstrapCommandActions(
300
301
  export function createBootstrapCommandSessionSection(
301
302
  options: Pick<
302
303
  BootstrapCommandSectionOptions,
303
- 'conversation' | 'runtime' | 'sessionManager' | 'sessionMemoryStore' | 'sessionLineageTracker' | 'wrfcController' | 'changeTracker'
304
+ 'conversation' | 'runtime' | 'sessionManager' | 'sessionMemoryStore' | 'sessionLineageTracker' | 'wrfcController' | 'changeTracker' | 'hydrateSessionUsage'
304
305
  >,
305
306
  ): BootstrapCommandSessionSection {
306
307
  return {
@@ -311,6 +312,7 @@ export function createBootstrapCommandSessionSection(
311
312
  sessionLineageTracker: options.sessionLineageTracker,
312
313
  wrfcController: options.wrfcController,
313
314
  changeTracker: options.changeTracker,
315
+ hydrateSessionUsage: options.hydrateSessionUsage,
314
316
  };
315
317
  }
316
318
 
@@ -28,6 +28,7 @@ import { loadBootstrapSystemPrompt, syncConfiguredServices } from '@/runtime/ind
28
28
  import { registerBootstrapHookBridge } from '@/runtime/index.ts';
29
29
  import { registerBootstrapRuntimeEvents } from '@/runtime/index.ts';
30
30
  import { createRuntimeServices, type RuntimeServices } from './services.ts';
31
+ import { setPricingSource } from '../export/cost-utils.ts';
31
32
  import { createUiRuntimeServices, type UiRuntimeServices } from './ui-services.ts';
32
33
  import { join } from 'node:path';
33
34
  import { installWrfcAgentToolGuard } from '../tools/wrfc-agent-guard.ts';
@@ -255,6 +256,10 @@ export async function initializeBootstrapCore(
255
256
  providerRegistry.initModelLimits();
256
257
  services.benchmarkStore.initBenchmarks();
257
258
  providerRegistry.initCatalog();
259
+ // Wire cost-utils to the live catalog so cost displays distinguish real
260
+ // pricing from unpriced (WO-315) instead of silently reading zero for any
261
+ // model the small static fallback table doesn't cover.
262
+ setPricingSource(() => providerRegistry.getRawCatalogModels());
258
263
  services.keybindingsManager.loadFromDisk();
259
264
  domainDispatch.syncControlPlaneState({
260
265
  enabled: Boolean(configManager.get('controlPlane.enabled')),
@@ -29,6 +29,8 @@ export interface ResumeSessionOptions {
29
29
  readonly configManager: Pick<ConfigManager, 'get' | 'getCategory'>;
30
30
  readonly providerRegistry: Pick<ProviderRegistry, 'get' | 'getCurrentModel' | 'getForModel' | 'require'>;
31
31
  readonly homeDirectory: string;
32
+ /** See CommandSessionServices.hydrateSessionUsage (command-registry.ts). */
33
+ readonly hydrateSessionUsage?: () => void;
32
34
  }
33
35
 
34
36
  export function createResumeSessionHandler(options: ResumeSessionOptions): (sessionId: string) => void {
@@ -65,6 +67,9 @@ export function createResumeSessionHandler(options: ResumeSessionOptions): (sess
65
67
  });
66
68
  },
67
69
  });
70
+ // Hydrate the footer's token counters from the resumed history now that
71
+ // fromJSON()/journal replay are both applied — before requestRender() below.
72
+ options.hydrateSessionUsage?.();
68
73
  options.onSessionIdChanged?.(sessionId);
69
74
  if (meta?.model) options.runtime.model = meta.model;
70
75
  if (meta?.provider) options.runtime.provider = meta.provider;
@@ -1,5 +1,5 @@
1
1
  import { join } from 'node:path';
2
- import type { ConversationManager } from '../core/conversation';
2
+ import { sumConversationUsage, type ConversationManager } from '../core/conversation';
3
3
  import type { Orchestrator } from '../core/orchestrator';
4
4
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
5
5
  import type { RuntimeEventBus } from '@/runtime/index.ts';
@@ -93,6 +93,15 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
93
93
  } = options;
94
94
 
95
95
  const systemMessagesPanel = new SystemMessagesPanel(configManager, services.componentHealthMonitor);
96
+ // W0.9: after any resume seam replays historical messages into `conversation`,
97
+ // the freshly-constructed `orchestrator` still has its zeroed default usage
98
+ // (SDK gap — Orchestrator.usage is never persisted/reseeded). Recompute it
99
+ // from the replayed history so the footer doesn't show Input: 0 post-resume.
100
+ const hydrateSessionUsage = (): void => {
101
+ const { usage, lastInputTokens } = sumConversationUsage(conversation.getMessageSnapshot());
102
+ orchestrator.usage = usage;
103
+ orchestrator.lastInputTokens = lastInputTokens;
104
+ };
96
105
  const resumeSession = createResumeSessionHandler({
97
106
  runtimeBus,
98
107
  runtime,
@@ -107,6 +116,7 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
107
116
  configManager,
108
117
  providerRegistry: services.providerRegistry,
109
118
  homeDirectory: services.homeDirectory,
119
+ hydrateSessionUsage,
110
120
  });
111
121
 
112
122
  const foundationClients = createRuntimeFoundationClients({
@@ -282,6 +292,7 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
282
292
  },
283
293
  completeModelSelectionSideEffect,
284
294
  componentHealthMonitor: services.componentHealthMonitor,
295
+ hydrateSessionUsage,
285
296
  });
286
297
  commandContextRef = commandContext;
287
298
 
@@ -0,0 +1,80 @@
1
+ /**
2
+ * render-scheduler.ts — same-tick render coalescing for the TUI shell (WO-208).
3
+ *
4
+ * main.ts fans out its own direct render() calls across turn/stream/input wiring
5
+ * (~14 direct invocation contexts). Each one previously ran a full synchronous
6
+ * Compositor.composite() the instant it was called. When several fire within a
7
+ * single event-loop tick — a streaming burst is the canonical case — only the
8
+ * LAST frame is ever visible; the earlier composites are immediately overwritten.
9
+ * This scheduler collapses every schedule() call made within one tick into a
10
+ * single composite flushed on the microtask queue, so the tick produces exactly
11
+ * one frame. That frame is byte-identical to what the last synchronous render()
12
+ * would have produced, because the coalesced flush runs after all of the tick's
13
+ * synchronous state mutations, reading the same final state the last direct
14
+ * render() would have read.
15
+ *
16
+ * This is deliberately SEPARATE from bootstrap-core's requestRender(), which is a
17
+ * cross-tick, 16ms-throttled (~60fps) coalescer for the panel/input/runtime
18
+ * fan-out — that one caps repaint RATE across ticks. This one collapses the
19
+ * WITHIN-tick burst with no added latency: a microtask flushes at the tail of the
20
+ * current tick, before the event loop yields to I/O, so the frame still lands in
21
+ * the same turn it was requested in.
22
+ *
23
+ * flushNow() preserves a synchronous immediate path for callers that genuinely
24
+ * need the frame composited before they return. Terminal resize is the known
25
+ * case: the resize handler resets the compositor diff and must repaint against
26
+ * the new dimensions synchronously rather than waiting for the microtask.
27
+ * flushNow() also clears any pending coalesced flush, so a resize (or any
28
+ * immediate paint) followed by an already-queued microtask never double-composites.
29
+ */
30
+
31
+ export interface RenderScheduler {
32
+ /**
33
+ * Coalesced request: schedule a composite for the current tick. N calls within
34
+ * one tick collapse into exactly one composite, flushed on the microtask queue.
35
+ */
36
+ readonly schedule: () => void;
37
+ /**
38
+ * Immediate request: run the composite synchronously right now, and cancel any
39
+ * pending coalesced flush so the tick still composites only once. Use only where
40
+ * a caller genuinely requires synchronous output (terminal resize).
41
+ */
42
+ readonly flushNow: () => void;
43
+ }
44
+
45
+ /**
46
+ * Build a render scheduler around `renderNow` (the synchronous composite closure).
47
+ *
48
+ * `scheduleFlush` is the deferral primitive; it defaults to `queueMicrotask` so
49
+ * bursts coalesce within the current tick. Tests and benchmarks inject a manual
50
+ * queue to flush deterministically.
51
+ */
52
+ export function createRenderScheduler(
53
+ renderNow: () => void,
54
+ scheduleFlush: (flush: () => void) => void = queueMicrotask,
55
+ ): RenderScheduler {
56
+ let scheduled = false;
57
+
58
+ const flush = (): void => {
59
+ // A synchronous flushNow() (or a superseding immediate paint) before this
60
+ // microtask ran clears `scheduled`, turning this into a no-op so a single
61
+ // tick never composites twice.
62
+ if (!scheduled) return;
63
+ scheduled = false;
64
+ renderNow();
65
+ };
66
+
67
+ const schedule = (): void => {
68
+ if (scheduled) return;
69
+ scheduled = true;
70
+ scheduleFlush(flush);
71
+ };
72
+
73
+ const flushNow = (): void => {
74
+ // Satisfy any pending coalesced flush, then composite synchronously.
75
+ scheduled = false;
76
+ renderNow();
77
+ };
78
+
79
+ return { schedule, flushNow };
80
+ }
@@ -20,7 +20,8 @@ const SEPARATOR = '━'.repeat(ART_W);
20
20
  */
21
21
  const TAGLINE = '[ good vibes ・ A I ・ いい雰囲気 ]';
22
22
 
23
- const VERSION_LINE = ` ✦ v${VERSION} █ terminal AI assistant █ 自動コード  ✦`;
23
+ const versionLine = (version: string) =>
24
+ ` ✦ v${version} █ terminal AI assistant █ 自動コード  ✦`;
24
25
 
25
26
  /** Fixed hint line — the three primary shell entry points. */
26
27
  const HINT_LINE = 'Ctrl+P panels / ? help / F2 processes';
@@ -35,6 +36,13 @@ export interface SplashOptions {
35
36
  * When present, the splash advertises a resume affordance.
36
37
  */
37
38
  lastSessionId?: string;
39
+ /**
40
+ * Version string rendered on the splash's version line. Defaults to the
41
+ * build VERSION; injectable so the golden-frame tests can pin a fixture —
42
+ * the version's display width shifts the line's centering, so goldens tied
43
+ * to the live VERSION break on every release bump (v1.0.0 release failure).
44
+ */
45
+ version?: string;
38
46
  }
39
47
 
40
48
  /** Collapse a $HOME-prefixed working directory to a leading `~`. */
@@ -54,7 +62,7 @@ export function getSplashLines(columns: number, opts: SplashOptions = {}): strin
54
62
  ...ART_LINES.map((line) => center(line, columns)),
55
63
  center(SEPARATOR, columns),
56
64
  center(TAGLINE, columns),
57
- center(VERSION_LINE, columns),
65
+ center(versionLine(opts.version ?? VERSION), columns),
58
66
  '',
59
67
  ];
60
68
 
package/src/version.ts CHANGED
@@ -6,7 +6,7 @@ import { join } from 'node:path';
6
6
  // The prebuild script updates the fallback value before compilation.
7
7
  // Uses import.meta.dir (Bun) to locate package.json relative to this file,
8
8
  // which is correct regardless of the process working directory.
9
- let _version = '0.29.0';
9
+ let _version = '1.1.0';
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
12
12
  _version = pkg.version ?? _version;
@@ -1,153 +0,0 @@
1
- import { type Line, createStyledCell } from '../types/grid.ts';
2
- import { UIFactory } from './ui-factory.ts';
3
- import { getDisplayWidth } from '../utils/terminal-width.ts';
4
-
5
- /** Color by file extension category. */
6
- function getFileColor(name: string): string {
7
- if (name.endsWith('/')) return '#00ffff'; // directory
8
- const ext = name.split('.').pop()?.toLowerCase() ?? '';
9
- if (['ts', 'tsx', 'js', 'jsx', 'mjs'].includes(ext)) return '#dcdcaa';
10
- if (['json', 'yaml', 'yml', 'toml'].includes(ext)) return '#ce9178';
11
- if (['md', 'txt', 'rst'].includes(ext)) return '252';
12
- if (['sh', 'bash', 'zsh'].includes(ext)) return '#22c55e';
13
- if (['css', 'scss', 'less'].includes(ext)) return '#569cd6';
14
- if (['html', 'htm', 'xml', 'svg'].includes(ext)) return '#f97316';
15
- if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'ico'].includes(ext)) return '#a855f7';
16
- if (['lock', 'env', 'gitignore'].includes(name)) return '238';
17
- return '252';
18
- }
19
-
20
- export interface FileTreeEntry {
21
- name: string;
22
- isDir: boolean;
23
- size?: number;
24
- depth: number;
25
- isLast: boolean;
26
- isLastAtDepth: boolean[]; // tracks if each ancestor was last child
27
- }
28
-
29
- /**
30
- * renderFileTree - Render a directory listing as a tree with colored file types.
31
- * Returns Line[] for the cell-based pipeline.
32
- */
33
- export function renderFileTree(
34
- entries: FileTreeEntry[],
35
- width: number,
36
- title?: string
37
- ): Line[] {
38
- const lines: Line[] = [];
39
-
40
- // Optional header
41
- if (title) {
42
- lines.push(UIFactory.stringToLine(` [dir] ${title}`, width, { fg: '#00ffff', bold: true }));
43
- lines.push(UIFactory.stringToLine(' ' + '-'.repeat(width - 2), width, { fg: '240' }));
44
- }
45
-
46
- for (const entry of entries) {
47
- const { name, isDir, size, depth, isLast, isLastAtDepth } = entry;
48
-
49
- // Build tree prefix
50
- let prefix = '';
51
- for (let d = 0; d < depth; d++) {
52
- if (d < depth - 1) {
53
- prefix += isLastAtDepth[d] ? ' ' : '| ';
54
- }
55
- }
56
- if (depth > 0) {
57
- prefix += isLast ? '`-- ' : '|-- ';
58
- } else {
59
- prefix += '';
60
- }
61
-
62
- const displayName = isDir ? name + '/' : name;
63
- const fg = getFileColor(isDir ? displayName : name);
64
-
65
- // Size info (dimmed)
66
- let sizeStr = '';
67
- if (size !== undefined && !isDir) {
68
- if (size < 1024) sizeStr = ` ${size}B`;
69
- else if (size < 1024 * 1024) sizeStr = ` ${(size / 1024).toFixed(1)}K`;
70
- else sizeStr = ` ${(size / (1024 * 1024)).toFixed(1)}M`;
71
- }
72
-
73
- const fullText = prefix + displayName;
74
- const sizeW = getDisplayWidth(sizeStr);
75
- const nameW = getDisplayWidth(fullText);
76
- const maxNameW = width - sizeW - 1;
77
-
78
- const truncated = nameW > maxNameW ? fullText.slice(0, Math.max(0, maxNameW - 3)) + '...' : fullText;
79
-
80
- // Compose: name + padding + size
81
- const paddingW = Math.max(0, width - getDisplayWidth(truncated) - sizeW);
82
- const fullLine = truncated + ' '.repeat(paddingW);
83
-
84
- // Build the line with per-character styles
85
- const line = UIFactory.stringToLine(fullLine, width, { fg });
86
-
87
- // Dim the size info
88
- if (sizeStr && sizeW > 0) {
89
- const sizeStartX = width - sizeW;
90
- let cx = sizeStartX;
91
- for (const ch of sizeStr) {
92
- if (cx >= width) break;
93
- const cw = getDisplayWidth(ch);
94
- line[cx] = createStyledCell(ch, { fg: '240', dim: true });
95
- if (cw === 2 && cx + 1 < width) line[cx + 1] = { ...line[cx], char: '' };
96
- cx += cw;
97
- }
98
- }
99
-
100
- lines.push(line);
101
- }
102
-
103
- return lines;
104
- }
105
-
106
- /**
107
- * parseListDirOutput - Convert a simple list directory output string to FileTreeEntry[].
108
- * Expects one path per line, relative paths with '/' for directories.
109
- */
110
- export function parseListDirOutput(output: string, rootDir: string): FileTreeEntry[] {
111
- const rawLines = output.trim().split('\n').filter(Boolean);
112
- const entries: FileTreeEntry[] = [];
113
-
114
- for (const line of rawLines) {
115
- const trimmed = line.trim();
116
- if (!trimmed) continue;
117
- const isDir = trimmed.endsWith('/');
118
- const name = isDir ? trimmed.slice(0, -1).split('/').pop() + '/' : trimmed.split('/').pop() ?? trimmed;
119
- const depth = (trimmed.match(/\//g) ?? []).length - (isDir ? 1 : 0);
120
-
121
- entries.push({
122
- name: name || trimmed,
123
- isDir,
124
- depth: Math.max(0, depth),
125
- isLast: false,
126
- isLastAtDepth: [],
127
- });
128
- }
129
-
130
- // Mark isLast for each entry (last sibling at same depth under same parent)
131
- for (let i = 0; i < entries.length; i++) {
132
- const nextSameOrLower = entries.slice(i + 1).findIndex(
133
- e => e.depth <= entries[i].depth
134
- );
135
- entries[i].isLast = nextSameOrLower === 0 || nextSameOrLower === -1;
136
-
137
- // Build isLastAtDepth[d] = true if the ancestor at depth d was the last child
138
- // of its parent. This governs whether to draw '| ' or ' ' vertical bars.
139
- const isLastAtDepth: boolean[] = new Array(entries[i].depth).fill(false);
140
- for (let d = 0; d < entries[i].depth; d++) {
141
- // Walk backwards to find the most recent ancestor at depth d
142
- for (let j = i - 1; j >= 0; j--) {
143
- if (entries[j].depth === d) {
144
- isLastAtDepth[d] = entries[j].isLast;
145
- break;
146
- }
147
- }
148
- }
149
- entries[i].isLastAtDepth = isLastAtDepth;
150
- }
151
-
152
- return entries;
153
- }
@@ -1,100 +0,0 @@
1
- import { type Line } from '../types/grid.ts';
2
- import { UIFactory } from './ui-factory.ts';
3
- import { getDisplayWidth, padDisplayEnd } from '../utils/terminal-width.ts';
4
- import { abbreviateCount } from '../utils/format-number.ts';
5
- import { SPINNER_FRAMES } from './ui-primitives.ts';
6
-
7
- // Rich spinner frames (used by progress indicators) — single source in ui-primitives.ts.
8
- export { SPINNER_FRAMES };
9
- // Braille thinking spinner frames (used by the orchestrator thinking animation) — same frame set.
10
- export const THINKING_SPINNER_FRAMES = SPINNER_FRAMES;
11
-
12
- /**
13
- * renderSpinner - Render a spinner with label as a single Line.
14
- */
15
- export function renderSpinner(
16
- frame: string,
17
- label: string,
18
- width: number,
19
- fg: string = '135'
20
- ): Line {
21
- const text = ` ${frame} ${label}`;
22
- return UIFactory.stringToLine(padDisplayEnd(text, width), width, { fg, bold: true });
23
- }
24
-
25
- /**
26
- * renderToolProgress - Render tool execution progress.
27
- * E.g. "[2/5] Editing src/config.ts..."
28
- */
29
- export function renderToolProgress(
30
- current: number,
31
- total: number,
32
- label: string,
33
- width: number
34
- ): Line[] {
35
- const counter = `[${current}/${total}]`;
36
- const text = ` ${counter} ${label}`;
37
- return [
38
- UIFactory.stringToLine(padDisplayEnd(text, width), width, { fg: '#ffcc00', bold: true }),
39
- ];
40
- }
41
-
42
- /**
43
- * renderTokenBar - Render a token usage bar for the footer.
44
- * Shows used/max tokens as a visual bar + numbers.
45
- */
46
- export function renderTokenBar(
47
- used: number,
48
- max: number,
49
- width: number,
50
- model: string,
51
- toolCount: number
52
- ): Line[] {
53
- const lines: Line[] = [];
54
-
55
- // Stats row
56
- const usedK = abbreviateCount(used, { noM: true });
57
- const maxK = abbreviateCount(max, { noM: true });
58
- const pct = max > 0 ? Math.min(100, Math.round((used / max) * 100)) : 0;
59
- const toolStr = toolCount > 0 ? ` tools:${toolCount}` : '';
60
- const statsText = ` ${model} in:${usedK}/${maxK} (${pct}%)${toolStr}`;
61
-
62
- // Progress bar
63
- const barLabel = ' ctx ';
64
- const barLabelW = getDisplayWidth(barLabel);
65
- const barW = Math.max(10, Math.floor(width * 0.3));
66
- const filled = Math.round((pct / 100) * barW);
67
- const empty = barW - filled;
68
-
69
- // Color based on usage
70
- const barFg = pct > 85 ? '#ef4444' : pct > 60 ? '#ffcc00' : '#22c55e';
71
-
72
- const bar = '#'.repeat(filled) + '-'.repeat(empty);
73
- const barText = barLabel + bar;
74
-
75
- const statsW = getDisplayWidth(statsText);
76
- const barTextW = getDisplayWidth(barText);
77
- const spacingW = Math.max(1, width - statsW - barTextW - 2);
78
-
79
- // Build with mixed colors
80
- const line = UIFactory.stringToLine(statsText + ' '.repeat(spacingW), width, { fg: '244', dim: true });
81
-
82
- // Overlay the bar with color
83
- let barStartX = getDisplayWidth(statsText) + spacingW;
84
- for (const ch of barLabel) {
85
- if (barStartX >= width) break;
86
- const cw = getDisplayWidth(ch);
87
- line[barStartX] = { char: ch, fg: '244', bg: '', bold: false, dim: true, underline: false, italic: false, strikethrough: false };
88
- if (cw === 2 && barStartX + 1 < width) line[barStartX + 1] = { ...line[barStartX], char: '' };
89
- barStartX += cw;
90
- }
91
- for (let i = 0; i < filled && barStartX + i < width; i++) {
92
- line[barStartX + i] = { char: '#', fg: barFg, bg: '', bold: false, dim: false, underline: false, italic: false, strikethrough: false };
93
- }
94
- for (let i = 0; i < empty && barStartX + filled + i < width; i++) {
95
- line[barStartX + filled + i] = { char: '-', fg: '238', bg: '', bold: false, dim: true, underline: false, italic: false, strikethrough: false };
96
- }
97
-
98
- lines.push(line);
99
- return lines;
100
- }
@@ -1,67 +0,0 @@
1
- // ---------------------------------------------------------------------------
2
- // buildStatusToken — always glyph + color, never color-only.
3
- //
4
- // Maps a semantic state to a Unicode glyph AND a palette color so that
5
- // colorblind users and screen readers can distinguish states without color.
6
- //
7
- // Glyphs:
8
- // good ✓ (CHECK MARK)
9
- // warn ⚠ (WARNING SIGN)
10
- // bad ✕ (MULTIPLICATION X)
11
- // info ○ (WHITE CIRCLE)
12
- // ---------------------------------------------------------------------------
13
-
14
- import type { Cell } from '../types/grid.ts';
15
- import { DEFAULT_PANEL_PALETTE } from '../panels/polish.ts';
16
- import { type StatusState, STATE_GLYPHS } from './status-glyphs.ts';
17
-
18
- // Re-export for downstream consumers that import from this module.
19
- export type { StatusState } from './status-glyphs.ts';
20
- export { STATE_GLYPHS } from './status-glyphs.ts';
21
-
22
- const STATE_COLORS: Record<StatusState, string> = {
23
- good: DEFAULT_PANEL_PALETTE.good,
24
- warn: DEFAULT_PANEL_PALETTE.warn,
25
- bad: DEFAULT_PANEL_PALETTE.bad,
26
- info: DEFAULT_PANEL_PALETTE.info,
27
- };
28
-
29
- export interface StatusTokenOpts {
30
- /** Append a numeric count after the label, e.g. "label (3)". */
31
- count?: number;
32
- /** Override the default glyph for this state. */
33
- glyph?: string;
34
- }
35
-
36
- /**
37
- * Build a small sequence of styled cells: [glyph, space, label, optional count]
38
- *
39
- * Always prepends the glyph so colorblind users can parse the state.
40
- * The color is applied to both glyph and label as a redundant cue.
41
- *
42
- * @param state Semantic state — controls default glyph and color.
43
- * @param label Human-readable label string.
44
- * @param opts Optional count suffix or glyph override.
45
- * @returns Array of Cell objects ready to embed in a Line.
46
- */
47
- export function buildStatusToken(
48
- state: StatusState,
49
- label: string,
50
- opts?: StatusTokenOpts,
51
- ): Cell[] {
52
- const glyph = opts?.glyph ?? STATE_GLYPHS[state];
53
- const color = STATE_COLORS[state];
54
- const suffix = opts?.count !== undefined ? ` (${opts.count})` : '';
55
- const text = `${glyph} ${label}${suffix}`;
56
-
57
- return text.split('').map((char): Cell => ({
58
- char,
59
- fg: color,
60
- bg: '',
61
- bold: false,
62
- dim: false,
63
- underline: false,
64
- italic: false,
65
- strikethrough: false,
66
- }));
67
- }