@pellux/goodvibes-tui 0.29.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +69 -98
- package/README.md +16 -7
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +2 -2
- package/src/cli/ensure-goodvibes-gitignore.ts +32 -0
- package/src/cli/entrypoint.ts +2 -0
- package/src/core/conversation-line-cache.ts +432 -0
- package/src/core/conversation-rendering.ts +11 -3
- package/src/core/conversation.ts +90 -4
- package/src/core/stream-event-wiring.ts +104 -2
- package/src/core/stream-stall-watchdog.ts +41 -17
- package/src/export/cost-utils.ts +90 -8
- package/src/input/command-registry.ts +16 -0
- package/src/input/commands/diff-runtime.ts +61 -30
- package/src/input/commands/session-content.ts +12 -0
- package/src/input/commands/session-workflow.ts +3 -0
- package/src/input/commands/share-runtime.ts +9 -2
- package/src/input/handler-content-actions.ts +30 -11
- package/src/input/handler-feed-routes.ts +63 -1
- package/src/input/handler-feed.ts +12 -0
- package/src/input/handler-interactions.ts +2 -0
- package/src/input/handler-types.ts +1 -0
- package/src/input/handler.ts +4 -0
- package/src/input/input-history.ts +17 -4
- package/src/main.ts +27 -23
- package/src/panels/agent-inspector-shared.ts +33 -10
- package/src/panels/base-panel.ts +1 -1
- package/src/panels/builtin/development.ts +1 -1
- package/src/panels/cockpit-read-model.ts +16 -11
- package/src/panels/cost-tracker-panel.ts +28 -5
- package/src/panels/debug-panel.ts +11 -5
- package/src/panels/diff-panel.ts +122 -22
- package/src/panels/docs-panel.ts +9 -0
- package/src/panels/file-explorer-panel.ts +9 -0
- package/src/panels/git-panel.ts +11 -11
- package/src/panels/knowledge-graph-panel.ts +9 -0
- package/src/panels/local-auth-panel.ts +10 -0
- package/src/panels/panel-list-panel.ts +9 -0
- package/src/panels/project-planning-panel.ts +10 -0
- package/src/panels/provider-health-tracker.ts +5 -1
- package/src/panels/provider-health-views.ts +8 -1
- package/src/panels/scrollable-list-panel.ts +9 -0
- package/src/panels/session-browser-panel.ts +9 -0
- package/src/panels/token-budget-panel.ts +5 -3
- package/src/panels/types.ts +13 -0
- package/src/panels/work-plan-panel.ts +10 -0
- package/src/renderer/agent-detail-modal.ts +42 -12
- package/src/renderer/code-block.ts +58 -28
- package/src/renderer/context-inspector.ts +3 -6
- package/src/renderer/conversation-surface.ts +1 -21
- package/src/renderer/diff-view.ts +6 -4
- package/src/renderer/fullscreen-primitives.ts +1 -1
- package/src/renderer/fullscreen-workspace.ts +2 -7
- package/src/renderer/hint-grammar.ts +1 -1
- package/src/renderer/history-search-overlay.ts +10 -16
- package/src/renderer/markdown.ts +9 -1
- package/src/renderer/model-picker-overlay.ts +8 -499
- package/src/renderer/model-workspace.ts +9 -24
- package/src/renderer/overlay-box.ts +7 -9
- package/src/renderer/process-indicator.ts +13 -25
- package/src/renderer/process-modal.ts +17 -2
- package/src/renderer/profile-picker-modal.ts +13 -14
- package/src/renderer/prompt-content-width.ts +16 -0
- package/src/renderer/selection-modal-overlay.ts +3 -1
- package/src/renderer/semantic-diff.ts +1 -1
- package/src/renderer/settings-modal-helpers.ts +10 -34
- package/src/renderer/shell-surface.ts +29 -9
- package/src/renderer/surface-layout.ts +0 -12
- package/src/renderer/term-caps.ts +1 -1
- package/src/renderer/tool-call.ts +6 -20
- package/src/renderer/ui-factory.ts +98 -14
- package/src/renderer/ui-primitives.ts +18 -1
- package/src/runtime/bootstrap-command-context.ts +3 -0
- package/src/runtime/bootstrap-command-parts.ts +3 -1
- package/src/runtime/bootstrap-core.ts +5 -0
- package/src/runtime/bootstrap-hook-bridge.ts +5 -0
- package/src/runtime/bootstrap-shell.ts +12 -1
- package/src/runtime/render-scheduler.ts +80 -0
- package/src/utils/splash-lines.ts +10 -2
- package/src/version.ts +1 -1
- package/src/renderer/file-tree.ts +0 -153
- package/src/renderer/progress.ts +0 -100
- package/src/renderer/status-token.ts +0 -67
|
@@ -1,28 +1,16 @@
|
|
|
1
1
|
import { type Line } from '../types/grid.ts';
|
|
2
2
|
import { UIFactory } from './ui-factory.ts';
|
|
3
|
-
import {
|
|
4
|
-
import { GLYPHS } from './ui-primitives.ts';
|
|
5
|
-
|
|
6
|
-
/** Truncate a string to fit within maxWidth display columns. */
|
|
7
|
-
function truncateToWidth(text: string, maxWidth: number): string {
|
|
8
|
-
let width = 0;
|
|
9
|
-
let i = 0;
|
|
10
|
-
for (const char of text) {
|
|
11
|
-
const cw = getDisplayWidth(char);
|
|
12
|
-
if (width + cw > maxWidth) break;
|
|
13
|
-
width += cw;
|
|
14
|
-
i += char.length;
|
|
15
|
-
}
|
|
16
|
-
return text.slice(0, i);
|
|
17
|
-
}
|
|
3
|
+
import { truncateDisplay } from '../utils/terminal-width.ts';
|
|
4
|
+
import { GLYPHS, UI_TONES } from './ui-primitives.ts';
|
|
5
|
+
import { formatHints } from './hint-grammar.ts';
|
|
18
6
|
|
|
19
7
|
/**
|
|
20
8
|
* renderProcessIndicator — shows a one-line summary of active background
|
|
21
9
|
* processes below the input area.
|
|
22
10
|
*
|
|
23
11
|
* Dimmed when no processes are active, highlighted (cyan) when agents or
|
|
24
|
-
* background exec processes are running. Includes an `Enter
|
|
25
|
-
* when active.
|
|
12
|
+
* background exec processes are running. Includes an `[Enter] View` hint
|
|
13
|
+
* (hint-grammar bracket form) when active.
|
|
26
14
|
*/
|
|
27
15
|
export function renderProcessIndicator(
|
|
28
16
|
width: number,
|
|
@@ -38,10 +26,10 @@ export function renderProcessIndicator(
|
|
|
38
26
|
const renderFocusedStatus = (text: string): Line[] => {
|
|
39
27
|
const bg = '#31506f';
|
|
40
28
|
const fg = '#eefaff';
|
|
41
|
-
const markerFg =
|
|
29
|
+
const markerFg = UI_TONES.accent.browser;
|
|
42
30
|
const line = UIFactory.stringToLine(' '.repeat(width), width, { fg: '238' });
|
|
43
31
|
const prefix = `${GLYPHS.navigation.selected} `;
|
|
44
|
-
const body =
|
|
32
|
+
const body = truncateDisplay(text, Math.max(0, width - 8), '');
|
|
45
33
|
const highlighted = ` ${prefix}${body} `;
|
|
46
34
|
const startX = 2;
|
|
47
35
|
for (let i = 0; i < highlighted.length && startX + i < width - 2; i++) {
|
|
@@ -62,8 +50,8 @@ export function renderProcessIndicator(
|
|
|
62
50
|
if (agentCount > 0) parts.push(`${agentCount} agent${agentCount !== 1 ? 's' : ''}`);
|
|
63
51
|
if (toolCount > 0) parts.push(`${toolCount} tool${toolCount !== 1 ? 's' : ''} running`);
|
|
64
52
|
const label = total === 0
|
|
65
|
-
? `No background processes ${
|
|
66
|
-
: `${parts.join(` ${GLYPHS.navigation.pipeSeparator} `)} ${
|
|
53
|
+
? `No background processes ${formatHints([{ key: 'Esc', verb: 'Back to input' }])}`
|
|
54
|
+
: `${parts.join(` ${GLYPHS.navigation.pipeSeparator} `)} ${formatHints([{ key: 'Enter', verb: 'Open' }, { key: 'Esc', verb: 'Back to input' }])}`;
|
|
67
55
|
return renderFocusedStatus(label);
|
|
68
56
|
}
|
|
69
57
|
|
|
@@ -83,16 +71,16 @@ export function renderProcessIndicator(
|
|
|
83
71
|
/**
|
|
84
72
|
* Number of columns reserved for the agent count label and hint text.
|
|
85
73
|
* Breakdown: "bg: N agents" prefix (~15 chars) + " | " separator (~3)
|
|
86
|
-
* + " Enter
|
|
74
|
+
* + " [Enter] View " hint (~16) + padding (~9) ≈ 43 chars.
|
|
87
75
|
*/
|
|
88
76
|
const PROGRESS_RESERVED_CHARS = 43;
|
|
89
77
|
const progressMaxLen = Math.max(0, width - PROGRESS_RESERVED_CHARS); // reserve space for count + hint
|
|
90
78
|
// Truncate by display width (not JS string length) so wide/CJK glyphs in the
|
|
91
79
|
// agent progress text don't overflow the reserved budget.
|
|
92
80
|
const progressSuffix = agentProgress && progressMaxLen > 10
|
|
93
|
-
? ` | ${
|
|
81
|
+
? ` | ${truncateDisplay(agentProgress, progressMaxLen, '...')}`
|
|
94
82
|
: '';
|
|
95
83
|
const label = `${parts.join(` ${GLYPHS.navigation.pipeSeparator} `)}${progressSuffix}`;
|
|
96
|
-
const hint = ` ${
|
|
97
|
-
return renderPlainStatus(`${label}${hint}`, { fg:
|
|
84
|
+
const hint = ` ${formatHints([{ key: 'Enter', verb: 'View' }])}`;
|
|
85
|
+
return renderPlainStatus(`${label}${hint}`, { fg: UI_TONES.accent.brand, bold: true });
|
|
98
86
|
}
|
|
@@ -85,7 +85,7 @@ function buildAgentLabel(rec: AgentRecord, deps: ProcessModalDeps): string {
|
|
|
85
85
|
if (task.startsWith('WRFC Review Request')) {
|
|
86
86
|
const thresholdMatch = task.match(/threshold is (\d+(?:\.\d+)?)/);
|
|
87
87
|
const threshold = thresholdMatch ? thresholdMatch[1] : '9.9';
|
|
88
|
-
const desc = truncateFirst(originalTask ?? 'review in progress', 50);
|
|
88
|
+
const desc = truncateFirst(originalTask ?? embeddedTaskDescription(task) ?? 'review in progress', 50);
|
|
89
89
|
return `[Review] ${desc} (target: ${threshold}/10)`;
|
|
90
90
|
}
|
|
91
91
|
|
|
@@ -96,7 +96,7 @@ function buildAgentLabel(rec: AgentRecord, deps: ProcessModalDeps): string {
|
|
|
96
96
|
const toScore = scoreMatch ? scoreMatch[3] : '?';
|
|
97
97
|
const attemptMatch = task.match(/Fix attempt:\s*(\d+)/);
|
|
98
98
|
const attempt = attemptMatch ? attemptMatch[1] : '?';
|
|
99
|
-
const desc = truncateFirst(originalTask ?? 'fix in progress', 45);
|
|
99
|
+
const desc = truncateFirst(originalTask ?? embeddedTaskDescription(task) ?? 'fix in progress', 45);
|
|
100
100
|
// Show constraint count when the chain has constraints to target (SDK 0.23.0)
|
|
101
101
|
const chain = rec.wrfcId ? safeGetChain(rec.wrfcId, deps) : null;
|
|
102
102
|
const constraintCount = chain && (chain.constraints?.length ?? 0) > 0 ? chain.constraints?.length ?? 0 : 0;
|
|
@@ -441,6 +441,21 @@ function getChainTask(wrfcId: string | undefined, deps: Pick<ProcessModalDeps, '
|
|
|
441
441
|
return safeGetChain(wrfcId, deps)?.task ?? null;
|
|
442
442
|
}
|
|
443
443
|
|
|
444
|
+
/**
|
|
445
|
+
* Fallback for when the WRFC chain lookup comes back null (chain already
|
|
446
|
+
* completed/evicted, or wrfcId not populated on the record) — the record's
|
|
447
|
+
* own `rec.task` is seeded as `'WRFC Review Request\n<original task
|
|
448
|
+
* description>'` / `'WRFC Fix Request\n...'`, so the description is sitting
|
|
449
|
+
* right there even without the chain. Returns null (not the generic
|
|
450
|
+
* placeholder) when there's no second line to extract.
|
|
451
|
+
*/
|
|
452
|
+
function embeddedTaskDescription(task: string): string | null {
|
|
453
|
+
const firstNewline = task.indexOf('\n');
|
|
454
|
+
if (firstNewline === -1) return null;
|
|
455
|
+
const desc = task.slice(firstNewline + 1).trim();
|
|
456
|
+
return desc.length > 0 ? desc : null;
|
|
457
|
+
}
|
|
458
|
+
|
|
444
459
|
/** Truncate to first line, capped at max chars. */
|
|
445
460
|
function truncateFirst(text: string, max: number): string {
|
|
446
461
|
const line = text.split('\n')[0].trim();
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* using ModalFactory.
|
|
4
4
|
*
|
|
5
5
|
* Shows a list of saved profiles with:
|
|
6
|
-
* - name, timestamp (formatted)
|
|
6
|
+
* - name, timestamp (formatted)
|
|
7
7
|
* Footer hints: [Up/Down] Navigate [Enter] Load [d] Arm/Delete [s] Save current [Esc] Close
|
|
8
8
|
*/
|
|
9
9
|
|
|
@@ -55,18 +55,21 @@ export function renderProfilePickerModal(
|
|
|
55
55
|
style: { fg: '240', dim: true },
|
|
56
56
|
});
|
|
57
57
|
} else {
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
// Proportional column widths that adapt to the modal's content width:
|
|
59
|
+
// timestamp ~22% (clamped 10..16); the name column absorbs the remainder
|
|
60
|
+
// (ported from session-picker-modal.ts). Reserves 4 cols — 2 for the
|
|
61
|
+
// name/timestamp separator and 2 for the list row's selection indicator
|
|
62
|
+
// ("▸ ") that ModalFactory prepends outside the wrapped label — so the
|
|
63
|
+
// row never spills the timestamp's embedded space onto a wrapped line.
|
|
64
|
+
const tsW = Math.min(16, Math.max(10, Math.floor(contentW * 0.22)));
|
|
65
|
+
const nameW = Math.max(8, contentW - tsW - 4);
|
|
62
66
|
|
|
63
67
|
// Column header
|
|
64
|
-
const nameHdr
|
|
65
|
-
const tsHdr
|
|
66
|
-
const previewHdr = fitDisplay('Settings', previewW);
|
|
68
|
+
const nameHdr = fitDisplay('Name', nameW);
|
|
69
|
+
const tsHdr = fitDisplay('Saved', tsW);
|
|
67
70
|
sections.push({
|
|
68
71
|
type: 'text',
|
|
69
|
-
content: `${nameHdr} ${tsHdr}
|
|
72
|
+
content: `${nameHdr} ${tsHdr}`,
|
|
70
73
|
style: { fg: '240', dim: true },
|
|
71
74
|
});
|
|
72
75
|
sections.push({ type: 'separator' });
|
|
@@ -79,11 +82,7 @@ export function renderProfilePickerModal(
|
|
|
79
82
|
|
|
80
83
|
const tsStr = fitDisplay(formatTimestamp(prof.timestamp), tsW);
|
|
81
84
|
|
|
82
|
-
|
|
83
|
-
// (We only have name/timestamp in ProfileInfo, so show a placeholder)
|
|
84
|
-
const preview = fitDisplay('(display/provider/behavior)', previewW);
|
|
85
|
-
|
|
86
|
-
const label = `${nameStr} ${tsStr} ${preview}`;
|
|
85
|
+
const label = `${nameStr} ${tsStr}`;
|
|
87
86
|
return { label, selected: isSelected };
|
|
88
87
|
});
|
|
89
88
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* computePromptContentWidth — content width available for prompt text inside
|
|
3
|
+
* the footer input box, after the box margin, inner padding, and the ' > '
|
|
4
|
+
* prefix are subtracted.
|
|
5
|
+
*
|
|
6
|
+
* Floors at 1: on a hostile-narrow terminal the raw subtraction
|
|
7
|
+
* (boxWidth - 4 - 3) can go zero or negative, and a non-positive content
|
|
8
|
+
* width breaks downstream text-wrapping and cursor-position math (which
|
|
9
|
+
* assume at least one column to place a character in).
|
|
10
|
+
*/
|
|
11
|
+
export function computePromptContentWidth(terminalColumns: number): number {
|
|
12
|
+
const w = terminalColumns || 80;
|
|
13
|
+
const boxMargin = 2;
|
|
14
|
+
const boxWidth = w - (boxMargin * 2);
|
|
15
|
+
return Math.max(1, boxWidth - 4 - 3); // minus padding (4) minus prefix width (3: ' > ')
|
|
16
|
+
}
|
|
@@ -196,10 +196,12 @@ export function renderSelectionModalOverlay(
|
|
|
196
196
|
: selectedItem?.primaryAction === 'delete'
|
|
197
197
|
? '[Enter] Delete'
|
|
198
198
|
: '[Enter] Select';
|
|
199
|
-
let hints = `[Up/Down] Navigate ${primaryVerb}
|
|
199
|
+
let hints = `[Up/Down] Navigate ${primaryVerb}`;
|
|
200
200
|
if (modal.allowSearch) hints += ' [/] Search';
|
|
201
201
|
if (selectedItem?.primaryAction === 'toggle' && !selectedItem.actions) hints += ' [Space] Toggle';
|
|
202
202
|
if (selectedItem?.actions) hints += ` ${selectedItem.actions}`;
|
|
203
|
+
// hint-grammar: Esc is the conventional "way out" and always sorts last.
|
|
204
|
+
hints += ' [Esc] Close';
|
|
203
205
|
putText(
|
|
204
206
|
footerLine,
|
|
205
207
|
layout.margin + 2,
|
|
@@ -328,7 +328,7 @@ const CHANGE_GLYPH: Record<ChangeKind, string> = {
|
|
|
328
328
|
* + import ./utils { A, B }
|
|
329
329
|
* ~ import ./types (+NewType, -OldType)
|
|
330
330
|
*/
|
|
331
|
-
|
|
331
|
+
function formatSemanticDiff(diff: SemanticDiff): string[] {
|
|
332
332
|
const lines: string[] = [];
|
|
333
333
|
|
|
334
334
|
// Symbol changes first
|
|
@@ -7,6 +7,13 @@
|
|
|
7
7
|
import type { SettingEntry, McpEntry, SubscriptionEntry } from '../input/settings-modal-types.ts';
|
|
8
8
|
import { SETTINGS_CATEGORIES } from '../input/settings-modal-types.ts';
|
|
9
9
|
import { isSecretConfigKey, isSecretReferenceValue } from '../config/secret-config.ts';
|
|
10
|
+
import { UI_TONES } from './ui-primitives.ts';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* "Modified / active" accent for settings rows — cyan-green, no UI_TONES
|
|
14
|
+
* role matches it (WO-207b: preserved byte-exact as a named constant).
|
|
15
|
+
*/
|
|
16
|
+
const SETTINGS_ACCENT = '#00ffcc';
|
|
10
17
|
|
|
11
18
|
function maskSecretValue(value: string): string {
|
|
12
19
|
if (value.length === 0) return '(empty)';
|
|
@@ -25,43 +32,12 @@ export function formatValue(entry: SettingEntry): string {
|
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
export function valueColor(entry: SettingEntry): string {
|
|
28
|
-
if (!entry.isDefault) return
|
|
29
|
-
return '244';
|
|
35
|
+
if (!entry.isDefault) return SETTINGS_ACCENT; // cyan-green = modified
|
|
36
|
+
return '244'; // dim = default
|
|
30
37
|
}
|
|
31
38
|
|
|
32
|
-
export function flagStateColor(state: string, killed: boolean): string {
|
|
33
|
-
if (killed) return '#ef4444'; // red
|
|
34
|
-
if (state === 'enabled') return '#00ffcc'; // cyan-green
|
|
35
|
-
return '244'; // dim
|
|
36
|
-
}
|
|
37
39
|
|
|
38
|
-
export function mcpTrustColor(mode: McpEntry['trustMode']): string {
|
|
39
|
-
switch (mode) {
|
|
40
|
-
case 'allow-all':
|
|
41
|
-
return '#ef4444';
|
|
42
|
-
case 'ask-on-risk':
|
|
43
|
-
return '#eab308';
|
|
44
|
-
case 'constrained':
|
|
45
|
-
return '#00ffcc';
|
|
46
|
-
case 'blocked':
|
|
47
|
-
return '244';
|
|
48
|
-
default:
|
|
49
|
-
return '244';
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
40
|
|
|
53
|
-
export function subscriptionStateColor(state: SubscriptionEntry['state']): string {
|
|
54
|
-
switch (state) {
|
|
55
|
-
case 'active':
|
|
56
|
-
return '#00ffcc';
|
|
57
|
-
case 'pending':
|
|
58
|
-
return '#eab308';
|
|
59
|
-
case 'available':
|
|
60
|
-
return '#38bdf8';
|
|
61
|
-
default:
|
|
62
|
-
return '244';
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
41
|
|
|
66
42
|
export function inferSubscriptionRouteReason(entry: SubscriptionEntry): string | undefined {
|
|
67
43
|
if (entry.routeReason?.trim()) return entry.routeReason;
|
|
@@ -107,7 +83,7 @@ export const CATEGORY_LABELS: Record<(typeof SETTINGS_CATEGORIES)[number], strin
|
|
|
107
83
|
network: 'Network',
|
|
108
84
|
};
|
|
109
85
|
|
|
110
|
-
|
|
86
|
+
const SETTING_LABELS: Partial<Record<string, string>> = {
|
|
111
87
|
'ui.systemMessages': 'System Message Target',
|
|
112
88
|
'ui.operationalMessages': 'Operational Message Target',
|
|
113
89
|
'ui.wrfcMessages': 'WRFC Message Target',
|
|
@@ -8,6 +8,13 @@ export interface ShellFooterBuildOptions {
|
|
|
8
8
|
readonly promptLineCount: number;
|
|
9
9
|
readonly promptCursorPos?: number;
|
|
10
10
|
readonly promptFocused?: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* True when the panel workspace owns keyboard focus. Only consulted as a
|
|
13
|
+
* fallback when `promptFocused` is not supplied — see buildShellFooter's
|
|
14
|
+
* default expression. Callers that already compute `promptFocused`
|
|
15
|
+
* themselves (main.ts does) do not need to also pass this.
|
|
16
|
+
*/
|
|
17
|
+
readonly panelFocused?: boolean;
|
|
11
18
|
readonly usage: { up: number; down: number };
|
|
12
19
|
readonly showExitNotice: boolean;
|
|
13
20
|
readonly lastCopyTime: number;
|
|
@@ -54,22 +61,35 @@ export interface ShellFooterBuildResult {
|
|
|
54
61
|
const FOOTER_BASE_ROWS = 5;
|
|
55
62
|
const CONTEXT_PROGRESS_ROWS = 1;
|
|
56
63
|
const PROCESS_INDICATOR_ROWS = 1;
|
|
64
|
+
// Compact posture drops the context-info line (and never shows the context
|
|
65
|
+
// bar or process indicator), leaving just: prompt-box top border, bottom
|
|
66
|
+
// border, token-usage line, and the help/exit line.
|
|
67
|
+
const COMPACT_FOOTER_BASE_ROWS = 4;
|
|
57
68
|
|
|
58
69
|
/**
|
|
59
|
-
* Real height of the most recently rendered footer
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
70
|
+
* Real height of the most recently rendered footer, tagged with the compact
|
|
71
|
+
* posture it was rendered under. estimateShellFooterHeight prefers this so
|
|
72
|
+
* the pre-prompt viewport math accounts for the composer posture block and
|
|
73
|
+
* context hint that the static formula cannot see. Reset to null before any
|
|
74
|
+
* footer has rendered (cold start), where the formula is exact for the
|
|
75
|
+
* common posture-free, hint-free case. The compact flag is part of the cache
|
|
76
|
+
* key: a cached non-compact height must never answer a compact query (and
|
|
77
|
+
* vice versa), since the two postures differ by several rows.
|
|
64
78
|
*/
|
|
65
|
-
let lastRenderedFooterHeight: number | null = null;
|
|
79
|
+
let lastRenderedFooterHeight: { compact: boolean; height: number } | null = null;
|
|
66
80
|
|
|
67
81
|
export function estimateShellFooterHeight(
|
|
68
82
|
promptLineCount: number,
|
|
69
83
|
contextWindow?: number,
|
|
84
|
+
compact = false,
|
|
70
85
|
): number {
|
|
71
|
-
if (lastRenderedFooterHeight !== null
|
|
86
|
+
if (lastRenderedFooterHeight !== null && lastRenderedFooterHeight.compact === compact) {
|
|
87
|
+
return lastRenderedFooterHeight.height;
|
|
88
|
+
}
|
|
72
89
|
const safePromptLines = Math.max(1, promptLineCount);
|
|
90
|
+
if (compact) {
|
|
91
|
+
return COMPACT_FOOTER_BASE_ROWS + safePromptLines;
|
|
92
|
+
}
|
|
73
93
|
const progressRows = contextWindow && contextWindow > 0 ? CONTEXT_PROGRESS_ROWS : 0;
|
|
74
94
|
return FOOTER_BASE_ROWS + safePromptLines + progressRows + PROCESS_INDICATOR_ROWS;
|
|
75
95
|
}
|
|
@@ -94,7 +114,7 @@ export function buildShellFooter(
|
|
|
94
114
|
options.lastInputTokens,
|
|
95
115
|
options.commandArgsHint,
|
|
96
116
|
options.hitlMode,
|
|
97
|
-
options.promptFocused ?? !options.indicatorFocused,
|
|
117
|
+
options.promptFocused ?? (!options.indicatorFocused && !options.panelFocused),
|
|
98
118
|
options.composerMode,
|
|
99
119
|
options.composerStatus,
|
|
100
120
|
options.composerFlags,
|
|
@@ -119,6 +139,6 @@ export function buildShellFooter(
|
|
|
119
139
|
lines.unshift(hintLine);
|
|
120
140
|
}
|
|
121
141
|
}
|
|
122
|
-
lastRenderedFooterHeight = lines.length;
|
|
142
|
+
lastRenderedFooterHeight = { compact: options.compact ?? false, height: lines.length };
|
|
123
143
|
return { lines, height: lines.length };
|
|
124
144
|
}
|
|
@@ -87,15 +87,3 @@ export function getTrackedVisibleWindow(
|
|
|
87
87
|
}
|
|
88
88
|
return { start, end, count: end - start, total };
|
|
89
89
|
}
|
|
90
|
-
|
|
91
|
-
export function sliceVisibleWindow<T>(
|
|
92
|
-
items: readonly T[],
|
|
93
|
-
selectedIndex: number,
|
|
94
|
-
visibleCount: number,
|
|
95
|
-
): { readonly items: readonly T[]; readonly window: VisibleWindow } {
|
|
96
|
-
const window = getVisibleWindow(items.length, selectedIndex, visibleCount);
|
|
97
|
-
return {
|
|
98
|
-
items: items.slice(window.start, window.end),
|
|
99
|
-
window,
|
|
100
|
-
};
|
|
101
|
-
}
|
|
@@ -223,7 +223,7 @@ export function nearestAnsi16Fg(r: number, g: number, b: number): number {
|
|
|
223
223
|
* Convert an ANSI16 fg code to the corresponding bg code.
|
|
224
224
|
* fg 30-37 → bg 40-47; fg 90-97 → bg 100-107.
|
|
225
225
|
*/
|
|
226
|
-
|
|
226
|
+
function ansi16FgToBg(fgCode: number): number {
|
|
227
227
|
// Both ranges (30-37 and 90-97) shift by +10 to reach their bg equivalents
|
|
228
228
|
// (30-37 → 40-47, 90-97 → 100-107).
|
|
229
229
|
return fgCode + 10;
|
|
@@ -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
|
-
}
|
|
@@ -8,13 +8,36 @@ 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';
|
|
11
|
+
import { calcSessionCost, isModelPriced } from '../export/cost-utils.ts';
|
|
12
12
|
import { buildFooterTip, isAgentActive } from './footer-tips.ts';
|
|
13
|
+
import type { StreamMetrics } from '../core/stream-event-wiring.ts';
|
|
13
14
|
|
|
14
15
|
/** Number of frames before the animated gradient completes one full cycle. */
|
|
15
16
|
const GRADIENT_CYCLE_FRAMES = 50;
|
|
16
17
|
/** Number of frames before rotating to the next thinking phrase (~30 seconds at 80ms/frame). */
|
|
17
18
|
const PHRASE_ROTATION_FRAMES = 375;
|
|
19
|
+
/**
|
|
20
|
+
* Ms since the last STREAM_DELTA before the whimsical phrase rotation freezes
|
|
21
|
+
* and createThinkingFragment shows an honest "stalled Ns" / "reconnecting"
|
|
22
|
+
* label instead. Deliberately much shorter than the 30s stream-stall-watchdog
|
|
23
|
+
* hint threshold (stream-stall-watchdog.ts) — that threshold gates a
|
|
24
|
+
* low-priority system message about a likely-dead connection; this one gates
|
|
25
|
+
* a cosmetic label so the UI stops claiming "Vibing..." within a couple of
|
|
26
|
+
* seconds of real silence, well before the stall is confirmed as a problem.
|
|
27
|
+
*/
|
|
28
|
+
const THINKING_STALL_FREEZE_MS = 2_500;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Stall/reconnect state for the live thinking indicator, computed by the
|
|
32
|
+
* caller every render frame from streamMetrics (see stream-event-wiring.ts).
|
|
33
|
+
* `reconnect` is populated only once the SDK's STREAM_RETRY event fires
|
|
34
|
+
* (structurally consumed — absent from SDK 0.35.0's TurnEvent union today).
|
|
35
|
+
*/
|
|
36
|
+
export interface ThinkingStallInfo {
|
|
37
|
+
/** Ms since the last STREAM_DELTA (or STREAM_START if none yet this turn). */
|
|
38
|
+
readonly msSinceLastDelta: number;
|
|
39
|
+
readonly reconnect?: { readonly attempt: number; readonly maxAttempts: number };
|
|
40
|
+
}
|
|
18
41
|
|
|
19
42
|
/** Build the git segment string and its display width. Single source of truth for header layout. */
|
|
20
43
|
function buildGitSegment(gitInfo: GitHeaderInfo): { text: string; width: number } {
|
|
@@ -268,15 +291,15 @@ export class UIFactory {
|
|
|
268
291
|
// Suppressed in compact mode; the ctx-info line no longer duplicates these
|
|
269
292
|
// tokens, so this block is the single home for mode/status/flags.
|
|
270
293
|
const composerTokens: Array<{ text: string; fg: string; bold?: boolean; dim?: boolean }> = [];
|
|
271
|
-
if (composerMode) composerTokens.push({ text: ` ${GLYPHS.status.active} ${composerMode} `, fg:
|
|
294
|
+
if (composerMode) composerTokens.push({ text: ` ${GLYPHS.status.active} ${composerMode} `, fg: UI_TONES.state.info, bold: true });
|
|
272
295
|
if (composerPendingRisk && composerPendingRisk !== 'none') {
|
|
273
296
|
const riskColor = composerPendingRisk === 'approval-wait'
|
|
274
|
-
?
|
|
297
|
+
? UI_TONES.state.warn
|
|
275
298
|
: composerPendingRisk === 'shell'
|
|
276
|
-
?
|
|
299
|
+
? UI_TONES.state.bad
|
|
277
300
|
: composerPendingRisk === 'remote'
|
|
278
301
|
? '#a78bfa'
|
|
279
|
-
:
|
|
302
|
+
: UI_TONES.state.warn;
|
|
280
303
|
composerTokens.push({ text: ` risk:${composerPendingRisk} `, fg: riskColor, bold: true });
|
|
281
304
|
}
|
|
282
305
|
if (composerStatus && composerStatus !== 'idle') composerTokens.push({ text: ` state:${composerStatus} `, fg: '244', dim: true });
|
|
@@ -313,7 +336,15 @@ export class UIFactory {
|
|
|
313
336
|
const cw = u.cacheWrite ?? 0;
|
|
314
337
|
const total = inp + out + cr + cw;
|
|
315
338
|
const tokenSep = ` ${GLYPHS.navigation.pipeSeparator} `;
|
|
316
|
-
|
|
339
|
+
// 'n/a' (not 'unpriced') to stay compact in the single-line footer and
|
|
340
|
+
// match the existing "no priceable data" convention used elsewhere
|
|
341
|
+
// (cockpit-panel formatCost, agent-inspector-shared) — the footer has no
|
|
342
|
+
// room for a longer marker before truncation kicks in.
|
|
343
|
+
const costSegment = model
|
|
344
|
+
? isModelPriced(model)
|
|
345
|
+
? `${tokenSep}~$${fmtCost(calcSessionCost(inp, out, cr, cw, model))}`
|
|
346
|
+
: `${tokenSep}~n/a`
|
|
347
|
+
: '';
|
|
317
348
|
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} ]`;
|
|
318
349
|
const copiedNotice = isRecentlyCopied ? ` [COPIED] ` : '';
|
|
319
350
|
const statsLine = ' ' + tokenLine + ' '.repeat(Math.max(0, width - 4 - getDisplayWidth(tokenLine) - getDisplayWidth(copiedNotice))) + copiedNotice;
|
|
@@ -345,7 +376,11 @@ export class UIFactory {
|
|
|
345
376
|
ctxParts.push(model + (provider ? ` (${provider})` : ''));
|
|
346
377
|
}
|
|
347
378
|
if (toolCount) ctxParts.push(`${toolCount} tools`);
|
|
348
|
-
|
|
379
|
+
// Labeled "notify" (not "hitl") — /mode (aliased /hitl) governs UX
|
|
380
|
+
// notification verbosity (quiet/balanced/operator), not tool
|
|
381
|
+
// auto-approval, so it must not share vocabulary with the DANGER MODE
|
|
382
|
+
// risk banner rendered a few lines below.
|
|
383
|
+
if (hitlMode) ctxParts.push(`notify:${hitlMode}`);
|
|
349
384
|
const ctxLine = ' ' + ctxParts.join(` ${GLYPHS.navigation.pipeSeparator} `);
|
|
350
385
|
lines.push(this.stringToLine(truncateDisplay(ctxLine, width), width, { fg: '240', dim: true }));
|
|
351
386
|
}
|
|
@@ -368,7 +403,7 @@ export class UIFactory {
|
|
|
368
403
|
for (const ch of dangerWarn) {
|
|
369
404
|
if (col >= width) break;
|
|
370
405
|
const cw = getDisplayWidth(ch);
|
|
371
|
-
line[col] = { char: ch, fg:
|
|
406
|
+
line[col] = { char: ch, fg: UI_TONES.state.bad, bg: '', bold: true, dim: false, underline: false, italic: false, strikethrough: false };
|
|
372
407
|
if (cw === 2 && col + 1 < width) line[col + 1] = { ...line[col], char: '' };
|
|
373
408
|
col += cw;
|
|
374
409
|
}
|
|
@@ -406,10 +441,59 @@ export class UIFactory {
|
|
|
406
441
|
private static readonly THINK_GRADIENT_START = UI_TONES.accent.gradientStart;
|
|
407
442
|
private static readonly THINK_GRADIENT_END = UI_TONES.accent.gradientEnd;
|
|
408
443
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
444
|
+
/**
|
|
445
|
+
* Per-frame stall info from stream metrics — computed from lastDeltaAtMs every render (not
|
|
446
|
+
* from any event) so it degrades gracefully with zero new SDK events. Undefined until the
|
|
447
|
+
* first delta clock exists this turn.
|
|
448
|
+
*/
|
|
449
|
+
public static computeStallInfo(lastDeltaAtMs: number | undefined, reconnectAttempt: number | undefined, reconnectMaxAttempts: number | undefined, nowMs: number): ThinkingStallInfo | undefined {
|
|
450
|
+
if (lastDeltaAtMs === undefined) return undefined;
|
|
451
|
+
const reconnect = reconnectAttempt !== undefined && reconnectMaxAttempts !== undefined
|
|
452
|
+
? { attempt: reconnectAttempt, maxAttempts: reconnectMaxAttempts }
|
|
453
|
+
: undefined;
|
|
454
|
+
return { msSinceLastDelta: nowMs - lastDeltaAtMs, reconnect };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Render-frame stall-info decision used at the main render loop's call
|
|
459
|
+
* site: suppress stall detection entirely while a tool is actively
|
|
460
|
+
* executing. lastDeltaAtMs only tracks STREAM_START/STREAM_DELTA and is
|
|
461
|
+
* never advanced during tool execution (the model isn't producing tokens
|
|
462
|
+
* then), so without this gate any tool call longer than
|
|
463
|
+
* THINKING_STALL_FREEZE_MS would make the thinking fragment print
|
|
464
|
+
* "Stalled Ns..." directly above the ticking "executing (Ns)" tool row — a
|
|
465
|
+
* false positive during ordinary tool execution (see stream-event-wiring.ts
|
|
466
|
+
* TOOL_EXECUTING/TOOL_SUCCEEDED/TOOL_FAILED/TOOL_CANCELLED handlers).
|
|
467
|
+
* Genuine no-delta silence while waiting on the provider — including the
|
|
468
|
+
* pre-first-token case, where lastDeltaAtMs is seeded at STREAM_START —
|
|
469
|
+
* still stall-detects normally here, since no tool is active then; that is
|
|
470
|
+
* the honest stall case this indicator exists for.
|
|
471
|
+
*/
|
|
472
|
+
public static computeRenderStallInfo(
|
|
473
|
+
metrics: Pick<StreamMetrics, 'activeToolName' | 'lastDeltaAtMs' | 'reconnectAttempt' | 'reconnectMaxAttempts'>,
|
|
474
|
+
nowMs: number,
|
|
475
|
+
): ThinkingStallInfo | undefined {
|
|
476
|
+
return metrics.activeToolName === undefined
|
|
477
|
+
? this.computeStallInfo(metrics.lastDeltaAtMs, metrics.reconnectAttempt, metrics.reconnectMaxAttempts, nowMs)
|
|
478
|
+
: undefined;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
public static createThinkingFragment(width: number, spinner: string, frame: number = 0, tokenSpeed?: number, toolPreview?: string, inputTokens?: number, outputTokens?: number, elapsedMs?: number, ttftMs?: number, stallInfo?: ThinkingStallInfo): Line[] {
|
|
482
|
+
// Freeze the whimsical phrase rotation once real silence has gone on
|
|
483
|
+
// long enough to be misleading (THINKING_STALL_FREEZE_MS), and show an
|
|
484
|
+
// honest label instead: the SDK's reconnect attempt/maxAttempts once
|
|
485
|
+
// STREAM_RETRY is available, else a plain elapsed-silence readout.
|
|
486
|
+
const isStalled = stallInfo !== undefined && stallInfo.msSinceLastDelta >= THINKING_STALL_FREEZE_MS;
|
|
487
|
+
let phrase: string;
|
|
488
|
+
if (stallInfo?.reconnect) {
|
|
489
|
+
phrase = `Reconnecting (attempt ${stallInfo.reconnect.attempt}/${stallInfo.reconnect.maxAttempts})...`;
|
|
490
|
+
} else if (isStalled) {
|
|
491
|
+
phrase = `Stalled ${Math.floor(stallInfo.msSinceLastDelta / 1000)}s...`;
|
|
492
|
+
} else {
|
|
493
|
+
// Rotate phrase every ~30 seconds (frame ticks at 80ms, so ~375 frames)
|
|
494
|
+
const phraseIndex = Math.floor(frame / PHRASE_ROTATION_FRAMES) % this.THINKING_PHRASES.length;
|
|
495
|
+
phrase = this.THINKING_PHRASES[phraseIndex];
|
|
496
|
+
}
|
|
413
497
|
const speedSuffix = (tokenSpeed !== undefined && tokenSpeed > 0) ? ` (${Math.round(tokenSpeed)} tok/s)` : '';
|
|
414
498
|
const elapsedSuffix = elapsedMs !== undefined ? ` (${formatElapsed(elapsedMs)})` : '';
|
|
415
499
|
const ttftSuffix = (ttftMs !== undefined && ttftMs > 0) ? ` ttft:${ttftMs}ms` : '';
|
|
@@ -430,7 +514,7 @@ export class UIFactory {
|
|
|
430
514
|
const inTok = inputTokens ?? 0;
|
|
431
515
|
const outTok = outputTokens ?? 0;
|
|
432
516
|
segments.push({ text: ` in ${fmtNum(inTok)} `, fg: '243', dim: true });
|
|
433
|
-
segments.push({ text: `out ${fmtNum(outTok)}`, fg:
|
|
517
|
+
segments.push({ text: `out ${fmtNum(outTok)}`, fg: UI_TONES.accent.brand });
|
|
434
518
|
}
|
|
435
519
|
const line = createEmptyLine(width);
|
|
436
520
|
let col = 1;
|
|
@@ -470,7 +554,7 @@ export class UIFactory {
|
|
|
470
554
|
if (px >= width) break;
|
|
471
555
|
previewLine[px] = {
|
|
472
556
|
char: ch,
|
|
473
|
-
fg:
|
|
557
|
+
fg: UI_TONES.state.info,
|
|
474
558
|
bg: '',
|
|
475
559
|
bold: true,
|
|
476
560
|
dim: false,
|
|
@@ -111,7 +111,24 @@ export const UI_TONES = {
|
|
|
111
111
|
border: '#64748b',
|
|
112
112
|
} as const;
|
|
113
113
|
|
|
114
|
-
|
|
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;
|
|
115
132
|
|
|
116
133
|
/** Single spinner-frame source — layout.ts and progress.ts both re-export this. */
|
|
117
134
|
export const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const;
|