@pellux/goodvibes-tui 0.28.0 → 1.0.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/CHANGELOG.md +133 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/core/conversation-line-cache.ts +432 -0
- package/src/core/conversation-rendering.ts +11 -3
- package/src/core/conversation.ts +50 -4
- package/src/input/commands/cost-runtime.ts +49 -0
- package/src/input/commands/operator-runtime.ts +5 -1
- package/src/input/commands.ts +2 -0
- package/src/input/handler-content-actions.ts +8 -3
- package/src/input/handler-feed-routes.ts +12 -35
- package/src/input/handler-feed.ts +4 -8
- package/src/input/handler-shortcuts.ts +51 -0
- package/src/input/handler.ts +43 -6
- package/src/input/input-history.ts +17 -4
- package/src/input/keybindings.ts +48 -8
- package/src/input/panel-integration-actions.ts +107 -1
- package/src/main.ts +19 -11
- package/src/panels/agent-inspector-panel.ts +125 -36
- package/src/panels/agent-inspector-shared.ts +144 -0
- package/src/panels/approval-panel.ts +67 -16
- package/src/panels/automation-control-panel.ts +368 -124
- package/src/panels/base-panel.ts +1 -1
- package/src/panels/builtin/agent.ts +28 -37
- package/src/panels/builtin/development.ts +40 -32
- package/src/panels/builtin/knowledge.ts +15 -6
- package/src/panels/builtin/operations.ts +178 -117
- package/src/panels/builtin/session.ts +35 -9
- package/src/panels/builtin/shared.ts +98 -6
- package/src/panels/cockpit-panel.ts +232 -73
- package/src/panels/communication-panel.ts +58 -20
- package/src/panels/confirm-state.ts +8 -1
- package/src/panels/control-plane-panel.ts +351 -118
- package/src/panels/cost-tracker-panel.ts +165 -7
- package/src/panels/debug-panel.ts +312 -159
- package/src/panels/diff-panel.ts +209 -57
- package/src/panels/docs-panel.ts +152 -66
- package/src/panels/eval-panel.ts +220 -42
- package/src/panels/file-explorer-panel.ts +202 -122
- package/src/panels/file-preview-panel.ts +132 -57
- package/src/panels/git-panel.ts +310 -129
- package/src/panels/hooks-panel.ts +150 -22
- package/src/panels/incident-review-panel.ts +223 -35
- package/src/panels/index.ts +0 -4
- package/src/panels/intelligence-panel.ts +212 -86
- package/src/panels/knowledge-graph-panel.ts +461 -101
- package/src/panels/local-auth-panel.ts +240 -28
- package/src/panels/marketplace-panel.ts +193 -26
- package/src/panels/memory-panel.ts +78 -77
- package/src/panels/ops-control-panel.ts +146 -29
- package/src/panels/ops-strategy-panel.ts +72 -4
- package/src/panels/orchestration-panel.ts +231 -69
- package/src/panels/panel-list-panel.ts +154 -131
- package/src/panels/panel-manager.ts +97 -9
- package/src/panels/plan-dashboard-panel.ts +333 -17
- package/src/panels/plugins-panel.ts +184 -29
- package/src/panels/policy-panel.ts +210 -38
- package/src/panels/polish-core.ts +7 -2
- package/src/panels/polish.ts +23 -4
- package/src/panels/project-planning-answer-actions.ts +134 -0
- package/src/panels/project-planning-panel.ts +62 -113
- package/src/panels/provider-health-panel.ts +434 -518
- package/src/panels/provider-health-routes.ts +203 -0
- package/src/panels/provider-health-tracker.ts +194 -6
- package/src/panels/provider-health-views.ts +560 -0
- package/src/panels/qr-panel.ts +116 -30
- package/src/panels/remote-panel.ts +114 -36
- package/src/panels/routes-panel.ts +63 -22
- package/src/panels/sandbox-panel.ts +174 -42
- package/src/panels/scrollable-list-panel.ts +19 -135
- package/src/panels/security-panel.ts +133 -33
- package/src/panels/services-panel.ts +116 -64
- package/src/panels/session-browser-panel.ts +73 -2
- package/src/panels/session-maintenance.ts +4 -122
- package/src/panels/settings-sync-panel.ts +335 -72
- package/src/panels/skills-panel.ts +157 -89
- package/src/panels/subscription-panel.ts +86 -33
- package/src/panels/symbol-outline-panel.ts +248 -108
- package/src/panels/system-messages-panel.ts +114 -13
- package/src/panels/tasks-panel.ts +326 -139
- package/src/panels/thinking-panel.ts +43 -10
- package/src/panels/token-budget-panel.ts +194 -18
- package/src/panels/tool-inspector-panel.ts +144 -34
- package/src/panels/types.ts +40 -4
- package/src/panels/work-plan-panel.ts +280 -17
- package/src/panels/worktree-panel.ts +175 -42
- package/src/panels/wrfc-panel.ts +116 -24
- package/src/renderer/agent-detail-modal.ts +37 -8
- package/src/renderer/code-block.ts +58 -28
- package/src/renderer/context-inspector.ts +3 -6
- package/src/renderer/conversation-overlays.ts +25 -11
- package/src/renderer/conversation-surface.ts +1 -21
- package/src/renderer/diff-view.ts +6 -4
- package/src/renderer/footer-tips.ts +41 -0
- package/src/renderer/fullscreen-primitives.ts +23 -17
- package/src/renderer/fullscreen-workspace.ts +2 -7
- package/src/renderer/help-overlay.ts +91 -14
- package/src/renderer/hint-grammar.ts +52 -0
- package/src/renderer/history-search-overlay.ts +10 -16
- package/src/renderer/layout.ts +7 -7
- package/src/renderer/markdown.ts +9 -1
- package/src/renderer/modal-factory.ts +23 -15
- package/src/renderer/model-picker-overlay.ts +8 -485
- package/src/renderer/model-workspace.ts +9 -24
- package/src/renderer/overlay-box.ts +23 -19
- package/src/renderer/process-indicator.ts +15 -25
- package/src/renderer/profile-picker-modal.ts +13 -14
- package/src/renderer/prompt-content-width.ts +16 -0
- package/src/renderer/search-overlay.ts +27 -6
- package/src/renderer/selection-modal-overlay.ts +3 -1
- package/src/renderer/semantic-diff.ts +1 -1
- package/src/renderer/session-picker-modal.ts +6 -4
- package/src/renderer/settings-modal-helpers.ts +10 -34
- package/src/renderer/settings-modal.ts +70 -10
- package/src/renderer/shell-surface.ts +54 -15
- package/src/renderer/status-glyphs.ts +11 -9
- package/src/renderer/surface-layout.ts +0 -12
- package/src/renderer/term-caps.ts +1 -1
- package/src/renderer/theme.ts +60 -3
- package/src/renderer/tool-call.ts +6 -20
- package/src/renderer/ui-factory.ts +48 -37
- package/src/renderer/ui-primitives.ts +41 -3
- package/src/runtime/bootstrap-shell.ts +35 -18
- package/src/runtime/diagnostics/panels/index.ts +0 -3
- package/src/runtime/render-scheduler.ts +80 -0
- package/src/shell/ui-openers.ts +14 -22
- package/src/utils/format-duration.ts +2 -2
- package/src/utils/splash-lines.ts +54 -5
- package/src/version.ts +1 -1
- package/src/work-plans/work-plan-store.ts +13 -0
- package/src/panels/agent-logs-panel.ts +0 -635
- package/src/panels/agent-logs-shared.ts +0 -129
- package/src/panels/context-visualizer-panel.ts +0 -238
- package/src/panels/forensics-panel.ts +0 -378
- package/src/panels/provider-account-snapshot.ts +0 -259
- package/src/panels/provider-accounts-panel.ts +0 -255
- package/src/panels/provider-stats-panel.ts +0 -391
- package/src/panels/schedule-panel.ts +0 -365
- package/src/panels/watchers-panel.ts +0 -213
- package/src/renderer/file-tree.ts +0 -153
- package/src/renderer/progress.ts +0 -99
- package/src/renderer/status-token.ts +0 -67
- package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
- package/src/runtime/diagnostics/panels/policy.ts +0 -177
|
@@ -4,6 +4,7 @@ import { getDisplayWidth, truncateDisplay } from '../utils/terminal-width.ts';
|
|
|
4
4
|
import type { ToolCall } from '@pellux/goodvibes-sdk/platform/types';
|
|
5
5
|
import { stripDangerousAnsi } from './ansi-sanitize.ts';
|
|
6
6
|
import { formatElapsed } from '../utils/format-elapsed.ts';
|
|
7
|
+
import { UI_TONES } from './ui-primitives.ts';
|
|
7
8
|
|
|
8
9
|
const TOOL_NAME_MIN_WIDTH = 8;
|
|
9
10
|
const TOOL_NAME_MAX_WIDTH = 20;
|
|
@@ -73,14 +74,14 @@ function buildLeftSegments(
|
|
|
73
74
|
segments.push({ text: toolNameDisplay, fg: '#00ffcc', bold: true });
|
|
74
75
|
}
|
|
75
76
|
if (keyArgDisplay) {
|
|
76
|
-
segments.push({ text: ' ', fg:
|
|
77
|
+
segments.push({ text: ' ', fg: UI_TONES.fg.primary });
|
|
77
78
|
segments.push({ text: keyArgDisplay, fg: '252' });
|
|
78
79
|
}
|
|
79
80
|
if (suffixDisplay) {
|
|
80
|
-
segments.push({ text: ' ', fg:
|
|
81
|
+
segments.push({ text: ' ', fg: UI_TONES.fg.primary });
|
|
81
82
|
segments.push({
|
|
82
83
|
text: suffixDisplay,
|
|
83
|
-
fg: suffixText.startsWith('- ') ?
|
|
84
|
+
fg: suffixText.startsWith('- ') ? UI_TONES.state.bad : '244',
|
|
84
85
|
dim: true,
|
|
85
86
|
});
|
|
86
87
|
}
|
|
@@ -171,8 +172,8 @@ export function renderToolCallBlock(
|
|
|
171
172
|
const icon = status === 'done' ? TOOL_STATUS.SUCCESS_ICON
|
|
172
173
|
: status === 'error' ? TOOL_STATUS.FAIL_ICON
|
|
173
174
|
: TOOL_STATUS.SPINNER_FRAMES[(frameIndex ?? 0) % TOOL_STATUS.SPINNER_FRAMES.length];
|
|
174
|
-
const iconColor = status === 'done' ?
|
|
175
|
-
: status === 'error' ?
|
|
175
|
+
const iconColor = status === 'done' ? UI_TONES.state.good
|
|
176
|
+
: status === 'error' ? UI_TONES.state.bad
|
|
176
177
|
: '244';
|
|
177
178
|
const rightText = (() => {
|
|
178
179
|
if (durationMs !== undefined && status === 'done') {
|
|
@@ -227,18 +228,3 @@ export function renderToolCallBlock(
|
|
|
227
228
|
|
|
228
229
|
return [line];
|
|
229
230
|
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* Render a list of tool calls. All calls are treated as completed (done).
|
|
233
|
-
* Used for historical message rendering where status/timing is unavailable.
|
|
234
|
-
*/
|
|
235
|
-
export function renderToolCallList(
|
|
236
|
-
toolCalls: ToolCall[],
|
|
237
|
-
width: number,
|
|
238
|
-
): Line[] {
|
|
239
|
-
const lines: Line[] = [];
|
|
240
|
-
for (const tc of toolCalls) {
|
|
241
|
-
lines.push(...renderToolCallBlock(tc, 'done', undefined, width));
|
|
242
|
-
}
|
|
243
|
-
return lines;
|
|
244
|
-
}
|
|
@@ -4,10 +4,12 @@ import { VERSION } from '../version.ts';
|
|
|
4
4
|
import { fitDisplay, getDisplayWidth, truncateDisplay, wrapText, interpolateColor } from '../utils/terminal-width.ts';
|
|
5
5
|
import type { GitHeaderInfo } from './git-status.ts';
|
|
6
6
|
import { renderConversationFragment, renderConversationStatusLine, type ConversationStatusSegment } from './conversation-surface.ts';
|
|
7
|
-
import { GLYPHS } from './ui-primitives.ts';
|
|
7
|
+
import { GLYPHS, UI_TONES } from './ui-primitives.ts';
|
|
8
8
|
import { formatElapsed } from '../utils/format-elapsed.ts';
|
|
9
9
|
import { abbreviateCount } from '../utils/format-number.ts';
|
|
10
10
|
import { computeContextUsage } from '../core/context-usage.ts';
|
|
11
|
+
import { calcSessionCost } from '../export/cost-utils.ts';
|
|
12
|
+
import { buildFooterTip, isAgentActive } from './footer-tips.ts';
|
|
11
13
|
|
|
12
14
|
/** Number of frames before the animated gradient completes one full cycle. */
|
|
13
15
|
const GRADIENT_CYCLE_FRAMES = 50;
|
|
@@ -35,15 +37,23 @@ function fmtNum(n: number): string {
|
|
|
35
37
|
return abbreviateCount(n, { bSuffix: true });
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
/** Format a running USD cost estimate with a precision that suits its magnitude. */
|
|
41
|
+
function fmtCost(usd: number): string {
|
|
42
|
+
if (!(usd > 0)) return '0.00';
|
|
43
|
+
if (usd < 0.01) return usd.toFixed(4);
|
|
44
|
+
if (usd < 1) return usd.toFixed(3);
|
|
45
|
+
return usd.toFixed(2);
|
|
46
|
+
}
|
|
47
|
+
|
|
38
48
|
/**
|
|
39
49
|
* UIFactory - Generates standard UI fragments without needing Ink/React overhead.
|
|
40
50
|
*/
|
|
41
51
|
export class UIFactory {
|
|
42
52
|
public static createHeader(width: number, model: string, provider: string, title?: string, gitInfo?: GitHeaderInfo): Line[] {
|
|
43
53
|
const lines: Line[] = [];
|
|
44
|
-
const CYAN =
|
|
45
|
-
const GREY =
|
|
46
|
-
const TITLE_COLOR =
|
|
54
|
+
const CYAN = UI_TONES.accent.brand;
|
|
55
|
+
const GREY = UI_TONES.fg.dim;
|
|
56
|
+
const TITLE_COLOR = UI_TONES.fg.muted;
|
|
47
57
|
const brand = ` GoodVibes `;
|
|
48
58
|
const ver = `v${VERSION} `;
|
|
49
59
|
const stats = ` ${model} `;
|
|
@@ -77,11 +87,11 @@ export class UIFactory {
|
|
|
77
87
|
}
|
|
78
88
|
// Build git info segment
|
|
79
89
|
let gitStr = '';
|
|
80
|
-
let gitFg =
|
|
90
|
+
let gitFg: string = UI_TONES.fg.dim;
|
|
81
91
|
if (gitInfo) {
|
|
82
92
|
gitStr = buildGitSegment(gitInfo).text;
|
|
83
93
|
if (gitInfo.dirty || gitInfo.ahead > 0 || gitInfo.behind > 0) {
|
|
84
|
-
gitFg =
|
|
94
|
+
gitFg = UI_TONES.state.warn; // yellow when dirty or out-of-sync
|
|
85
95
|
}
|
|
86
96
|
}
|
|
87
97
|
const rightSideText = stats + prov;
|
|
@@ -91,7 +101,7 @@ export class UIFactory {
|
|
|
91
101
|
for (const char of stats) { if (rightX < width) line[rightX++] = { char, fg: CYAN, bg: '', bold: true, dim: false, underline: false, italic: false, strikethrough: false }; }
|
|
92
102
|
for (const char of prov) { if (rightX < width) line[rightX++] = { char, fg: GREY, bg: '', bold: false, dim: true, underline: false, italic: false, strikethrough: false }; }
|
|
93
103
|
lines.push(line);
|
|
94
|
-
lines.push(this.stringToLine('━'.repeat(width), width, { fg:
|
|
104
|
+
lines.push(this.stringToLine('━'.repeat(width), width, { fg: UI_TONES.fg.dim }));
|
|
95
105
|
return lines;
|
|
96
106
|
}
|
|
97
107
|
|
|
@@ -101,12 +111,12 @@ export class UIFactory {
|
|
|
101
111
|
*/
|
|
102
112
|
public static createMessageBar(
|
|
103
113
|
width: number, text: string,
|
|
104
|
-
bgColor = '#2a2a2a', textColor =
|
|
114
|
+
bgColor: string = '#2a2a2a', textColor: string = UI_TONES.fg.secondary, prefixStr: string = ' › ',
|
|
105
115
|
strikethrough = false
|
|
106
116
|
): Line[] {
|
|
107
117
|
return renderConversationFragment(text, width, {
|
|
108
118
|
prefix: prefixStr,
|
|
109
|
-
prefixFg:
|
|
119
|
+
prefixFg: UI_TONES.state.reasoning,
|
|
110
120
|
text: textColor,
|
|
111
121
|
bodyBg: bgColor,
|
|
112
122
|
strikethrough,
|
|
@@ -119,8 +129,8 @@ export class UIFactory {
|
|
|
119
129
|
public static createQueuedMessageFragment(width: number, text: string): Line[] {
|
|
120
130
|
return renderConversationFragment(text, width, {
|
|
121
131
|
prefix: ' (...) ',
|
|
122
|
-
prefixFg:
|
|
123
|
-
text:
|
|
132
|
+
prefixFg: UI_TONES.state.reasoning,
|
|
133
|
+
text: UI_TONES.fg.dim,
|
|
124
134
|
bodyBg: '#1a1a1a',
|
|
125
135
|
dim: true,
|
|
126
136
|
});
|
|
@@ -148,6 +158,7 @@ export class UIFactory {
|
|
|
148
158
|
composerStatus?: string,
|
|
149
159
|
composerFlags?: readonly string[],
|
|
150
160
|
composerPendingRisk?: 'none' | 'approval-wait' | 'shell' | 'command' | 'remote',
|
|
161
|
+
compact: boolean = false,
|
|
151
162
|
): Line[] {
|
|
152
163
|
const lines: Line[] = [];
|
|
153
164
|
const promptLines = prompt.split('\n');
|
|
@@ -253,22 +264,24 @@ export class UIFactory {
|
|
|
253
264
|
}
|
|
254
265
|
}
|
|
255
266
|
lines.push(bottomLine);
|
|
256
|
-
|
|
267
|
+
// --- Composer posture block (mode / risk / status / flags) ------------
|
|
268
|
+
// Suppressed in compact mode; the ctx-info line no longer duplicates these
|
|
269
|
+
// tokens, so this block is the single home for mode/status/flags.
|
|
257
270
|
const composerTokens: Array<{ text: string; fg: string; bold?: boolean; dim?: boolean }> = [];
|
|
258
|
-
if (composerMode) composerTokens.push({ text: ` ${GLYPHS.status.active} ${composerMode} `, fg:
|
|
271
|
+
if (composerMode) composerTokens.push({ text: ` ${GLYPHS.status.active} ${composerMode} `, fg: UI_TONES.state.info, bold: true });
|
|
259
272
|
if (composerPendingRisk && composerPendingRisk !== 'none') {
|
|
260
273
|
const riskColor = composerPendingRisk === 'approval-wait'
|
|
261
|
-
?
|
|
274
|
+
? UI_TONES.state.warn
|
|
262
275
|
: composerPendingRisk === 'shell'
|
|
263
|
-
?
|
|
276
|
+
? UI_TONES.state.bad
|
|
264
277
|
: composerPendingRisk === 'remote'
|
|
265
278
|
? '#a78bfa'
|
|
266
|
-
:
|
|
279
|
+
: UI_TONES.state.warn;
|
|
267
280
|
composerTokens.push({ text: ` risk:${composerPendingRisk} `, fg: riskColor, bold: true });
|
|
268
281
|
}
|
|
269
282
|
if (composerStatus && composerStatus !== 'idle') composerTokens.push({ text: ` state:${composerStatus} `, fg: '244', dim: true });
|
|
270
283
|
if (composerFlags && composerFlags.length > 0) composerTokens.push({ text: ` flags:${composerFlags.join(',')} `, fg: '244', dim: true });
|
|
271
|
-
if (composerTokens.length > 0) {
|
|
284
|
+
if (!compact && composerTokens.length > 0) {
|
|
272
285
|
const postureLine = createBaseLine();
|
|
273
286
|
let px = 2;
|
|
274
287
|
for (const token of composerTokens) {
|
|
@@ -289,10 +302,10 @@ export class UIFactory {
|
|
|
289
302
|
if (px >= width) break;
|
|
290
303
|
}
|
|
291
304
|
lines.push(postureLine);
|
|
292
|
-
lines.push(createBaseLine());
|
|
293
305
|
}
|
|
294
306
|
const isRecentlyCopied = Date.now() - lastCopyTime < 2000;
|
|
295
|
-
// Token usage line
|
|
307
|
+
// Token usage line + running ~$ cost estimate (derived from the usage
|
|
308
|
+
// object and the active model via cost-utils).
|
|
296
309
|
const u = usage as { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; up?: number; down?: number };
|
|
297
310
|
const inp = u.input ?? u.up ?? 0;
|
|
298
311
|
const out = u.output ?? u.down ?? 0;
|
|
@@ -300,12 +313,13 @@ export class UIFactory {
|
|
|
300
313
|
const cw = u.cacheWrite ?? 0;
|
|
301
314
|
const total = inp + out + cr + cw;
|
|
302
315
|
const tokenSep = ` ${GLYPHS.navigation.pipeSeparator} `;
|
|
303
|
-
const
|
|
316
|
+
const costSegment = model ? `${tokenSep}~$${fmtCost(calcSessionCost(inp, out, cr, cw, model))}` : '';
|
|
317
|
+
const tokenLine = ` Token Usage [ Input: ${fmtNum(inp)}${tokenSep}Output: ${fmtNum(out)}${tokenSep}Cache Read: ${fmtNum(cr)}${tokenSep}Cache Write: ${fmtNum(cw)}${tokenSep}Total: ${fmtNum(total)}${costSegment} ]`;
|
|
304
318
|
const copiedNotice = isRecentlyCopied ? ` [COPIED] ` : '';
|
|
305
319
|
const statsLine = ' ' + tokenLine + ' '.repeat(Math.max(0, width - 4 - getDisplayWidth(tokenLine) - getDisplayWidth(copiedNotice))) + copiedNotice;
|
|
306
320
|
lines.push(this.stringToLine(statsLine, width, { fg: isRecentlyCopied ? '81' : '244', bold: isRecentlyCopied }));
|
|
307
|
-
// Context usage progress bar
|
|
308
|
-
if (contextWindow && contextWindow > 0) {
|
|
321
|
+
// Context usage progress bar — suppressed in compact mode.
|
|
322
|
+
if (!compact && contextWindow && contextWindow > 0) {
|
|
309
323
|
const ctxTokens = lastInputTokens ?? 0;
|
|
310
324
|
const label = ' Context Usage: ';
|
|
311
325
|
const suffix = ` [ ${fmtNum(ctxTokens)} / ${fmtNum(contextWindow)} ]`;
|
|
@@ -315,11 +329,12 @@ export class UIFactory {
|
|
|
315
329
|
const thresholdFraction = (compactThreshold !== undefined && compactThreshold > 0)
|
|
316
330
|
? Math.min(1, compactThreshold)
|
|
317
331
|
: undefined;
|
|
318
|
-
lines.push(createBaseLine());
|
|
319
332
|
lines.push(this.createProgressBarLine(label, ctxPct, barWidth, width, suffix, thresholdFraction));
|
|
320
333
|
}
|
|
321
|
-
// Context info line (working dir, model+provider, tools)
|
|
322
|
-
|
|
334
|
+
// Context info line (working dir, model+provider, tools, hitl).
|
|
335
|
+
// Suppressed in compact mode. mode/status/flags are intentionally omitted —
|
|
336
|
+
// the posture block above owns them, so they are not duplicated here.
|
|
337
|
+
if (!compact && (workingDir || model)) {
|
|
323
338
|
const home = typeof process !== 'undefined' ? process.env.HOME ?? '' : '';
|
|
324
339
|
const displayDir = workingDir && home && workingDir.startsWith(home)
|
|
325
340
|
? '~' + workingDir.slice(home.length)
|
|
@@ -331,19 +346,16 @@ export class UIFactory {
|
|
|
331
346
|
}
|
|
332
347
|
if (toolCount) ctxParts.push(`${toolCount} tools`);
|
|
333
348
|
if (hitlMode) ctxParts.push(`hitl:${hitlMode}`);
|
|
334
|
-
if (composerMode) ctxParts.push(`mode:${composerMode}`);
|
|
335
|
-
if (composerStatus && composerStatus !== 'idle') ctxParts.push(`status:${composerStatus}`);
|
|
336
|
-
if (composerFlags && composerFlags.length > 0) ctxParts.push(composerFlags.join(','));
|
|
337
349
|
const ctxLine = ' ' + ctxParts.join(` ${GLYPHS.navigation.pipeSeparator} `);
|
|
338
|
-
lines.push(createBaseLine());
|
|
339
350
|
lines.push(this.stringToLine(truncateDisplay(ctxLine, width), width, { fg: '240', dim: true }));
|
|
340
|
-
lines.push(createBaseLine());
|
|
341
351
|
}
|
|
342
352
|
if (showExitNotice) {
|
|
343
353
|
const notice = ` !!! Press Ctrl+C again to exit !!! `;
|
|
344
354
|
lines.push(this.stringToLine(fitDisplay(notice, width), width, { fg: '196', bold: true }));
|
|
345
355
|
} else {
|
|
346
|
-
|
|
356
|
+
// Persistent discoverability tip. Rotates by context (agent-aware): the
|
|
357
|
+
// process-monitor tip leads while a turn is in flight. See footer-tips.ts.
|
|
358
|
+
const help = ` ${buildFooterTip({ agentActive: isAgentActive(composerStatus) })} `;
|
|
347
359
|
const dangerWarn = dangerMode ? `! DANGER MODE - ALL CHANGES AUTO-APPROVED ` : '';
|
|
348
360
|
const helpW = getDisplayWidth(help);
|
|
349
361
|
const dangerW = getDisplayWidth(dangerWarn);
|
|
@@ -356,14 +368,13 @@ export class UIFactory {
|
|
|
356
368
|
for (const ch of dangerWarn) {
|
|
357
369
|
if (col >= width) break;
|
|
358
370
|
const cw = getDisplayWidth(ch);
|
|
359
|
-
line[col] = { char: ch, fg:
|
|
371
|
+
line[col] = { char: ch, fg: UI_TONES.state.bad, bg: '', bold: true, dim: false, underline: false, italic: false, strikethrough: false };
|
|
360
372
|
if (cw === 2 && col + 1 < width) line[col + 1] = { ...line[col], char: '' };
|
|
361
373
|
col += cw;
|
|
362
374
|
}
|
|
363
375
|
}
|
|
364
376
|
lines.push(line);
|
|
365
377
|
}
|
|
366
|
-
lines.push(createBaseLine());
|
|
367
378
|
return lines;
|
|
368
379
|
}
|
|
369
380
|
|
|
@@ -392,8 +403,8 @@ export class UIFactory {
|
|
|
392
403
|
];
|
|
393
404
|
|
|
394
405
|
/** Gradient colors for thinking text — cyan to purple (matches splash). */
|
|
395
|
-
private static readonly THINK_GRADIENT_START =
|
|
396
|
-
private static readonly THINK_GRADIENT_END =
|
|
406
|
+
private static readonly THINK_GRADIENT_START = UI_TONES.accent.gradientStart;
|
|
407
|
+
private static readonly THINK_GRADIENT_END = UI_TONES.accent.gradientEnd;
|
|
397
408
|
|
|
398
409
|
public static createThinkingFragment(width: number, spinner: string, frame: number = 0, tokenSpeed?: number, toolPreview?: string, inputTokens?: number, outputTokens?: number, elapsedMs?: number, ttftMs?: number): Line[] {
|
|
399
410
|
// Rotate phrase every ~30 seconds (frame ticks at 80ms, so ~375 frames)
|
|
@@ -419,7 +430,7 @@ export class UIFactory {
|
|
|
419
430
|
const inTok = inputTokens ?? 0;
|
|
420
431
|
const outTok = outputTokens ?? 0;
|
|
421
432
|
segments.push({ text: ` in ${fmtNum(inTok)} `, fg: '243', dim: true });
|
|
422
|
-
segments.push({ text: `out ${fmtNum(outTok)}`, fg:
|
|
433
|
+
segments.push({ text: `out ${fmtNum(outTok)}`, fg: UI_TONES.accent.brand });
|
|
423
434
|
}
|
|
424
435
|
const line = createEmptyLine(width);
|
|
425
436
|
let col = 1;
|
|
@@ -459,7 +470,7 @@ export class UIFactory {
|
|
|
459
470
|
if (px >= width) break;
|
|
460
471
|
previewLine[px] = {
|
|
461
472
|
char: ch,
|
|
462
|
-
fg:
|
|
473
|
+
fg: UI_TONES.state.info,
|
|
463
474
|
bg: '',
|
|
464
475
|
bold: true,
|
|
465
476
|
dim: false,
|
|
@@ -35,8 +35,9 @@ export const GLYPHS = {
|
|
|
35
35
|
failure: '✕',
|
|
36
36
|
pending: '•',
|
|
37
37
|
active: '●',
|
|
38
|
-
idle: '
|
|
39
|
-
info: '
|
|
38
|
+
idle: '◌',
|
|
39
|
+
info: '○',
|
|
40
|
+
warn: '⚠',
|
|
40
41
|
blocked: '⊘',
|
|
41
42
|
skipped: '◇',
|
|
42
43
|
review: '◈',
|
|
@@ -56,6 +57,11 @@ export const GLYPHS = {
|
|
|
56
57
|
},
|
|
57
58
|
} as const;
|
|
58
59
|
|
|
60
|
+
/**
|
|
61
|
+
* UI_TONES — the single chrome/color-token source for src/renderer and
|
|
62
|
+
* src/panels. Concrete (dark-mode) values; resolveUiTones(mode) in theme.ts
|
|
63
|
+
* is the single mode-resolved read path — this object is the 'dark' entry.
|
|
64
|
+
*/
|
|
59
65
|
export const UI_TONES = {
|
|
60
66
|
fg: {
|
|
61
67
|
primary: '#e2e8f0',
|
|
@@ -63,6 +69,8 @@ export const UI_TONES = {
|
|
|
63
69
|
muted: '#94a3b8',
|
|
64
70
|
dim: '#475569',
|
|
65
71
|
inverse: '#0f172a',
|
|
72
|
+
/** Empty-state / placeholder foreground (formerly DEFAULT_PANEL_PALETTE.empty literal). */
|
|
73
|
+
empty: '#334155',
|
|
66
74
|
},
|
|
67
75
|
bg: {
|
|
68
76
|
base: '#11131a',
|
|
@@ -75,6 +83,8 @@ export const UI_TONES = {
|
|
|
75
83
|
warning: '#2b2116',
|
|
76
84
|
error: '#2a161b',
|
|
77
85
|
success: '#14241b',
|
|
86
|
+
/** Fullscreen/shell footer background. */
|
|
87
|
+
footer: '#111827',
|
|
78
88
|
},
|
|
79
89
|
state: {
|
|
80
90
|
info: '#38bdf8',
|
|
@@ -83,6 +93,8 @@ export const UI_TONES = {
|
|
|
83
93
|
bad: '#ef4444',
|
|
84
94
|
blocked: '#f97316',
|
|
85
95
|
active: '#60a5fa',
|
|
96
|
+
/** Single canonical reasoning/thinking purple (replaces #9945FF and ad-hoc purples). */
|
|
97
|
+
reasoning: '#a855f7',
|
|
86
98
|
},
|
|
87
99
|
accent: {
|
|
88
100
|
browser: '#7dd3fc',
|
|
@@ -90,7 +102,33 @@ export const UI_TONES = {
|
|
|
90
102
|
inspector: '#c4b5fd',
|
|
91
103
|
workflow: '#fbbf24',
|
|
92
104
|
conversation: '#93c5fd',
|
|
105
|
+
/** Neon brand accent — header/splash/thinking gradient only. */
|
|
106
|
+
brand: '#00ffff',
|
|
107
|
+
gradientStart: '#00ffff',
|
|
108
|
+
gradientEnd: '#d000ff',
|
|
93
109
|
},
|
|
110
|
+
/** Canonical border stroke color for fullscreen/panel chrome. */
|
|
111
|
+
border: '#64748b',
|
|
94
112
|
} as const;
|
|
95
113
|
|
|
96
|
-
|
|
114
|
+
/**
|
|
115
|
+
* Diff surface accent color shared across the three diff-rendering surfaces —
|
|
116
|
+
* the conversation diff view (diff-view.ts), the file diff panel
|
|
117
|
+
* (diff-panel.ts), and the git panel's inline diff (git-panel.ts). Hunk-header
|
|
118
|
+
* blue has no matching UI_TONES.state/accent role, so WO-204 gives it one
|
|
119
|
+
* named token here (value converged on diff-panel.ts's pre-existing hunk
|
|
120
|
+
* color, the only one of the three surfaces that predates this token; add/del
|
|
121
|
+
* on all three surfaces already converge on UI_TONES.state.good/bad).
|
|
122
|
+
*/
|
|
123
|
+
export const DIFF_TONES = {
|
|
124
|
+
// add/del carry diff-panel.ts's shipped colors: the diff panel is the only
|
|
125
|
+
// diff surface users have ever seen (diff-view.ts was unwired from v0.9.6
|
|
126
|
+
// until WO-204), so its look is the reference — the conversation surface
|
|
127
|
+
// adopts it, never the other way around.
|
|
128
|
+
add: '#00ff88',
|
|
129
|
+
del: '#ff4444',
|
|
130
|
+
hunk: '#88aaff',
|
|
131
|
+
} as const;
|
|
132
|
+
|
|
133
|
+
/** Single spinner-frame source — layout.ts and progress.ts both re-export this. */
|
|
134
|
+
export const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const;
|
|
@@ -109,12 +109,41 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
|
|
|
109
109
|
homeDirectory: services.homeDirectory,
|
|
110
110
|
});
|
|
111
111
|
|
|
112
|
+
const foundationClients = createRuntimeFoundationClients({
|
|
113
|
+
runtimeServices: services,
|
|
114
|
+
tasksReadModel: uiServices.readModels.tasks,
|
|
115
|
+
taskManager,
|
|
116
|
+
opsControlPlane,
|
|
117
|
+
});
|
|
118
|
+
const {
|
|
119
|
+
directTransport,
|
|
120
|
+
hookApi,
|
|
121
|
+
knowledgeApi,
|
|
122
|
+
mcpApi,
|
|
123
|
+
opsApi,
|
|
124
|
+
providerApi,
|
|
125
|
+
} = foundationClients;
|
|
126
|
+
const planRuntime = createShellPlanRuntime({
|
|
127
|
+
adaptivePlanner: services.adaptivePlanner,
|
|
128
|
+
runtimeBus,
|
|
129
|
+
});
|
|
130
|
+
|
|
112
131
|
const openAgentDetailRef: { fn: (agentId: string) => void } = { fn: (_agentId: string) => {} };
|
|
113
132
|
|
|
133
|
+
// WO-139: initial cost-budget alert threshold (USD; 0/unset = disabled).
|
|
134
|
+
// Once the session starts, the real control surface is the CostTrackerPanel
|
|
135
|
+
// itself — the in-panel 'b' key and /cost budget <usd> both call
|
|
136
|
+
// CostTrackerPanel.setBudgetThreshold() directly on the live panel instance.
|
|
137
|
+
const parsedBudgetThreshold = Number(process.env.GOODVIBES_COST_BUDGET_USD);
|
|
138
|
+
const initialCostBudgetThreshold = Number.isFinite(parsedBudgetThreshold) && parsedBudgetThreshold > 0
|
|
139
|
+
? parsedBudgetThreshold
|
|
140
|
+
: 0;
|
|
141
|
+
|
|
114
142
|
let commandContextRef: CommandContext | null = null;
|
|
115
143
|
registerBuiltinPanels(services.panelManager, {
|
|
116
144
|
configManager,
|
|
117
145
|
getOrchestratorUsage: () => orchestrator.usage as { input: number; output: number; cacheRead: number; cacheWrite: number; model?: string },
|
|
146
|
+
budgetThreshold: initialCostBudgetThreshold,
|
|
118
147
|
toolRegistry,
|
|
119
148
|
providerRegistry: services.providerRegistry,
|
|
120
149
|
contextWindow: services.providerRegistry.getContextWindowForModel(services.providerRegistry.getCurrentModel()),
|
|
@@ -153,6 +182,12 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
|
|
|
153
182
|
mcpRegistry: services.mcpRegistry,
|
|
154
183
|
openAgentDetail: (agentId: string) => openAgentDetailRef.fn(agentId),
|
|
155
184
|
daemonHomeDir: join(services.homeDirectory, '.goodvibes', 'daemon'),
|
|
185
|
+
opsApi,
|
|
186
|
+
planRuntime,
|
|
187
|
+
watcherRegistry: services.watcherRegistry,
|
|
188
|
+
runtimeStore,
|
|
189
|
+
openPanel: (panelId: string) => { services.panelManager.open(panelId); },
|
|
190
|
+
knowledgeApi,
|
|
156
191
|
});
|
|
157
192
|
services.panelManager.prewarmRegistered();
|
|
158
193
|
|
|
@@ -170,29 +205,11 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
|
|
|
170
205
|
|
|
171
206
|
const commandRegistry = new CommandRegistry();
|
|
172
207
|
registerBuiltinCommands(commandRegistry);
|
|
173
|
-
const foundationClients = createRuntimeFoundationClients({
|
|
174
|
-
runtimeServices: services,
|
|
175
|
-
tasksReadModel: uiServices.readModels.tasks,
|
|
176
|
-
taskManager,
|
|
177
|
-
opsControlPlane,
|
|
178
|
-
});
|
|
179
|
-
const {
|
|
180
|
-
directTransport,
|
|
181
|
-
hookApi,
|
|
182
|
-
knowledgeApi,
|
|
183
|
-
mcpApi,
|
|
184
|
-
opsApi,
|
|
185
|
-
providerApi,
|
|
186
|
-
} = foundationClients;
|
|
187
208
|
const remoteRuntime = createShellRemoteCommandService({
|
|
188
209
|
readModels: uiServices.readModels,
|
|
189
210
|
remoteRunnerRegistry: services.remoteRunnerRegistry,
|
|
190
211
|
runtimeStore,
|
|
191
212
|
});
|
|
192
|
-
const planRuntime = createShellPlanRuntime({
|
|
193
|
-
adaptivePlanner: services.adaptivePlanner,
|
|
194
|
-
runtimeBus,
|
|
195
|
-
});
|
|
196
213
|
|
|
197
214
|
const commandContext: CommandContext = createBootstrapCommandContext({
|
|
198
215
|
configManager,
|
|
@@ -12,13 +12,10 @@ export type { InspectableDomain } from '@/runtime/index.ts';
|
|
|
12
12
|
export { HealthPanel } from '@/runtime/index.ts';
|
|
13
13
|
export { DivergencePanel } from '@/runtime/index.ts';
|
|
14
14
|
export { ReplayPanel } from '@/runtime/index.ts';
|
|
15
|
-
export { PolicyPanel } from './policy.ts';
|
|
16
|
-
export type { PolicyPanelSnapshot } from './policy.ts';
|
|
17
15
|
export { ToolContractsPanel } from '@/runtime/index.ts';
|
|
18
16
|
export { TransportPanel } from '@/runtime/index.ts';
|
|
19
17
|
export type { TransportPanelSnapshot } from '@/runtime/index.ts';
|
|
20
18
|
export { OpsPanel } from './ops.ts';
|
|
21
19
|
export type { OpsAuditEntry } from './ops.ts';
|
|
22
|
-
export { PanelResourcesPanel } from './panel-resources.ts';
|
|
23
20
|
export { SecurityPanel } from '@/runtime/index.ts';
|
|
24
21
|
export type { SecurityPanelSnapshot } from '@/runtime/index.ts';
|
|
@@ -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
|
+
}
|
package/src/shell/ui-openers.ts
CHANGED
|
@@ -253,6 +253,7 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
|
|
|
253
253
|
if (!input.helpOverlayActive) input.modalOpened('help');
|
|
254
254
|
input.helpOverlayActive = !input.helpOverlayActive;
|
|
255
255
|
input.helpScrollOffset = 0;
|
|
256
|
+
render();
|
|
256
257
|
};
|
|
257
258
|
|
|
258
259
|
commandContext.openShortcutsOverlay = () => {
|
|
@@ -292,19 +293,16 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
|
|
|
292
293
|
};
|
|
293
294
|
|
|
294
295
|
commandContext.openPanelPicker = () => {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
panelManager.show();
|
|
304
|
-
input.panelFocused = true;
|
|
305
|
-
conversation.setSplashSuppressed(true);
|
|
296
|
+
// Focus ownership lives in PanelManager (focusTarget); read it there rather
|
|
297
|
+
// than tracking a parallel input.panelFocused flag. Toggle semantics: if the
|
|
298
|
+
// workspace is visible AND already focused, hide it; otherwise reveal and
|
|
299
|
+
// focus it (opening the panel list when nothing is open yet).
|
|
300
|
+
if (panelManager.isVisible() && panelManager.getFocusTarget() === 'panel') {
|
|
301
|
+
panelManager.hide();
|
|
302
|
+
panelManager.focusPrompt();
|
|
303
|
+
conversation.setSplashSuppressed(false);
|
|
306
304
|
conversation.rebuildHistory();
|
|
307
|
-
} else
|
|
305
|
+
} else {
|
|
308
306
|
if (panelManager.getAllOpen().length === 0) {
|
|
309
307
|
try {
|
|
310
308
|
panelManager.open('panel-list');
|
|
@@ -313,26 +311,20 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
|
|
|
313
311
|
}
|
|
314
312
|
}
|
|
315
313
|
panelManager.show();
|
|
316
|
-
|
|
314
|
+
panelManager.focusPanels();
|
|
317
315
|
conversation.setSplashSuppressed(true);
|
|
318
316
|
conversation.rebuildHistory();
|
|
319
|
-
} else {
|
|
320
|
-
panelManager.hide();
|
|
321
|
-
input.panelFocused = false;
|
|
322
|
-
conversation.setSplashSuppressed(false);
|
|
323
|
-
conversation.rebuildHistory();
|
|
324
317
|
}
|
|
325
318
|
render();
|
|
326
319
|
};
|
|
327
320
|
|
|
328
321
|
commandContext.focusPanels = () => {
|
|
329
|
-
|
|
330
|
-
input.panelFocused = true;
|
|
322
|
+
panelManager.focusPanels();
|
|
331
323
|
render();
|
|
332
324
|
};
|
|
333
325
|
|
|
334
326
|
commandContext.focusPrompt = () => {
|
|
335
|
-
|
|
327
|
+
panelManager.focusPrompt();
|
|
336
328
|
input.indicatorFocused = false;
|
|
337
329
|
render();
|
|
338
330
|
};
|
|
@@ -340,7 +332,7 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
|
|
|
340
332
|
commandContext.showPanel = (panelId, pane) => {
|
|
341
333
|
panelManager.open(panelId, pane);
|
|
342
334
|
panelManager.show();
|
|
343
|
-
|
|
335
|
+
panelManager.focusPanels();
|
|
344
336
|
conversation.setSplashSuppressed(true);
|
|
345
337
|
conversation.rebuildHistory();
|
|
346
338
|
render();
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
/**
|
|
11
11
|
* Format a latency value in milliseconds with sub-second precision.
|
|
12
12
|
*
|
|
13
|
-
* Used by: debug-panel, provider-health-panel
|
|
13
|
+
* Used by: debug-panel, provider-health-panel
|
|
14
14
|
*
|
|
15
15
|
* ms <= 0 → 'n/a'
|
|
16
16
|
* ms >= 10000 → '12.3s' (one decimal)
|
|
@@ -42,7 +42,7 @@ export function formatDuration(ms: number): string {
|
|
|
42
42
|
/**
|
|
43
43
|
* Format a short eval/forensics duration; treats undefined as '?ms'.
|
|
44
44
|
*
|
|
45
|
-
* Used by:
|
|
45
|
+
* Used by: incident-review-panel, eval-panel
|
|
46
46
|
*
|
|
47
47
|
* undefined → '?ms'
|
|
48
48
|
* ms < 1000 → '500ms'
|