@aemonge-dev/pi-stardeck 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +240 -2
- package/demo/pi-stardeck.tape +59 -0
- package/docs/architecture.md +106 -0
- package/docs/assets/pi-stardeck.gif +0 -0
- package/docs/development.md +128 -0
- package/licenses/README.md +19 -0
- package/licenses/pi-vim-LICENSE +21 -0
- package/package.json +81 -4
- package/packages/footer/README.md +15 -0
- package/packages/footer/index.ts +37 -0
- package/packages/footer/package.json +32 -0
- package/packages/footer/src/protocol.ts +10 -0
- package/packages/footer/src/quota.ts +26 -0
- package/packages/footer/src/telemetry.ts +59 -0
- package/packages/pi-stardeck/README.md +220 -0
- package/packages/pi-stardeck/index.ts +691 -0
- package/packages/pi-stardeck/package.json +32 -0
- package/packages/pi-stardeck/src/activity-age.ts +184 -0
- package/packages/pi-stardeck/src/capabilities.ts +105 -0
- package/packages/pi-stardeck/src/deck.ts +807 -0
- package/packages/pi-stardeck/src/gauge.ts +1323 -0
- package/packages/pi-stardeck/src/git-refresh.ts +5 -0
- package/packages/pi-stardeck/src/process-resources.ts +428 -0
- package/packages/pi-stardeck/src/title-state.ts +195 -0
- package/packages/pi-stardeck/src/title.ts +443 -0
- package/packages/pi-vim-top-border/LICENSE +21 -0
- package/packages/pi-vim-top-border/LOCAL-CHANGES.md +71 -0
- package/packages/pi-vim-top-border/README.md +577 -0
- package/packages/pi-vim-top-border/clipboard-mirror.ts +451 -0
- package/packages/pi-vim-top-border/clipboard-policy.ts +23 -0
- package/packages/pi-vim-top-border/cursor-shape.ts +131 -0
- package/packages/pi-vim-top-border/index.ts +4195 -0
- package/packages/pi-vim-top-border/input-keys.ts +49 -0
- package/packages/pi-vim-top-border/mode-change-command.ts +120 -0
- package/packages/pi-vim-top-border/mode-colors.ts +97 -0
- package/packages/pi-vim-top-border/mode-label.ts +62 -0
- package/packages/pi-vim-top-border/motions.ts +297 -0
- package/packages/pi-vim-top-border/package.json +47 -0
- package/packages/pi-vim-top-border/prompt-external-editor.ts +152 -0
- package/packages/pi-vim-top-border/prompt-rail.ts +334 -0
- package/packages/pi-vim-top-border/prompt-status.ts +131 -0
- package/packages/pi-vim-top-border/settings.ts +287 -0
- package/packages/pi-vim-top-border/text-objects.ts +389 -0
- package/packages/pi-vim-top-border/types.ts +51 -0
- package/packages/pi-vim-top-border/visual.ts +65 -0
- package/packages/pi-vim-top-border/word-boundary-cache.ts +196 -0
- package/packages/status/README.md +398 -0
- package/packages/status/index.ts +1717 -0
- package/packages/status/package.json +33 -0
- package/packages/status/src/active-work.ts +235 -0
- package/packages/status/src/config.ts +218 -0
- package/packages/status/src/diagnostics.ts +238 -0
- package/packages/status/src/goal-block.ts +377 -0
- package/packages/status/src/goal.ts +170 -0
- package/packages/status/src/ledger.ts +101 -0
- package/packages/status/src/openspec.ts +394 -0
- package/packages/status/src/orchestration.ts +237 -0
- package/packages/status/src/publisher.ts +1294 -0
- package/packages/status/src/reducer.ts +190 -0
- package/packages/status/src/runtime-runs.ts +267 -0
- package/packages/status/src/state.ts +178 -0
- package/packages/status/src/title-activity.ts +224 -0
- package/packages/status/src/title-state.ts +218 -0
- package/packages/status/src/turn-cost.ts +63 -0
- package/packages/status/src/types.ts +228 -0
- package/src/deck-surface.ts +46 -0
- package/src/health.ts +227 -0
- package/src/index.ts +27 -0
- package/src/pi-status.ts +334 -0
|
@@ -0,0 +1,1323 @@
|
|
|
1
|
+
import { truncateToWidth } from '@earendil-works/pi-tui';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type DeckTitleState,
|
|
5
|
+
type DeckTurnCost,
|
|
6
|
+
parseDeckTitleState,
|
|
7
|
+
parseDeckTurnCost,
|
|
8
|
+
} from './title-state.ts';
|
|
9
|
+
|
|
10
|
+
export const CONTEXT_GAUGE_WIDTH = 7;
|
|
11
|
+
export const FANCY_FOOTER_WIDGET_CHANNEL = 'pi-fancy-footer:widget';
|
|
12
|
+
export const FANCY_FOOTER_READY_CHANNEL = 'pi-fancy-footer:ready';
|
|
13
|
+
export const GALACTICA_HEADER_CHANNEL = 'galactica-status:header';
|
|
14
|
+
export const LOCATION_WIDGET_ID = 'galactica.location';
|
|
15
|
+
export const LEGACY_GIT_WIDGET_ID = 'galactica.git-status';
|
|
16
|
+
export const GIT_BRANCH_WIDGET_ID = 'galactica.git-branch';
|
|
17
|
+
export const GIT_SCOPE_SEPARATOR_WIDGET_ID = 'galactica.git-scope-separator';
|
|
18
|
+
export const GIT_STAGED_WIDGET_ID = 'galactica.git-staged';
|
|
19
|
+
export const GIT_MODIFIED_WIDGET_ID = 'galactica.git-modified';
|
|
20
|
+
export const GIT_UNTRACKED_WIDGET_ID = 'galactica.git-untracked';
|
|
21
|
+
export const GIT_CONFLICT_WIDGET_ID = 'galactica.git-conflict';
|
|
22
|
+
export const ENVIRONMENT_WIDGET_ID = 'galactica.agent-environment';
|
|
23
|
+
export const REASONING_WIDGET_ID = 'galactica.reasoning-effort';
|
|
24
|
+
export const CONTEXT_WIDGET_ID = 'galactica.context-usage';
|
|
25
|
+
export const COMPACTION_WIDGET_ID = 'galactica.compaction-count';
|
|
26
|
+
export const RESOURCE_SEPARATOR_WIDGET_ID = 'galactica.resource-separator';
|
|
27
|
+
export const SCOPE_SEPARATOR_WIDGET_ID = 'galactica.scope-separator';
|
|
28
|
+
export const PROMPT_STATUS_CHANNEL = 'galactica-status:prompt-row';
|
|
29
|
+
export const FOOTER_TELEMETRY_CHANNEL = 'pi-stardeck:footer-telemetry/v1';
|
|
30
|
+
export const PI_NATIVE_STATUS_CHANNEL = 'pi-stardeck:native-status/v1';
|
|
31
|
+
export const PI_STATUS_SNAPSHOT_CHANNEL = 'pi-stardeck:pi-status/v1';
|
|
32
|
+
export const VIM_MODE_CHANNEL = 'pi-vim:mode-change';
|
|
33
|
+
|
|
34
|
+
const HEADER_COLORS = [
|
|
35
|
+
'text',
|
|
36
|
+
'accent',
|
|
37
|
+
'muted',
|
|
38
|
+
'dim',
|
|
39
|
+
'success',
|
|
40
|
+
'error',
|
|
41
|
+
'warning',
|
|
42
|
+
] as const;
|
|
43
|
+
|
|
44
|
+
export type HeaderColor = (typeof HEADER_COLORS)[number];
|
|
45
|
+
export type HeaderDiagnostics = {
|
|
46
|
+
text: string;
|
|
47
|
+
color: HeaderColor;
|
|
48
|
+
state: 'pass' | 'fail' | 'running';
|
|
49
|
+
/** Aggregate findings count for the notice slot (Task 3.1). */
|
|
50
|
+
count?: number;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const HEADER_ACTIVITY_KINDS = [
|
|
54
|
+
'understanding',
|
|
55
|
+
'inspection',
|
|
56
|
+
'synthesis',
|
|
57
|
+
'planning',
|
|
58
|
+
'skill-proposal',
|
|
59
|
+
'mutation',
|
|
60
|
+
'implementation',
|
|
61
|
+
'change-review',
|
|
62
|
+
'regression',
|
|
63
|
+
'verification',
|
|
64
|
+
'result-review',
|
|
65
|
+
'recovery',
|
|
66
|
+
'awaiting-validation',
|
|
67
|
+
'awaiting-input',
|
|
68
|
+
'operation-aborted',
|
|
69
|
+
] as const;
|
|
70
|
+
|
|
71
|
+
export type HeaderActivity = {
|
|
72
|
+
kind: (typeof HEADER_ACTIVITY_KINDS)[number];
|
|
73
|
+
current?: number;
|
|
74
|
+
total?: number;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const MAX_ACTIVITY_PATH_DEPTH = 6;
|
|
78
|
+
export const MAX_ACTIVITY_SEGMENT_ID_LENGTH = 48;
|
|
79
|
+
export const MAX_ACTIVITY_SEGMENT_LABEL_LENGTH = 48;
|
|
80
|
+
export const MAX_ACTIVITY_SEGMENT_COMPACT_LENGTH = 24;
|
|
81
|
+
|
|
82
|
+
export type HeaderActivitySegment = {
|
|
83
|
+
id: string;
|
|
84
|
+
label: string;
|
|
85
|
+
compact?: string;
|
|
86
|
+
current?: number;
|
|
87
|
+
total?: number;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export type HeaderWork = {
|
|
91
|
+
lifecycle: string;
|
|
92
|
+
titles: string[];
|
|
93
|
+
color: HeaderColor;
|
|
94
|
+
activity?: HeaderActivity;
|
|
95
|
+
activityPath?: HeaderActivitySegment[];
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export const HEADER_SELECTION_SOURCES = [
|
|
99
|
+
'openspec',
|
|
100
|
+
'goal',
|
|
101
|
+
'session-work',
|
|
102
|
+
'subject',
|
|
103
|
+
'legacy',
|
|
104
|
+
] as const;
|
|
105
|
+
|
|
106
|
+
export type HeaderSelectionGoalDetail = {
|
|
107
|
+
status: string;
|
|
108
|
+
elapsedSeconds?: number;
|
|
109
|
+
tasksDone?: number;
|
|
110
|
+
tasksActive?: number;
|
|
111
|
+
tasksTotal?: number;
|
|
112
|
+
tokensUsed?: number;
|
|
113
|
+
tokenBudget?: number;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export type HeaderSelection = {
|
|
117
|
+
source: (typeof HEADER_SELECTION_SOURCES)[number];
|
|
118
|
+
titles: string[];
|
|
119
|
+
color: HeaderColor;
|
|
120
|
+
goal?: HeaderSelectionGoalDetail;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const HEADER_SUGGESTIONS = [
|
|
124
|
+
'requesting-validation',
|
|
125
|
+
'awaiting-continuation',
|
|
126
|
+
'requesting-redirection',
|
|
127
|
+
'awaiting-resume',
|
|
128
|
+
'requesting-input',
|
|
129
|
+
] as const;
|
|
130
|
+
|
|
131
|
+
export type HeaderSuggestion = (typeof HEADER_SUGGESTIONS)[number];
|
|
132
|
+
|
|
133
|
+
export type HeaderSnapshot = {
|
|
134
|
+
openGoals?: number;
|
|
135
|
+
activity?: HeaderActivity | null;
|
|
136
|
+
selection?: HeaderSelection | null;
|
|
137
|
+
suggestion?: HeaderSuggestion | null;
|
|
138
|
+
work: HeaderWork | null;
|
|
139
|
+
/** Versioned deck-title-state contract; null when published malformed. */
|
|
140
|
+
title?: DeckTitleState | null;
|
|
141
|
+
/** Per-turn cost meter published beside the title state. */
|
|
142
|
+
turn?: DeckTurnCost | null;
|
|
143
|
+
diagnostics: HeaderDiagnostics | null;
|
|
144
|
+
backgroundActivity: boolean;
|
|
145
|
+
approvalRequired: boolean;
|
|
146
|
+
blocked: boolean;
|
|
147
|
+
activeRunSpans?: Array<{
|
|
148
|
+
id: string;
|
|
149
|
+
kind: 'agent' | 'subagent' | 'bash';
|
|
150
|
+
parent: string | null;
|
|
151
|
+
agent?: string;
|
|
152
|
+
label?: string;
|
|
153
|
+
stage: string;
|
|
154
|
+
elapsedMs: number;
|
|
155
|
+
}>;
|
|
156
|
+
counters: {
|
|
157
|
+
agents: { active: number; total: number };
|
|
158
|
+
activeRuns?: { children: number; subagents: number };
|
|
159
|
+
tasks?: { completed: number; total: number };
|
|
160
|
+
steps?: { completed: number; total: number };
|
|
161
|
+
files?: { completed: number; total: number };
|
|
162
|
+
};
|
|
163
|
+
progress: Array<{
|
|
164
|
+
icon: string;
|
|
165
|
+
completed: number;
|
|
166
|
+
total: number;
|
|
167
|
+
color: HeaderColor;
|
|
168
|
+
}>;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export type FooterQuotaWindow = {
|
|
172
|
+
label: string;
|
|
173
|
+
percent: number;
|
|
174
|
+
resetAt?: number;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
export type FooterTelemetrySnapshot = {
|
|
178
|
+
totalCost: number;
|
|
179
|
+
quotaPercent?: number;
|
|
180
|
+
quotaWindows?: FooterQuotaWindow[];
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export type PiStatusSnapshot = {
|
|
184
|
+
errors: number;
|
|
185
|
+
warnings: number;
|
|
186
|
+
nativeStatusCount: number;
|
|
187
|
+
nativeStatuses?: ReadonlyArray<{ key: string; text: string }>;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
export type VimMode = 'insert' | 'normal' | 'visual' | 'visual-line' | 'ex';
|
|
191
|
+
|
|
192
|
+
export type PromptStatusSnapshot = {
|
|
193
|
+
protocol: 1;
|
|
194
|
+
signals: Array<{ glyph: string; color: HeaderColor }>;
|
|
195
|
+
git: GitStatusSummary & { branch: string };
|
|
196
|
+
progress: HeaderSnapshot['progress'];
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export const HEADER_PRESENTATION = {
|
|
200
|
+
contextIcon: '',
|
|
201
|
+
contextLabel: 'ctx',
|
|
202
|
+
contextColor: 'success',
|
|
203
|
+
locationColor: 'success',
|
|
204
|
+
} as const;
|
|
205
|
+
|
|
206
|
+
export const IDLE_HEADER_WORK: HeaderWork = {
|
|
207
|
+
lifecycle: 'listening',
|
|
208
|
+
titles: ['no focused task'],
|
|
209
|
+
color: 'accent',
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
export function displayedHeaderWork(work: HeaderWork | null | undefined): {
|
|
213
|
+
work: HeaderWork;
|
|
214
|
+
idle: boolean;
|
|
215
|
+
} {
|
|
216
|
+
return work ? { work, idle: false } : { work: IDLE_HEADER_WORK, idle: true };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function runtimeHeaderWork(
|
|
220
|
+
work: HeaderWork | null | undefined,
|
|
221
|
+
agentBusy: boolean,
|
|
222
|
+
): { work: HeaderWork; idle: boolean } {
|
|
223
|
+
const displayed = displayedHeaderWork(work);
|
|
224
|
+
if (!agentBusy || !displayed.idle) return displayed;
|
|
225
|
+
return {
|
|
226
|
+
work: {
|
|
227
|
+
lifecycle: 'understanding',
|
|
228
|
+
titles: ['no focus'],
|
|
229
|
+
color: 'accent',
|
|
230
|
+
activity: { kind: 'understanding' },
|
|
231
|
+
},
|
|
232
|
+
idle: false,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function lifecycleStatusLabel(work: HeaderWork | null | undefined): string {
|
|
237
|
+
return work ? `⟩ ${work.lifecycle}` : '⟩';
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export type ContextGauge = {
|
|
241
|
+
percent: number;
|
|
242
|
+
percentText: string;
|
|
243
|
+
filled: string;
|
|
244
|
+
empty: string;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
export function contextGaugeColor(percent: number): HeaderColor {
|
|
248
|
+
if (percent >= 85) return 'warning';
|
|
249
|
+
return 'success';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export const DEVBOX_PWD_ICON = '[] ';
|
|
253
|
+
|
|
254
|
+
export function formatFooterPath(cwd: string, home = ''): string {
|
|
255
|
+
return !home
|
|
256
|
+
? cwd
|
|
257
|
+
: cwd === home
|
|
258
|
+
? '~'
|
|
259
|
+
: cwd.startsWith(`${home}/`)
|
|
260
|
+
? `~${cwd.slice(home.length)}`
|
|
261
|
+
: cwd;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function formatFooterBranch(branch: string, maxWidth = 30): string {
|
|
265
|
+
const trimmed = branch.trim();
|
|
266
|
+
const separator = trimmed.indexOf('/');
|
|
267
|
+
const prefixed =
|
|
268
|
+
separator > 0
|
|
269
|
+
? `${Array.from(trimmed.slice(0, separator))[0] ?? ''}/${trimmed.slice(separator + 1)}`
|
|
270
|
+
: trimmed;
|
|
271
|
+
return truncateToWidth(prefixed, Math.max(0, Math.floor(maxWidth)), '...').replace(
|
|
272
|
+
/\u001b\[[0-?]*[ -/]*[@-~]/gu,
|
|
273
|
+
'',
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export type FooterPaletteColor =
|
|
278
|
+
| 'accent'
|
|
279
|
+
| 'borderAccent'
|
|
280
|
+
| 'customMessageLabel'
|
|
281
|
+
| 'error'
|
|
282
|
+
| 'mdHeading'
|
|
283
|
+
| 'success'
|
|
284
|
+
| 'toolDiffAdded'
|
|
285
|
+
| 'thinkingHigh'
|
|
286
|
+
| 'thinkingLow'
|
|
287
|
+
| 'thinkingMax'
|
|
288
|
+
| 'thinkingMedium'
|
|
289
|
+
| 'thinkingXhigh'
|
|
290
|
+
| 'warning';
|
|
291
|
+
|
|
292
|
+
export function reasoningFooterColor(thinking = ''): FooterPaletteColor {
|
|
293
|
+
switch (thinking.trim().toLowerCase()) {
|
|
294
|
+
case 'medium':
|
|
295
|
+
return 'thinkingMedium';
|
|
296
|
+
case 'high':
|
|
297
|
+
return 'thinkingHigh';
|
|
298
|
+
case 'xhigh':
|
|
299
|
+
return 'thinkingXhigh';
|
|
300
|
+
case 'max':
|
|
301
|
+
return 'thinkingMax';
|
|
302
|
+
default:
|
|
303
|
+
return 'thinkingLow';
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function contextFooterColor(_thinking = '', percent = 0): FooterPaletteColor {
|
|
308
|
+
const normalizedPercent = Number.isFinite(percent)
|
|
309
|
+
? Math.max(0, Math.min(100, percent))
|
|
310
|
+
: 0;
|
|
311
|
+
return normalizedPercent >= 85 ? 'warning' : 'accent';
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function countCompactions(entries: readonly { type: string }[]): number {
|
|
315
|
+
return entries.filter((entry) => entry.type === 'compaction').length;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function buildAgentFooterWidgets(options: {
|
|
319
|
+
cwd: string;
|
|
320
|
+
branch: string;
|
|
321
|
+
git: GitStatusSummary;
|
|
322
|
+
gitAvailable?: boolean;
|
|
323
|
+
model: string;
|
|
324
|
+
thinking?: string;
|
|
325
|
+
contextPercent: number;
|
|
326
|
+
compactionCount?: number;
|
|
327
|
+
devbox?: boolean;
|
|
328
|
+
}) {
|
|
329
|
+
const gauge = buildContextGauge(options.contextPercent, 80);
|
|
330
|
+
const gitAvailable = options.gitAvailable ?? Boolean(options.branch);
|
|
331
|
+
const modelColor: FooterPaletteColor = 'accent';
|
|
332
|
+
const reasoningColor: FooterPaletteColor = 'borderAccent';
|
|
333
|
+
const contextColor = contextFooterColor(options.thinking, gauge.percent);
|
|
334
|
+
const compactionCount = Math.max(0, Math.trunc(options.compactionCount ?? 0));
|
|
335
|
+
return [
|
|
336
|
+
{
|
|
337
|
+
protocol: 1,
|
|
338
|
+
type: 'upsert',
|
|
339
|
+
widget: {
|
|
340
|
+
id: LOCATION_WIDGET_ID,
|
|
341
|
+
label: 'Working directory',
|
|
342
|
+
description: 'Current Pi working directory',
|
|
343
|
+
content: { type: 'text', text: options.cwd },
|
|
344
|
+
icon: {
|
|
345
|
+
glyphs: options.devbox
|
|
346
|
+
? {
|
|
347
|
+
nerd: DEVBOX_PWD_ICON,
|
|
348
|
+
emoji: '[📦] 📁',
|
|
349
|
+
unicode: '[□] ▣',
|
|
350
|
+
ascii: '[D] D',
|
|
351
|
+
}
|
|
352
|
+
: { nerd: '', emoji: '📁', unicode: '▣', ascii: 'D' },
|
|
353
|
+
color: 'accent',
|
|
354
|
+
},
|
|
355
|
+
style: { textColor: 'accent', bold: true },
|
|
356
|
+
layout: {
|
|
357
|
+
row: 0,
|
|
358
|
+
position: 0,
|
|
359
|
+
align: 'left',
|
|
360
|
+
fill: 'grow',
|
|
361
|
+
minWidth: 12,
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
protocol: 1,
|
|
367
|
+
type: 'upsert',
|
|
368
|
+
widget: {
|
|
369
|
+
id: GIT_SCOPE_SEPARATOR_WIDGET_ID,
|
|
370
|
+
label: 'Git scope separator',
|
|
371
|
+
description: 'Separates project status from Git state',
|
|
372
|
+
content: { type: 'text', text: options.branch ? '›' : '' },
|
|
373
|
+
icon: false,
|
|
374
|
+
style: { textColor: 'accent' },
|
|
375
|
+
layout: { row: 0, position: 1, align: 'left', fill: 'none' },
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
protocol: 1,
|
|
380
|
+
type: 'upsert',
|
|
381
|
+
widget: {
|
|
382
|
+
id: GIT_BRANCH_WIDGET_ID,
|
|
383
|
+
label: 'Git branch',
|
|
384
|
+
description: 'Current Git branch',
|
|
385
|
+
content: {
|
|
386
|
+
type: 'text',
|
|
387
|
+
text: options.branch
|
|
388
|
+
? options.branch
|
|
389
|
+
: gitAvailable
|
|
390
|
+
? '(detached)'
|
|
391
|
+
: '(no Git)',
|
|
392
|
+
},
|
|
393
|
+
icon: options.branch
|
|
394
|
+
? {
|
|
395
|
+
glyphs: { nerd: '', emoji: '🌿', unicode: '⎇', ascii: '*' },
|
|
396
|
+
color: 'success',
|
|
397
|
+
}
|
|
398
|
+
: {
|
|
399
|
+
glyphs: { nerd: '› ', emoji: '› 🌿', unicode: '› ⎇', ascii: '> *' },
|
|
400
|
+
color: 'dim',
|
|
401
|
+
},
|
|
402
|
+
style: { textColor: options.branch ? 'success' : 'dim' },
|
|
403
|
+
layout: {
|
|
404
|
+
row: 0,
|
|
405
|
+
position: 2,
|
|
406
|
+
align: 'left',
|
|
407
|
+
fill: options.branch ? 'none' : 'grow',
|
|
408
|
+
...(options.branch ? {} : { minWidth: 0 }),
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
protocol: 1,
|
|
414
|
+
type: 'upsert',
|
|
415
|
+
widget: {
|
|
416
|
+
id: GIT_STAGED_WIDGET_ID,
|
|
417
|
+
label: 'Git staged files',
|
|
418
|
+
description: 'Number of staged files',
|
|
419
|
+
content: {
|
|
420
|
+
type: 'text',
|
|
421
|
+
text: options.git.staged > 0 ? String(options.git.staged) : '',
|
|
422
|
+
},
|
|
423
|
+
icon:
|
|
424
|
+
options.git.staged > 0
|
|
425
|
+
? {
|
|
426
|
+
glyphs: { nerd: '', emoji: '➕', unicode: '+', ascii: '+' },
|
|
427
|
+
color: 'toolDiffAdded',
|
|
428
|
+
}
|
|
429
|
+
: false,
|
|
430
|
+
style: { textColor: 'toolDiffAdded' },
|
|
431
|
+
layout: { row: 0, position: 3, align: 'left', fill: 'none' },
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
protocol: 1,
|
|
436
|
+
type: 'upsert',
|
|
437
|
+
widget: {
|
|
438
|
+
id: GIT_MODIFIED_WIDGET_ID,
|
|
439
|
+
label: 'Git modified files',
|
|
440
|
+
description: 'Number of modified files',
|
|
441
|
+
content: {
|
|
442
|
+
type: 'text',
|
|
443
|
+
text: options.git.modified > 0 ? String(options.git.modified) : '',
|
|
444
|
+
},
|
|
445
|
+
icon:
|
|
446
|
+
options.git.modified > 0
|
|
447
|
+
? {
|
|
448
|
+
glyphs: { nerd: '', emoji: '✏️', unicode: '~', ascii: '~' },
|
|
449
|
+
color: 'warning',
|
|
450
|
+
}
|
|
451
|
+
: false,
|
|
452
|
+
style: { textColor: 'warning' },
|
|
453
|
+
layout: { row: 0, position: 4, align: 'left', fill: 'none' },
|
|
454
|
+
},
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
protocol: 1,
|
|
458
|
+
type: 'upsert',
|
|
459
|
+
widget: {
|
|
460
|
+
id: GIT_UNTRACKED_WIDGET_ID,
|
|
461
|
+
label: 'Git untracked files',
|
|
462
|
+
description: 'Number of untracked files',
|
|
463
|
+
content: {
|
|
464
|
+
type: 'text',
|
|
465
|
+
text: options.git.untracked > 0 ? String(options.git.untracked) : '',
|
|
466
|
+
},
|
|
467
|
+
icon:
|
|
468
|
+
options.git.untracked > 0
|
|
469
|
+
? {
|
|
470
|
+
glyphs: { nerd: '', emoji: '❓', unicode: '?', ascii: '?' },
|
|
471
|
+
color: 'mdHeading',
|
|
472
|
+
}
|
|
473
|
+
: false,
|
|
474
|
+
style: { textColor: 'mdHeading' },
|
|
475
|
+
layout: { row: 0, position: 5, align: 'left', fill: 'none' },
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
{
|
|
479
|
+
protocol: 1,
|
|
480
|
+
type: 'upsert',
|
|
481
|
+
widget: {
|
|
482
|
+
id: GIT_CONFLICT_WIDGET_ID,
|
|
483
|
+
label: 'Git conflicts',
|
|
484
|
+
description: 'Number of conflicted files',
|
|
485
|
+
content: {
|
|
486
|
+
type: 'text',
|
|
487
|
+
text: options.git.conflicts > 0 ? String(options.git.conflicts) : '',
|
|
488
|
+
},
|
|
489
|
+
icon:
|
|
490
|
+
options.git.conflicts > 0
|
|
491
|
+
? {
|
|
492
|
+
glyphs: { nerd: '', emoji: '⚠️', unicode: '!', ascii: '!' },
|
|
493
|
+
color: 'error',
|
|
494
|
+
}
|
|
495
|
+
: false,
|
|
496
|
+
style: { textColor: 'error' },
|
|
497
|
+
layout: { row: 0, position: 6, align: 'left', fill: 'none' },
|
|
498
|
+
},
|
|
499
|
+
},
|
|
500
|
+
{
|
|
501
|
+
protocol: 1,
|
|
502
|
+
type: 'upsert',
|
|
503
|
+
widget: {
|
|
504
|
+
id: ENVIRONMENT_WIDGET_ID,
|
|
505
|
+
label: 'Agent model',
|
|
506
|
+
description: 'Current model identity',
|
|
507
|
+
content: { type: 'text', text: options.model },
|
|
508
|
+
icon: {
|
|
509
|
+
glyphs: { nerd: '', emoji: '🤖', unicode: '◉', ascii: '@' },
|
|
510
|
+
color: modelColor,
|
|
511
|
+
},
|
|
512
|
+
style: { textColor: modelColor },
|
|
513
|
+
layout: { row: 1, position: 0, align: 'left', fill: 'none' },
|
|
514
|
+
},
|
|
515
|
+
},
|
|
516
|
+
{
|
|
517
|
+
protocol: 1,
|
|
518
|
+
type: 'upsert',
|
|
519
|
+
widget: {
|
|
520
|
+
id: REASONING_WIDGET_ID,
|
|
521
|
+
label: 'Reasoning effort',
|
|
522
|
+
description: 'Current model reasoning level',
|
|
523
|
+
content: {
|
|
524
|
+
type: 'text',
|
|
525
|
+
text: options.thinking ? `› ${options.thinking}` : '',
|
|
526
|
+
},
|
|
527
|
+
icon: false,
|
|
528
|
+
style: { textColor: reasoningColor },
|
|
529
|
+
layout: { row: 1, position: 1, align: 'left', fill: 'none' },
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
{
|
|
533
|
+
protocol: 1,
|
|
534
|
+
type: 'upsert',
|
|
535
|
+
widget: {
|
|
536
|
+
id: SCOPE_SEPARATOR_WIDGET_ID,
|
|
537
|
+
label: 'Scope separator',
|
|
538
|
+
description: 'Separates model identity from local session metrics',
|
|
539
|
+
content: { type: 'text', text: '⟩' },
|
|
540
|
+
icon: false,
|
|
541
|
+
style: { textColor: 'thinkingHigh' },
|
|
542
|
+
layout: { row: 1, position: 5, align: 'right', fill: 'none' },
|
|
543
|
+
},
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
protocol: 1,
|
|
547
|
+
type: 'upsert',
|
|
548
|
+
widget: {
|
|
549
|
+
id: CONTEXT_WIDGET_ID,
|
|
550
|
+
label: 'Context usage',
|
|
551
|
+
description: 'Current model context consumption',
|
|
552
|
+
content: {
|
|
553
|
+
type: 'text',
|
|
554
|
+
text: gauge.percentText,
|
|
555
|
+
},
|
|
556
|
+
icon: {
|
|
557
|
+
glyphs: { nerd: '', emoji: '🎯', unicode: '◎', ascii: 'O' },
|
|
558
|
+
color: contextColor,
|
|
559
|
+
},
|
|
560
|
+
style: { textColor: contextColor },
|
|
561
|
+
layout: { row: 1, position: 9, align: 'right', fill: 'none' },
|
|
562
|
+
},
|
|
563
|
+
},
|
|
564
|
+
{
|
|
565
|
+
protocol: 1,
|
|
566
|
+
type: 'upsert',
|
|
567
|
+
widget: {
|
|
568
|
+
id: COMPACTION_WIDGET_ID,
|
|
569
|
+
label: 'Compaction count',
|
|
570
|
+
description: 'Number of context compactions in the active session branch',
|
|
571
|
+
content: {
|
|
572
|
+
type: 'text',
|
|
573
|
+
text: compactionCount > 0 ? String(compactionCount) : '',
|
|
574
|
+
},
|
|
575
|
+
icon:
|
|
576
|
+
compactionCount > 0
|
|
577
|
+
? {
|
|
578
|
+
glyphs: {
|
|
579
|
+
nerd: '› ',
|
|
580
|
+
emoji: '› 📝',
|
|
581
|
+
unicode: '› ¶',
|
|
582
|
+
ascii: '> S',
|
|
583
|
+
},
|
|
584
|
+
color: contextColor,
|
|
585
|
+
}
|
|
586
|
+
: false,
|
|
587
|
+
style: { textColor: contextColor },
|
|
588
|
+
layout: { row: 1, position: 10, align: 'right', fill: 'none' },
|
|
589
|
+
},
|
|
590
|
+
},
|
|
591
|
+
{
|
|
592
|
+
protocol: 1,
|
|
593
|
+
type: 'upsert',
|
|
594
|
+
widget: {
|
|
595
|
+
id: RESOURCE_SEPARATOR_WIDGET_ID,
|
|
596
|
+
label: 'Resource separator',
|
|
597
|
+
description: 'Separates context state from session RAM and CPU',
|
|
598
|
+
content: { type: 'text', text: '⟩' },
|
|
599
|
+
icon: false,
|
|
600
|
+
style: { textColor: 'thinkingHigh' },
|
|
601
|
+
layout: { row: 1, position: 8, align: 'right', fill: 'none' },
|
|
602
|
+
},
|
|
603
|
+
},
|
|
604
|
+
] as const;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export function parseGitBranch(stdout: string): string {
|
|
608
|
+
const branch = stdout.trim().split(/\r?\n/u, 1)[0] ?? '';
|
|
609
|
+
return branch === 'HEAD' ? '' : branch.slice(0, 48);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
export interface GitStatusSummary {
|
|
613
|
+
staged: number;
|
|
614
|
+
modified: number;
|
|
615
|
+
untracked: number;
|
|
616
|
+
conflicts: number;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export function parseGitStatus(stdout: string): GitStatusSummary {
|
|
620
|
+
const summary: GitStatusSummary = {
|
|
621
|
+
staged: 0,
|
|
622
|
+
modified: 0,
|
|
623
|
+
untracked: 0,
|
|
624
|
+
conflicts: 0,
|
|
625
|
+
};
|
|
626
|
+
const conflictStates = new Set(['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU']);
|
|
627
|
+
for (const line of stdout.split(/\r?\n/u)) {
|
|
628
|
+
if (line.startsWith('??')) {
|
|
629
|
+
summary.untracked += 1;
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
if (line.length < 2 || line.startsWith('!!')) continue;
|
|
633
|
+
const state = line.slice(0, 2);
|
|
634
|
+
if (conflictStates.has(state)) {
|
|
635
|
+
summary.conflicts += 1;
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
const index = line[0];
|
|
639
|
+
const worktree = line[1];
|
|
640
|
+
if (index && index !== ' ' && index !== '?') summary.staged += 1;
|
|
641
|
+
if (worktree && worktree !== ' ') summary.modified += 1;
|
|
642
|
+
}
|
|
643
|
+
return summary;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function isHeaderColor(value: unknown): value is HeaderColor {
|
|
647
|
+
return (
|
|
648
|
+
typeof value === 'string' && (HEADER_COLORS as readonly string[]).includes(value)
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
export function parseDiagnosticsHeader(raw: unknown): HeaderDiagnostics | null {
|
|
653
|
+
const snapshot = parseHeaderSnapshot(raw);
|
|
654
|
+
return snapshot?.diagnostics ?? null;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function boundedProgress(
|
|
658
|
+
candidate: Record<string, unknown>,
|
|
659
|
+
): Pick<HeaderActivitySegment, 'current' | 'total'> {
|
|
660
|
+
if (
|
|
661
|
+
typeof candidate.current !== 'number' ||
|
|
662
|
+
!Number.isFinite(candidate.current) ||
|
|
663
|
+
typeof candidate.total !== 'number' ||
|
|
664
|
+
!Number.isFinite(candidate.total) ||
|
|
665
|
+
candidate.total <= 0
|
|
666
|
+
) {
|
|
667
|
+
return {};
|
|
668
|
+
}
|
|
669
|
+
const total = Math.min(9_999, Math.max(1, Math.trunc(candidate.total)));
|
|
670
|
+
return {
|
|
671
|
+
total,
|
|
672
|
+
current: Math.min(total, Math.max(0, Math.trunc(candidate.current))),
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function parseHeaderActivity(value: unknown): HeaderActivity | undefined {
|
|
677
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
|
678
|
+
const candidate = value as Record<string, unknown>;
|
|
679
|
+
if (
|
|
680
|
+
typeof candidate.kind !== 'string' ||
|
|
681
|
+
!(HEADER_ACTIVITY_KINDS as readonly string[]).includes(candidate.kind)
|
|
682
|
+
) {
|
|
683
|
+
return undefined;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
return {
|
|
687
|
+
kind: candidate.kind as HeaderActivity['kind'],
|
|
688
|
+
...boundedProgress(candidate),
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const ACTIVITY_SEGMENT_ID = /^[a-z0-9][a-z0-9-]*$/u;
|
|
693
|
+
const ANSI_ESCAPE =
|
|
694
|
+
/\u001b(?:\][\s\S]*?(?:\u0007|\u001b\\)|\[[0-?]*[ -/]*[@-~]|[@-_])/gu;
|
|
695
|
+
const C1_CSI = /\u009b[0-?]*[ -/]*[@-~]/gu;
|
|
696
|
+
const CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f]/gu;
|
|
697
|
+
|
|
698
|
+
function boundedCodePoints(value: string, maximum: number): string {
|
|
699
|
+
return Array.from(value).slice(0, maximum).join('');
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function normalizeActivityLabel(value: string, maximum: number): string | undefined {
|
|
703
|
+
const normalized = value
|
|
704
|
+
.replace(ANSI_ESCAPE, '')
|
|
705
|
+
.replace(C1_CSI, '')
|
|
706
|
+
.replace(CONTROL_CHARACTER, ' ')
|
|
707
|
+
.replace(/\s+/gu, ' ')
|
|
708
|
+
.trim();
|
|
709
|
+
if (!normalized) return undefined;
|
|
710
|
+
return boundedCodePoints(normalized, maximum);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
export function parseHeaderActivityPath(
|
|
714
|
+
value: unknown,
|
|
715
|
+
): HeaderActivitySegment[] | undefined {
|
|
716
|
+
if (!Array.isArray(value) || value.length > MAX_ACTIVITY_PATH_DEPTH) {
|
|
717
|
+
return undefined;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const ids = new Set<string>();
|
|
721
|
+
const path: HeaderActivitySegment[] = [];
|
|
722
|
+
for (const rawSegment of value) {
|
|
723
|
+
if (!rawSegment || typeof rawSegment !== 'object' || Array.isArray(rawSegment)) {
|
|
724
|
+
return undefined;
|
|
725
|
+
}
|
|
726
|
+
const candidate = rawSegment as Record<string, unknown>;
|
|
727
|
+
if (
|
|
728
|
+
typeof candidate.id !== 'string' ||
|
|
729
|
+
Array.from(candidate.id).length > MAX_ACTIVITY_SEGMENT_ID_LENGTH ||
|
|
730
|
+
!ACTIVITY_SEGMENT_ID.test(candidate.id) ||
|
|
731
|
+
ids.has(candidate.id) ||
|
|
732
|
+
typeof candidate.label !== 'string' ||
|
|
733
|
+
(candidate.compact !== undefined && typeof candidate.compact !== 'string')
|
|
734
|
+
) {
|
|
735
|
+
return undefined;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const label = normalizeActivityLabel(
|
|
739
|
+
candidate.label,
|
|
740
|
+
MAX_ACTIVITY_SEGMENT_LABEL_LENGTH,
|
|
741
|
+
);
|
|
742
|
+
const compact =
|
|
743
|
+
candidate.compact === undefined
|
|
744
|
+
? undefined
|
|
745
|
+
: normalizeActivityLabel(
|
|
746
|
+
candidate.compact,
|
|
747
|
+
MAX_ACTIVITY_SEGMENT_COMPACT_LENGTH,
|
|
748
|
+
);
|
|
749
|
+
if (!label || (candidate.compact !== undefined && !compact)) return undefined;
|
|
750
|
+
|
|
751
|
+
ids.add(candidate.id);
|
|
752
|
+
path.push({
|
|
753
|
+
id: candidate.id,
|
|
754
|
+
label,
|
|
755
|
+
...(compact ? { compact } : {}),
|
|
756
|
+
...boundedProgress(candidate),
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
return path;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function boundedActiveRuns(
|
|
763
|
+
value: unknown,
|
|
764
|
+
): { children: number; subagents: number } | undefined {
|
|
765
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
|
766
|
+
const candidate = value as Record<string, unknown>;
|
|
767
|
+
if (
|
|
768
|
+
!Number.isInteger(candidate.children) ||
|
|
769
|
+
!Number.isInteger(candidate.subagents) ||
|
|
770
|
+
Number(candidate.children) < 0 ||
|
|
771
|
+
Number(candidate.subagents) < 0 ||
|
|
772
|
+
Number(candidate.subagents) > Number(candidate.children) ||
|
|
773
|
+
Number(candidate.children) > 999_999
|
|
774
|
+
) {
|
|
775
|
+
return undefined;
|
|
776
|
+
}
|
|
777
|
+
return {
|
|
778
|
+
children: Number(candidate.children),
|
|
779
|
+
subagents: Number(candidate.subagents),
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function boundedCounterPair(
|
|
784
|
+
value: unknown,
|
|
785
|
+
completedKey: string,
|
|
786
|
+
): { completed: number; total: number } | undefined {
|
|
787
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
|
788
|
+
const record = value as Record<string, unknown>;
|
|
789
|
+
const completed = record[completedKey];
|
|
790
|
+
const total = record.total;
|
|
791
|
+
if (
|
|
792
|
+
!Number.isInteger(completed) ||
|
|
793
|
+
!Number.isInteger(total) ||
|
|
794
|
+
Number(completed) < 0 ||
|
|
795
|
+
Number(total) < 0 ||
|
|
796
|
+
Number(completed) > Number(total) ||
|
|
797
|
+
Number(total) > 999_999
|
|
798
|
+
) {
|
|
799
|
+
return undefined;
|
|
800
|
+
}
|
|
801
|
+
return { completed: Number(completed), total: Number(total) };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
export function parseFooterTelemetry(raw: unknown): FooterTelemetrySnapshot | null {
|
|
805
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
806
|
+
const message = raw as Record<string, unknown>;
|
|
807
|
+
if (message.protocol !== 1 || message.type !== 'snapshot') return null;
|
|
808
|
+
if (
|
|
809
|
+
typeof message.totalCost !== 'number' ||
|
|
810
|
+
!Number.isFinite(message.totalCost) ||
|
|
811
|
+
message.totalCost < 0
|
|
812
|
+
) {
|
|
813
|
+
return null;
|
|
814
|
+
}
|
|
815
|
+
if (
|
|
816
|
+
message.quotaPercent !== undefined &&
|
|
817
|
+
(typeof message.quotaPercent !== 'number' ||
|
|
818
|
+
!Number.isFinite(message.quotaPercent) ||
|
|
819
|
+
message.quotaPercent < 0 ||
|
|
820
|
+
message.quotaPercent > 100)
|
|
821
|
+
) {
|
|
822
|
+
return null;
|
|
823
|
+
}
|
|
824
|
+
const quotaWindows =
|
|
825
|
+
message.quotaWindows === undefined
|
|
826
|
+
? undefined
|
|
827
|
+
: parseFooterQuotaWindows(message.quotaWindows);
|
|
828
|
+
if (message.quotaWindows !== undefined && quotaWindows === undefined) {
|
|
829
|
+
return null;
|
|
830
|
+
}
|
|
831
|
+
return {
|
|
832
|
+
totalCost: message.totalCost,
|
|
833
|
+
...(message.quotaPercent !== undefined
|
|
834
|
+
? { quotaPercent: message.quotaPercent }
|
|
835
|
+
: {}),
|
|
836
|
+
...(quotaWindows !== undefined ? { quotaWindows } : {}),
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function parseFooterQuotaWindows(value: unknown): FooterQuotaWindow[] | undefined {
|
|
841
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > 2) {
|
|
842
|
+
return undefined;
|
|
843
|
+
}
|
|
844
|
+
const windows: FooterQuotaWindow[] = [];
|
|
845
|
+
for (const entry of value) {
|
|
846
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
847
|
+
return undefined;
|
|
848
|
+
}
|
|
849
|
+
const limit = entry as Record<string, unknown>;
|
|
850
|
+
if (
|
|
851
|
+
typeof limit.label !== 'string' ||
|
|
852
|
+
limit.label.length === 0 ||
|
|
853
|
+
limit.label.length > 8
|
|
854
|
+
) {
|
|
855
|
+
return undefined;
|
|
856
|
+
}
|
|
857
|
+
const percent = Number(limit.percent);
|
|
858
|
+
if (!Number.isFinite(percent) || percent < 0 || percent > 100) {
|
|
859
|
+
return undefined;
|
|
860
|
+
}
|
|
861
|
+
const resetAt = limit.resetAt === undefined ? undefined : Number(limit.resetAt);
|
|
862
|
+
if (resetAt !== undefined && (!Number.isFinite(resetAt) || resetAt <= 0)) {
|
|
863
|
+
return undefined;
|
|
864
|
+
}
|
|
865
|
+
windows.push({
|
|
866
|
+
label: limit.label,
|
|
867
|
+
percent,
|
|
868
|
+
...(resetAt !== undefined ? { resetAt } : {}),
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
return windows;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
export function formatDeckCountdown(resetAt: number, nowMs = Date.now()): string {
|
|
875
|
+
if (!Number.isFinite(resetAt) || !Number.isFinite(nowMs) || resetAt <= 0) {
|
|
876
|
+
return '';
|
|
877
|
+
}
|
|
878
|
+
const remainingMs = resetAt * 1000 - nowMs;
|
|
879
|
+
if (remainingMs <= 0) return '';
|
|
880
|
+
// Clock-style H:MM like the provider's own web UI: the coding window always
|
|
881
|
+
// fits inside a day, and longer horizons read better as whole days.
|
|
882
|
+
const minuteMs = 60_000;
|
|
883
|
+
const hourMs = 60 * minuteMs;
|
|
884
|
+
const dayMs = 24 * hourMs;
|
|
885
|
+
if (remainingMs >= dayMs) {
|
|
886
|
+
return `${Math.floor(remainingMs / dayMs)}d`;
|
|
887
|
+
}
|
|
888
|
+
const hours = Math.floor(remainingMs / hourMs);
|
|
889
|
+
const minutes = Math.floor((remainingMs % hourMs) / minuteMs);
|
|
890
|
+
return `${hours}:${String(minutes).padStart(2, '0')}`;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
export function parsePiStatusSnapshot(raw: unknown): PiStatusSnapshot | null {
|
|
894
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
895
|
+
const message = raw as Record<string, unknown>;
|
|
896
|
+
if (message.protocol !== 1 || message.type !== 'snapshot') return null;
|
|
897
|
+
const counts = [message.errors, message.warnings, message.nativeStatusCount];
|
|
898
|
+
if (
|
|
899
|
+
!counts.every(
|
|
900
|
+
(value) => Number.isInteger(value) && Number(value) >= 0 && Number(value) <= 999,
|
|
901
|
+
)
|
|
902
|
+
) {
|
|
903
|
+
return null;
|
|
904
|
+
}
|
|
905
|
+
const nativeStatuses: Array<{ key: string; text: string }> = [];
|
|
906
|
+
if (Array.isArray(message.nativeStatuses)) {
|
|
907
|
+
for (const entry of message.nativeStatuses.slice(0, 8)) {
|
|
908
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
|
909
|
+
const candidate = entry as Record<string, unknown>;
|
|
910
|
+
if (typeof candidate.key !== 'string' || typeof candidate.text !== 'string') {
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
nativeStatuses.push({
|
|
914
|
+
key: candidate.key.slice(0, 24),
|
|
915
|
+
text: candidate.text.slice(0, 160),
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
return {
|
|
920
|
+
errors: Number(message.errors),
|
|
921
|
+
warnings: Number(message.warnings),
|
|
922
|
+
nativeStatusCount: Number(message.nativeStatusCount),
|
|
923
|
+
...(nativeStatuses.length > 0 ? { nativeStatuses } : {}),
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
export function parseVimMode(raw: unknown): VimMode | null {
|
|
928
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
929
|
+
const mode = (raw as Record<string, unknown>).mode;
|
|
930
|
+
return ['insert', 'normal', 'visual', 'visual-line', 'ex'].includes(String(mode))
|
|
931
|
+
? (mode as VimMode)
|
|
932
|
+
: null;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function parseHeaderSelection(value: unknown): HeaderSelection | null {
|
|
936
|
+
if (value === null || !value || typeof value !== 'object' || Array.isArray(value)) {
|
|
937
|
+
return null;
|
|
938
|
+
}
|
|
939
|
+
const candidate = value as Record<string, unknown>;
|
|
940
|
+
if (
|
|
941
|
+
typeof candidate.source !== 'string' ||
|
|
942
|
+
!(HEADER_SELECTION_SOURCES as readonly string[]).includes(candidate.source) ||
|
|
943
|
+
!Array.isArray(candidate.titles) ||
|
|
944
|
+
candidate.titles.length < 1 ||
|
|
945
|
+
candidate.titles.length > 2 ||
|
|
946
|
+
!candidate.titles.every((title) => typeof title === 'string') ||
|
|
947
|
+
!isHeaderColor(candidate.color)
|
|
948
|
+
) {
|
|
949
|
+
return null;
|
|
950
|
+
}
|
|
951
|
+
const titles = candidate.titles.flatMap((title) => {
|
|
952
|
+
const normalized = normalizeActivityLabel(String(title), 180);
|
|
953
|
+
return normalized ? [normalized] : [];
|
|
954
|
+
});
|
|
955
|
+
if (titles.length !== candidate.titles.length) return null;
|
|
956
|
+
|
|
957
|
+
let goal: HeaderSelectionGoalDetail | undefined;
|
|
958
|
+
if (
|
|
959
|
+
candidate.source === 'goal' &&
|
|
960
|
+
candidate.goal &&
|
|
961
|
+
typeof candidate.goal === 'object' &&
|
|
962
|
+
!Array.isArray(candidate.goal)
|
|
963
|
+
) {
|
|
964
|
+
const raw = candidate.goal as Record<string, unknown>;
|
|
965
|
+
const boundedNumber = (input: unknown): number | undefined =>
|
|
966
|
+
typeof input === 'number' &&
|
|
967
|
+
Number.isFinite(input) &&
|
|
968
|
+
input >= 0 &&
|
|
969
|
+
input <= 9_999_999_999
|
|
970
|
+
? input
|
|
971
|
+
: undefined;
|
|
972
|
+
if (typeof raw.status === 'string' && raw.status.length <= 24) {
|
|
973
|
+
const detail: HeaderSelectionGoalDetail = { status: raw.status };
|
|
974
|
+
const elapsed = boundedNumber(raw.elapsedSeconds);
|
|
975
|
+
if (elapsed !== undefined) detail.elapsedSeconds = elapsed;
|
|
976
|
+
for (const [key, value] of [
|
|
977
|
+
['tasksDone', raw.tasksDone],
|
|
978
|
+
['tasksActive', raw.tasksActive],
|
|
979
|
+
['tasksTotal', raw.tasksTotal],
|
|
980
|
+
['tokenBudget', raw.tokenBudget],
|
|
981
|
+
] as const) {
|
|
982
|
+
const parsed = boundedNumber(value);
|
|
983
|
+
if (parsed !== undefined && parsed <= 9_999) {
|
|
984
|
+
detail[key] = parsed;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
const tokensUsed = boundedNumber(raw.tokensUsed);
|
|
988
|
+
if (tokensUsed !== undefined) detail.tokensUsed = tokensUsed;
|
|
989
|
+
goal = detail;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
return {
|
|
993
|
+
source: candidate.source as HeaderSelection['source'],
|
|
994
|
+
titles,
|
|
995
|
+
color: candidate.color,
|
|
996
|
+
...(goal !== undefined ? { goal } : {}),
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function parseHeaderSuggestion(value: unknown): HeaderSuggestion | null {
|
|
1001
|
+
return typeof value === 'string' &&
|
|
1002
|
+
(HEADER_SUGGESTIONS as readonly string[]).includes(value)
|
|
1003
|
+
? (value as HeaderSuggestion)
|
|
1004
|
+
: null;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
const EMPTY_SELECTION_TITLES = new Set([
|
|
1008
|
+
'no focus',
|
|
1009
|
+
'no openspec',
|
|
1010
|
+
'no focused task',
|
|
1011
|
+
'openspec',
|
|
1012
|
+
'openspec unavailable',
|
|
1013
|
+
'openspec clear',
|
|
1014
|
+
'taskflow run',
|
|
1015
|
+
]);
|
|
1016
|
+
|
|
1017
|
+
function legacyHeaderSelection(work: HeaderWork | null): HeaderSelection | null {
|
|
1018
|
+
if (!work || work.titles.length === 0) return null;
|
|
1019
|
+
const titles = work.titles.slice(0, 2).flatMap((title) => {
|
|
1020
|
+
const normalized = normalizeActivityLabel(title, 180);
|
|
1021
|
+
return normalized ? [normalized] : [];
|
|
1022
|
+
});
|
|
1023
|
+
if (
|
|
1024
|
+
titles.length === 0 ||
|
|
1025
|
+
titles.some((title) => EMPTY_SELECTION_TITLES.has(title.toLowerCase()))
|
|
1026
|
+
) {
|
|
1027
|
+
return null;
|
|
1028
|
+
}
|
|
1029
|
+
return { source: 'legacy', titles, color: work.color };
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function parseActiveRunSpans(value: unknown): HeaderSnapshot['activeRunSpans'] | null {
|
|
1033
|
+
if (!Array.isArray(value)) return null;
|
|
1034
|
+
const spans = value
|
|
1035
|
+
.map((rawSpan) => {
|
|
1036
|
+
if (!rawSpan || typeof rawSpan !== 'object' || Array.isArray(rawSpan))
|
|
1037
|
+
return null;
|
|
1038
|
+
const span = rawSpan as Record<string, unknown>;
|
|
1039
|
+
if (typeof span.id !== 'string') return null;
|
|
1040
|
+
if (
|
|
1041
|
+
typeof span.kind !== 'string' ||
|
|
1042
|
+
!['agent', 'subagent', 'bash'].includes(span.kind)
|
|
1043
|
+
) {
|
|
1044
|
+
return null;
|
|
1045
|
+
}
|
|
1046
|
+
if (typeof span.stage !== 'string') return null;
|
|
1047
|
+
const elapsedMs = typeof span.elapsedMs === 'number' ? span.elapsedMs : 0;
|
|
1048
|
+
return {
|
|
1049
|
+
id: span.id,
|
|
1050
|
+
kind: span.kind as 'agent' | 'subagent' | 'bash',
|
|
1051
|
+
parent: typeof span.parent === 'string' ? span.parent : null,
|
|
1052
|
+
...(typeof span.agent === 'string' ? { agent: span.agent } : {}),
|
|
1053
|
+
...(typeof span.label === 'string' ? { label: span.label } : {}),
|
|
1054
|
+
stage: span.stage,
|
|
1055
|
+
elapsedMs,
|
|
1056
|
+
};
|
|
1057
|
+
})
|
|
1058
|
+
.filter(
|
|
1059
|
+
(span): span is NonNullable<HeaderSnapshot['activeRunSpans']>[number] =>
|
|
1060
|
+
span !== null,
|
|
1061
|
+
);
|
|
1062
|
+
return spans.length > 0 ? spans : null;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
export function parseHeaderSnapshot(raw: unknown): HeaderSnapshot | null {
|
|
1066
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
1067
|
+
const message = raw as Record<string, unknown>;
|
|
1068
|
+
if (message.protocol !== 1) return null;
|
|
1069
|
+
const openGoals =
|
|
1070
|
+
typeof message.openGoals === 'number' &&
|
|
1071
|
+
Number.isInteger(message.openGoals) &&
|
|
1072
|
+
message.openGoals >= 0 &&
|
|
1073
|
+
message.openGoals <= 99
|
|
1074
|
+
? message.openGoals
|
|
1075
|
+
: 0;
|
|
1076
|
+
|
|
1077
|
+
const standaloneActivity = parseHeaderActivity(message.activity) ?? null;
|
|
1078
|
+
let work: HeaderWork | null = null;
|
|
1079
|
+
if (
|
|
1080
|
+
message.work &&
|
|
1081
|
+
typeof message.work === 'object' &&
|
|
1082
|
+
!Array.isArray(message.work)
|
|
1083
|
+
) {
|
|
1084
|
+
const candidate = message.work as Record<string, unknown>;
|
|
1085
|
+
if (
|
|
1086
|
+
typeof candidate.lifecycle === 'string' &&
|
|
1087
|
+
Array.isArray(candidate.titles) &&
|
|
1088
|
+
candidate.titles.every((title) => typeof title === 'string') &&
|
|
1089
|
+
isHeaderColor(candidate.color)
|
|
1090
|
+
) {
|
|
1091
|
+
const lifecycle = candidate.lifecycle.replace(/\s+/gu, ' ').trim().slice(0, 48);
|
|
1092
|
+
const titles = candidate.titles
|
|
1093
|
+
.map((title) => String(title).replace(/\s+/gu, ' ').trim().slice(0, 180))
|
|
1094
|
+
.filter(Boolean);
|
|
1095
|
+
const activity =
|
|
1096
|
+
parseHeaderActivity(candidate.activity) ?? standaloneActivity ?? undefined;
|
|
1097
|
+
const hasExplicitActivityPath = Object.prototype.hasOwnProperty.call(
|
|
1098
|
+
candidate,
|
|
1099
|
+
'activityPath',
|
|
1100
|
+
);
|
|
1101
|
+
const activityPath = hasExplicitActivityPath
|
|
1102
|
+
? parseHeaderActivityPath(candidate.activityPath)
|
|
1103
|
+
: undefined;
|
|
1104
|
+
const hasValidExplicitActivityPath =
|
|
1105
|
+
hasExplicitActivityPath && activityPath !== undefined;
|
|
1106
|
+
if (lifecycle) {
|
|
1107
|
+
work = {
|
|
1108
|
+
lifecycle,
|
|
1109
|
+
titles,
|
|
1110
|
+
color: candidate.color,
|
|
1111
|
+
...(activity ? { activity } : {}),
|
|
1112
|
+
...(hasValidExplicitActivityPath ? { activityPath } : {}),
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
let diagnostics: HeaderDiagnostics | null = null;
|
|
1119
|
+
if (
|
|
1120
|
+
message.diagnostics &&
|
|
1121
|
+
typeof message.diagnostics === 'object' &&
|
|
1122
|
+
!Array.isArray(message.diagnostics)
|
|
1123
|
+
) {
|
|
1124
|
+
const candidate = message.diagnostics as Record<string, unknown>;
|
|
1125
|
+
if (
|
|
1126
|
+
typeof candidate.text === 'string' &&
|
|
1127
|
+
isHeaderColor(candidate.color) &&
|
|
1128
|
+
['pass', 'fail', 'running'].includes(String(candidate.state))
|
|
1129
|
+
) {
|
|
1130
|
+
const text = candidate.text.replace(/\s+/gu, ' ').trim().slice(0, 48);
|
|
1131
|
+
if (text) {
|
|
1132
|
+
diagnostics = {
|
|
1133
|
+
text,
|
|
1134
|
+
color: candidate.color,
|
|
1135
|
+
state: candidate.state as HeaderDiagnostics['state'],
|
|
1136
|
+
...(Number.isInteger(candidate.count) &&
|
|
1137
|
+
(candidate.count as number) >= 0 &&
|
|
1138
|
+
(candidate.count as number) <= 999
|
|
1139
|
+
? { count: candidate.count as number }
|
|
1140
|
+
: {}),
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
const rawCounters =
|
|
1147
|
+
message.counters && typeof message.counters === 'object'
|
|
1148
|
+
? (message.counters as Record<string, unknown>)
|
|
1149
|
+
: {};
|
|
1150
|
+
const agents = boundedCounterPair(rawCounters.agents, 'active');
|
|
1151
|
+
const activeRuns = boundedActiveRuns(rawCounters.activeRuns);
|
|
1152
|
+
const tasks = boundedCounterPair(rawCounters.tasks, 'completed');
|
|
1153
|
+
const steps = boundedCounterPair(rawCounters.steps, 'completed');
|
|
1154
|
+
const files = boundedCounterPair(rawCounters.files, 'completed');
|
|
1155
|
+
|
|
1156
|
+
const progress = Array.isArray(message.progress)
|
|
1157
|
+
? message.progress.flatMap((rawProgress) => {
|
|
1158
|
+
if (
|
|
1159
|
+
!rawProgress ||
|
|
1160
|
+
typeof rawProgress !== 'object' ||
|
|
1161
|
+
Array.isArray(rawProgress)
|
|
1162
|
+
) {
|
|
1163
|
+
return [];
|
|
1164
|
+
}
|
|
1165
|
+
const candidate = rawProgress as Record<string, unknown>;
|
|
1166
|
+
if (
|
|
1167
|
+
typeof candidate.icon !== 'string' ||
|
|
1168
|
+
typeof candidate.completed !== 'number' ||
|
|
1169
|
+
typeof candidate.total !== 'number' ||
|
|
1170
|
+
!isHeaderColor(candidate.color)
|
|
1171
|
+
) {
|
|
1172
|
+
return [];
|
|
1173
|
+
}
|
|
1174
|
+
return [
|
|
1175
|
+
{
|
|
1176
|
+
icon: candidate.icon.slice(0, 8),
|
|
1177
|
+
completed: Math.max(0, Math.trunc(candidate.completed)),
|
|
1178
|
+
total: Math.max(0, Math.trunc(candidate.total)),
|
|
1179
|
+
color: candidate.color,
|
|
1180
|
+
},
|
|
1181
|
+
];
|
|
1182
|
+
})
|
|
1183
|
+
: [];
|
|
1184
|
+
|
|
1185
|
+
const hasExplicitSelection = Object.prototype.hasOwnProperty.call(
|
|
1186
|
+
message,
|
|
1187
|
+
'selection',
|
|
1188
|
+
);
|
|
1189
|
+
const hasExplicitTitle = Object.prototype.hasOwnProperty.call(message, 'title');
|
|
1190
|
+
const hasExplicitTurn = Object.prototype.hasOwnProperty.call(message, 'turn');
|
|
1191
|
+
|
|
1192
|
+
return {
|
|
1193
|
+
openGoals,
|
|
1194
|
+
activity: standaloneActivity,
|
|
1195
|
+
selection: hasExplicitSelection
|
|
1196
|
+
? parseHeaderSelection(message.selection)
|
|
1197
|
+
: legacyHeaderSelection(work),
|
|
1198
|
+
suggestion: parseHeaderSuggestion(message.suggestion),
|
|
1199
|
+
work,
|
|
1200
|
+
...(hasExplicitTitle ? { title: parseDeckTitleState(message.title) } : {}),
|
|
1201
|
+
...(hasExplicitTurn ? { turn: parseDeckTurnCost(message.turn) } : {}),
|
|
1202
|
+
diagnostics,
|
|
1203
|
+
backgroundActivity: message.backgroundActivity === true,
|
|
1204
|
+
...(parseActiveRunSpans(message.activeRunSpans)
|
|
1205
|
+
? {
|
|
1206
|
+
activeRunSpans: parseActiveRunSpans(
|
|
1207
|
+
message.activeRunSpans,
|
|
1208
|
+
) as HeaderSnapshot['activeRunSpans'],
|
|
1209
|
+
}
|
|
1210
|
+
: {}),
|
|
1211
|
+
...(typeof message.idleMs === 'number' ? { idleMs: message.idleMs } : {}),
|
|
1212
|
+
approvalRequired: message.approvalRequired === true,
|
|
1213
|
+
blocked: message.blocked === true,
|
|
1214
|
+
counters: {
|
|
1215
|
+
agents: agents
|
|
1216
|
+
? { active: agents.completed, total: agents.total }
|
|
1217
|
+
: { active: 0, total: 0 },
|
|
1218
|
+
...(activeRuns ? { activeRuns } : {}),
|
|
1219
|
+
...(tasks ? { tasks } : {}),
|
|
1220
|
+
...(steps ? { steps } : {}),
|
|
1221
|
+
...(files ? { files } : {}),
|
|
1222
|
+
},
|
|
1223
|
+
progress,
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
export function buildPromptStatusSnapshot(
|
|
1228
|
+
header: HeaderSnapshot | null,
|
|
1229
|
+
branch: string,
|
|
1230
|
+
git: GitStatusSummary,
|
|
1231
|
+
): PromptStatusSnapshot {
|
|
1232
|
+
const signals: PromptStatusSnapshot['signals'] = [];
|
|
1233
|
+
if (header?.blocked) signals.push({ glyph: '', color: 'error' });
|
|
1234
|
+
else if (header?.diagnostics?.state === 'fail') {
|
|
1235
|
+
signals.push({ glyph: '✗', color: 'error' });
|
|
1236
|
+
} else if (header?.diagnostics?.state === 'running') {
|
|
1237
|
+
signals.push({ glyph: '◷', color: 'accent' });
|
|
1238
|
+
} else if (header?.diagnostics?.state === 'pass') {
|
|
1239
|
+
signals.push({ glyph: '✓', color: 'success' });
|
|
1240
|
+
}
|
|
1241
|
+
if (header?.backgroundActivity) signals.push({ glyph: '', color: 'accent' });
|
|
1242
|
+
|
|
1243
|
+
return {
|
|
1244
|
+
protocol: 1,
|
|
1245
|
+
signals,
|
|
1246
|
+
git: { branch, ...git },
|
|
1247
|
+
progress: header?.progress ?? [],
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
export function headerColumnGap(
|
|
1252
|
+
width: number,
|
|
1253
|
+
leftWidth: number,
|
|
1254
|
+
rightWidth: number,
|
|
1255
|
+
): number | null {
|
|
1256
|
+
const gap = Math.floor(width) - leftWidth - rightWidth;
|
|
1257
|
+
return gap >= 1 ? gap : null;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
export function headerColumnLayout(
|
|
1261
|
+
width: number,
|
|
1262
|
+
leftWidth: number,
|
|
1263
|
+
centerWidth: number,
|
|
1264
|
+
rightWidth: number,
|
|
1265
|
+
): { beforeCenter: number; beforeRight: number } | null {
|
|
1266
|
+
const boundedWidth = Math.floor(width);
|
|
1267
|
+
const centerStart = Math.floor((boundedWidth - centerWidth) / 2);
|
|
1268
|
+
const rightStart = boundedWidth - rightWidth;
|
|
1269
|
+
const beforeCenter = centerStart - leftWidth;
|
|
1270
|
+
const beforeRight = rightStart - centerStart - centerWidth;
|
|
1271
|
+
if (beforeCenter < 1 || beforeRight < 1) return null;
|
|
1272
|
+
return { beforeCenter, beforeRight };
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
export function normalizeContextPercent(
|
|
1276
|
+
percent: unknown,
|
|
1277
|
+
tokens: unknown,
|
|
1278
|
+
contextWindow: unknown,
|
|
1279
|
+
): number {
|
|
1280
|
+
if (typeof percent === 'number' && Number.isFinite(percent)) {
|
|
1281
|
+
return Math.max(0, Math.min(100, percent));
|
|
1282
|
+
}
|
|
1283
|
+
if (
|
|
1284
|
+
typeof tokens === 'number' &&
|
|
1285
|
+
Number.isFinite(tokens) &&
|
|
1286
|
+
typeof contextWindow === 'number' &&
|
|
1287
|
+
Number.isFinite(contextWindow) &&
|
|
1288
|
+
contextWindow > 0
|
|
1289
|
+
) {
|
|
1290
|
+
return Math.max(0, Math.min(100, (tokens / contextWindow) * 100));
|
|
1291
|
+
}
|
|
1292
|
+
return 0;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
function formatPercent(percent: number): string {
|
|
1296
|
+
const rounded = Math.round(percent * 10) / 10;
|
|
1297
|
+
return `${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)}%`;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
export function buildContextGauge(
|
|
1301
|
+
percent: number,
|
|
1302
|
+
availableWidth: number,
|
|
1303
|
+
): ContextGauge {
|
|
1304
|
+
const normalized = Math.max(0, Math.min(100, percent));
|
|
1305
|
+
const percentText = formatPercent(normalized);
|
|
1306
|
+
// One cell each for the icon and two separating spaces. On narrow terminals,
|
|
1307
|
+
// reduce the gauge before sacrificing the percentage.
|
|
1308
|
+
const cells = Math.max(
|
|
1309
|
+
0,
|
|
1310
|
+
Math.min(CONTEXT_GAUGE_WIDTH, Math.floor(availableWidth) - percentText.length - 3),
|
|
1311
|
+
);
|
|
1312
|
+
let filledCells = cells === 0 ? 0 : Math.round((normalized / 100) * cells);
|
|
1313
|
+
if (normalized > 0 && filledCells === 0) filledCells = 1;
|
|
1314
|
+
if (normalized < 100 && filledCells === cells && cells > 1) {
|
|
1315
|
+
filledCells = cells - 1;
|
|
1316
|
+
}
|
|
1317
|
+
return {
|
|
1318
|
+
percent: normalized,
|
|
1319
|
+
percentText,
|
|
1320
|
+
filled: '▰'.repeat(filledCells),
|
|
1321
|
+
empty: '▱'.repeat(cells - filledCells),
|
|
1322
|
+
};
|
|
1323
|
+
}
|