@pellux/goodvibes-tui 0.29.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 +32 -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/handler-content-actions.ts +8 -3
- package/src/input/input-history.ts +17 -4
- package/src/main.ts +14 -10
- package/src/panels/base-panel.ts +1 -1
- package/src/panels/diff-panel.ts +10 -4
- package/src/panels/git-panel.ts +2 -2
- 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-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/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 +21 -8
- 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 +7 -7
- package/src/renderer/ui-primitives.ts +18 -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
|
@@ -10,22 +10,22 @@ import type { ModelPickerModal } from '../input/model-picker.ts';
|
|
|
10
10
|
import type { ModelPickerTargetInfo } from '../input/model-picker.ts';
|
|
11
11
|
import type { Line } from '../types/grid.ts';
|
|
12
12
|
import { createEmptyLine, createStyledCell } from '../types/grid.ts';
|
|
13
|
-
import { getDisplayWidth, wrapText } from '../utils/terminal-width.ts';
|
|
13
|
+
import { fitDisplay, getDisplayWidth, wrapText } from '../utils/terminal-width.ts';
|
|
14
14
|
import { abbreviateCount } from '../utils/format-number.ts';
|
|
15
15
|
import { GLYPHS, UI_TONES } from './ui-primitives.ts';
|
|
16
16
|
|
|
17
17
|
const PALETTE = {
|
|
18
|
-
border:
|
|
18
|
+
border: UI_TONES.border,
|
|
19
19
|
title: '#67e8f9',
|
|
20
|
-
subtitle:
|
|
21
|
-
text:
|
|
22
|
-
muted:
|
|
23
|
-
dim:
|
|
24
|
-
selectedBg:
|
|
20
|
+
subtitle: UI_TONES.accent.conversation,
|
|
21
|
+
text: UI_TONES.fg.primary,
|
|
22
|
+
muted: UI_TONES.fg.muted,
|
|
23
|
+
dim: UI_TONES.border,
|
|
24
|
+
selectedBg: UI_TONES.bg.selected,
|
|
25
25
|
targetBg: '#141b25',
|
|
26
26
|
detailBg: '#121923',
|
|
27
27
|
bodyBg: '#0f141d',
|
|
28
|
-
footerBg:
|
|
28
|
+
footerBg: UI_TONES.bg.footer,
|
|
29
29
|
good: UI_TONES.state.good,
|
|
30
30
|
warn: UI_TONES.state.warn,
|
|
31
31
|
info: UI_TONES.state.info,
|
|
@@ -100,23 +100,8 @@ function drawVertical(line: Line, x: number, bg = ''): void {
|
|
|
100
100
|
line[x] = createStyledCell(GLYPHS.frame.vertical, { fg: PALETTE.border, bg });
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
function clipDisplay(text: string, width: number): string {
|
|
104
|
-
if (width <= 0) return '';
|
|
105
|
-
let used = 0;
|
|
106
|
-
let output = '';
|
|
107
|
-
for (const ch of text) {
|
|
108
|
-
const chWidth = getDisplayWidth(ch);
|
|
109
|
-
if (chWidth <= 0) continue;
|
|
110
|
-
if (used + chWidth > width) break;
|
|
111
|
-
output += ch;
|
|
112
|
-
used += chWidth;
|
|
113
|
-
}
|
|
114
|
-
return output;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
103
|
function padDisplay(text: string, width: number): string {
|
|
118
|
-
|
|
119
|
-
return clipped + ' '.repeat(Math.max(0, width - getDisplayWidth(clipped)));
|
|
104
|
+
return fitDisplay(text, width, '');
|
|
120
105
|
}
|
|
121
106
|
|
|
122
107
|
function stableWindow(total: number, selected: number, visible: number): { start: number; end: number } {
|
|
@@ -53,7 +53,13 @@ export function createOverlayBoxLayout(
|
|
|
53
53
|
maxWidth: number,
|
|
54
54
|
): OverlayBoxLayout {
|
|
55
55
|
const resolvedMaxWidth = getOverlayMaxWidth(terminalWidth, margin, maxWidth);
|
|
56
|
-
|
|
56
|
+
// The 20-column floor exists so overlays stay legible on ordinary narrow
|
|
57
|
+
// terminals, but on a hostile-narrow terminal (terminalWidth smaller than
|
|
58
|
+
// margin*2 + 20) that floor alone can push the box wider than the real
|
|
59
|
+
// terminal, walking cells off the right edge of the line array. Clamp the
|
|
60
|
+
// floored result back down to what actually fits before returning it.
|
|
61
|
+
const availableWidth = Math.max(1, terminalWidth - margin * 2);
|
|
62
|
+
const width = Math.min(availableWidth, Math.max(20, Math.min(availableWidth, resolvedMaxWidth)));
|
|
57
63
|
const contentWidth = width - 2;
|
|
58
64
|
const innerWidth = contentWidth - 2;
|
|
59
65
|
return { margin, width, contentWidth, innerWidth };
|
|
@@ -128,14 +134,6 @@ export function createOverlayFilledBorderLine(
|
|
|
128
134
|
return line;
|
|
129
135
|
}
|
|
130
136
|
|
|
131
|
-
export function createOverlayFrameLine(
|
|
132
|
-
terminalWidth: number,
|
|
133
|
-
layout: OverlayBoxLayout,
|
|
134
|
-
bg = DEFAULT_OVERLAY_PALETTE.bodyBg,
|
|
135
|
-
): Line {
|
|
136
|
-
return createOverlayContentLine(terminalWidth, layout, DEFAULT_OVERLAY_PALETTE.borderFg, bg);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
137
|
export const OVERLAY_GLYPHS = {
|
|
140
138
|
topLeft: GLYPHS.frame.topLeft,
|
|
141
139
|
topRight: GLYPHS.frame.topRight,
|
|
@@ -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
|
}
|
|
@@ -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',
|
|
@@ -54,22 +54,35 @@ export interface ShellFooterBuildResult {
|
|
|
54
54
|
const FOOTER_BASE_ROWS = 5;
|
|
55
55
|
const CONTEXT_PROGRESS_ROWS = 1;
|
|
56
56
|
const PROCESS_INDICATOR_ROWS = 1;
|
|
57
|
+
// Compact posture drops the context-info line (and never shows the context
|
|
58
|
+
// bar or process indicator), leaving just: prompt-box top border, bottom
|
|
59
|
+
// border, token-usage line, and the help/exit line.
|
|
60
|
+
const COMPACT_FOOTER_BASE_ROWS = 4;
|
|
57
61
|
|
|
58
62
|
/**
|
|
59
|
-
* Real height of the most recently rendered footer
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
63
|
+
* Real height of the most recently rendered footer, tagged with the compact
|
|
64
|
+
* posture it was rendered under. estimateShellFooterHeight prefers this so
|
|
65
|
+
* the pre-prompt viewport math accounts for the composer posture block and
|
|
66
|
+
* context hint that the static formula cannot see. Reset to null before any
|
|
67
|
+
* footer has rendered (cold start), where the formula is exact for the
|
|
68
|
+
* common posture-free, hint-free case. The compact flag is part of the cache
|
|
69
|
+
* key: a cached non-compact height must never answer a compact query (and
|
|
70
|
+
* vice versa), since the two postures differ by several rows.
|
|
64
71
|
*/
|
|
65
|
-
let lastRenderedFooterHeight: number | null = null;
|
|
72
|
+
let lastRenderedFooterHeight: { compact: boolean; height: number } | null = null;
|
|
66
73
|
|
|
67
74
|
export function estimateShellFooterHeight(
|
|
68
75
|
promptLineCount: number,
|
|
69
76
|
contextWindow?: number,
|
|
77
|
+
compact = false,
|
|
70
78
|
): number {
|
|
71
|
-
if (lastRenderedFooterHeight !== null
|
|
79
|
+
if (lastRenderedFooterHeight !== null && lastRenderedFooterHeight.compact === compact) {
|
|
80
|
+
return lastRenderedFooterHeight.height;
|
|
81
|
+
}
|
|
72
82
|
const safePromptLines = Math.max(1, promptLineCount);
|
|
83
|
+
if (compact) {
|
|
84
|
+
return COMPACT_FOOTER_BASE_ROWS + safePromptLines;
|
|
85
|
+
}
|
|
73
86
|
const progressRows = contextWindow && contextWindow > 0 ? CONTEXT_PROGRESS_ROWS : 0;
|
|
74
87
|
return FOOTER_BASE_ROWS + safePromptLines + progressRows + PROCESS_INDICATOR_ROWS;
|
|
75
88
|
}
|
|
@@ -119,6 +132,6 @@ export function buildShellFooter(
|
|
|
119
132
|
lines.unshift(hintLine);
|
|
120
133
|
}
|
|
121
134
|
}
|
|
122
|
-
lastRenderedFooterHeight = lines.length;
|
|
135
|
+
lastRenderedFooterHeight = { compact: options.compact ?? false, height: lines.length };
|
|
123
136
|
return { lines, height: lines.length };
|
|
124
137
|
}
|
|
@@ -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
|
-
}
|
|
@@ -268,15 +268,15 @@ export class UIFactory {
|
|
|
268
268
|
// Suppressed in compact mode; the ctx-info line no longer duplicates these
|
|
269
269
|
// tokens, so this block is the single home for mode/status/flags.
|
|
270
270
|
const composerTokens: Array<{ text: string; fg: string; bold?: boolean; dim?: boolean }> = [];
|
|
271
|
-
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 });
|
|
272
272
|
if (composerPendingRisk && composerPendingRisk !== 'none') {
|
|
273
273
|
const riskColor = composerPendingRisk === 'approval-wait'
|
|
274
|
-
?
|
|
274
|
+
? UI_TONES.state.warn
|
|
275
275
|
: composerPendingRisk === 'shell'
|
|
276
|
-
?
|
|
276
|
+
? UI_TONES.state.bad
|
|
277
277
|
: composerPendingRisk === 'remote'
|
|
278
278
|
? '#a78bfa'
|
|
279
|
-
:
|
|
279
|
+
: UI_TONES.state.warn;
|
|
280
280
|
composerTokens.push({ text: ` risk:${composerPendingRisk} `, fg: riskColor, bold: true });
|
|
281
281
|
}
|
|
282
282
|
if (composerStatus && composerStatus !== 'idle') composerTokens.push({ text: ` state:${composerStatus} `, fg: '244', dim: true });
|
|
@@ -368,7 +368,7 @@ export class UIFactory {
|
|
|
368
368
|
for (const ch of dangerWarn) {
|
|
369
369
|
if (col >= width) break;
|
|
370
370
|
const cw = getDisplayWidth(ch);
|
|
371
|
-
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 };
|
|
372
372
|
if (cw === 2 && col + 1 < width) line[col + 1] = { ...line[col], char: '' };
|
|
373
373
|
col += cw;
|
|
374
374
|
}
|
|
@@ -430,7 +430,7 @@ export class UIFactory {
|
|
|
430
430
|
const inTok = inputTokens ?? 0;
|
|
431
431
|
const outTok = outputTokens ?? 0;
|
|
432
432
|
segments.push({ text: ` in ${fmtNum(inTok)} `, fg: '243', dim: true });
|
|
433
|
-
segments.push({ text: `out ${fmtNum(outTok)}`, fg:
|
|
433
|
+
segments.push({ text: `out ${fmtNum(outTok)}`, fg: UI_TONES.accent.brand });
|
|
434
434
|
}
|
|
435
435
|
const line = createEmptyLine(width);
|
|
436
436
|
let col = 1;
|
|
@@ -470,7 +470,7 @@ export class UIFactory {
|
|
|
470
470
|
if (px >= width) break;
|
|
471
471
|
previewLine[px] = {
|
|
472
472
|
char: ch,
|
|
473
|
-
fg:
|
|
473
|
+
fg: UI_TONES.state.info,
|
|
474
474
|
bg: '',
|
|
475
475
|
bold: true,
|
|
476
476
|
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;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* render-scheduler.ts — same-tick render coalescing for the TUI shell (WO-208).
|
|
3
|
+
*
|
|
4
|
+
* main.ts fans out its own direct render() calls across turn/stream/input wiring
|
|
5
|
+
* (~14 direct invocation contexts). Each one previously ran a full synchronous
|
|
6
|
+
* Compositor.composite() the instant it was called. When several fire within a
|
|
7
|
+
* single event-loop tick — a streaming burst is the canonical case — only the
|
|
8
|
+
* LAST frame is ever visible; the earlier composites are immediately overwritten.
|
|
9
|
+
* This scheduler collapses every schedule() call made within one tick into a
|
|
10
|
+
* single composite flushed on the microtask queue, so the tick produces exactly
|
|
11
|
+
* one frame. That frame is byte-identical to what the last synchronous render()
|
|
12
|
+
* would have produced, because the coalesced flush runs after all of the tick's
|
|
13
|
+
* synchronous state mutations, reading the same final state the last direct
|
|
14
|
+
* render() would have read.
|
|
15
|
+
*
|
|
16
|
+
* This is deliberately SEPARATE from bootstrap-core's requestRender(), which is a
|
|
17
|
+
* cross-tick, 16ms-throttled (~60fps) coalescer for the panel/input/runtime
|
|
18
|
+
* fan-out — that one caps repaint RATE across ticks. This one collapses the
|
|
19
|
+
* WITHIN-tick burst with no added latency: a microtask flushes at the tail of the
|
|
20
|
+
* current tick, before the event loop yields to I/O, so the frame still lands in
|
|
21
|
+
* the same turn it was requested in.
|
|
22
|
+
*
|
|
23
|
+
* flushNow() preserves a synchronous immediate path for callers that genuinely
|
|
24
|
+
* need the frame composited before they return. Terminal resize is the known
|
|
25
|
+
* case: the resize handler resets the compositor diff and must repaint against
|
|
26
|
+
* the new dimensions synchronously rather than waiting for the microtask.
|
|
27
|
+
* flushNow() also clears any pending coalesced flush, so a resize (or any
|
|
28
|
+
* immediate paint) followed by an already-queued microtask never double-composites.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
export interface RenderScheduler {
|
|
32
|
+
/**
|
|
33
|
+
* Coalesced request: schedule a composite for the current tick. N calls within
|
|
34
|
+
* one tick collapse into exactly one composite, flushed on the microtask queue.
|
|
35
|
+
*/
|
|
36
|
+
readonly schedule: () => void;
|
|
37
|
+
/**
|
|
38
|
+
* Immediate request: run the composite synchronously right now, and cancel any
|
|
39
|
+
* pending coalesced flush so the tick still composites only once. Use only where
|
|
40
|
+
* a caller genuinely requires synchronous output (terminal resize).
|
|
41
|
+
*/
|
|
42
|
+
readonly flushNow: () => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build a render scheduler around `renderNow` (the synchronous composite closure).
|
|
47
|
+
*
|
|
48
|
+
* `scheduleFlush` is the deferral primitive; it defaults to `queueMicrotask` so
|
|
49
|
+
* bursts coalesce within the current tick. Tests and benchmarks inject a manual
|
|
50
|
+
* queue to flush deterministically.
|
|
51
|
+
*/
|
|
52
|
+
export function createRenderScheduler(
|
|
53
|
+
renderNow: () => void,
|
|
54
|
+
scheduleFlush: (flush: () => void) => void = queueMicrotask,
|
|
55
|
+
): RenderScheduler {
|
|
56
|
+
let scheduled = false;
|
|
57
|
+
|
|
58
|
+
const flush = (): void => {
|
|
59
|
+
// A synchronous flushNow() (or a superseding immediate paint) before this
|
|
60
|
+
// microtask ran clears `scheduled`, turning this into a no-op so a single
|
|
61
|
+
// tick never composites twice.
|
|
62
|
+
if (!scheduled) return;
|
|
63
|
+
scheduled = false;
|
|
64
|
+
renderNow();
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const schedule = (): void => {
|
|
68
|
+
if (scheduled) return;
|
|
69
|
+
scheduled = true;
|
|
70
|
+
scheduleFlush(flush);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const flushNow = (): void => {
|
|
74
|
+
// Satisfy any pending coalesced flush, then composite synchronously.
|
|
75
|
+
scheduled = false;
|
|
76
|
+
renderNow();
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return { schedule, flushNow };
|
|
80
|
+
}
|
|
@@ -20,7 +20,8 @@ const SEPARATOR = '━'.repeat(ART_W);
|
|
|
20
20
|
*/
|
|
21
21
|
const TAGLINE = '[ good vibes ・ A I ・ いい雰囲気 ]';
|
|
22
22
|
|
|
23
|
-
const
|
|
23
|
+
const versionLine = (version: string) =>
|
|
24
|
+
` ✦ v${version} █ terminal AI assistant █ 自動コード ✦`;
|
|
24
25
|
|
|
25
26
|
/** Fixed hint line — the three primary shell entry points. */
|
|
26
27
|
const HINT_LINE = 'Ctrl+P panels / ? help / F2 processes';
|
|
@@ -35,6 +36,13 @@ export interface SplashOptions {
|
|
|
35
36
|
* When present, the splash advertises a resume affordance.
|
|
36
37
|
*/
|
|
37
38
|
lastSessionId?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Version string rendered on the splash's version line. Defaults to the
|
|
41
|
+
* build VERSION; injectable so the golden-frame tests can pin a fixture —
|
|
42
|
+
* the version's display width shifts the line's centering, so goldens tied
|
|
43
|
+
* to the live VERSION break on every release bump (v1.0.0 release failure).
|
|
44
|
+
*/
|
|
45
|
+
version?: string;
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
/** Collapse a $HOME-prefixed working directory to a leading `~`. */
|
|
@@ -54,7 +62,7 @@ export function getSplashLines(columns: number, opts: SplashOptions = {}): strin
|
|
|
54
62
|
...ART_LINES.map((line) => center(line, columns)),
|
|
55
63
|
center(SEPARATOR, columns),
|
|
56
64
|
center(TAGLINE, columns),
|
|
57
|
-
center(
|
|
65
|
+
center(versionLine(opts.version ?? VERSION), columns),
|
|
58
66
|
'',
|
|
59
67
|
];
|
|
60
68
|
|