@pellux/goodvibes-tui 1.7.0 → 1.9.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/README.md +10 -9
- package/docs/foundation-artifacts/operator-contract.json +4045 -1763
- package/package.json +2 -2
- package/src/audio/spoken-turn-controller.ts +12 -2
- package/src/audio/spoken-turn-wiring.ts +2 -1
- package/src/cli/help.ts +8 -1
- package/src/cli/service-posture.ts +2 -1
- package/src/cli/status.ts +2 -1
- package/src/cli/surface-command.ts +2 -2
- package/src/core/composer-state.ts +11 -3
- package/src/core/conversation-line-cache.ts +27 -3
- package/src/core/conversation-rendering.ts +71 -14
- package/src/core/stream-event-wiring.ts +20 -1
- package/src/core/system-message-noise.ts +87 -0
- package/src/core/system-message-router.ts +68 -1
- package/src/core/turn-cancellation.ts +7 -2
- package/src/core/turn-event-wiring.ts +10 -2
- package/src/daemon/cli.ts +29 -2
- package/src/daemon/handlers/register.ts +8 -1
- package/src/daemon/service-commands.ts +329 -0
- package/src/input/autocomplete.ts +27 -1
- package/src/input/command-registry.ts +46 -4
- package/src/input/commands/codebase-runtime.ts +46 -6
- package/src/input/commands/config.ts +43 -3
- package/src/input/commands/health-runtime.ts +9 -1
- package/src/input/commands/memory.ts +68 -35
- package/src/input/commands/operator-panel-runtime.ts +31 -7
- package/src/input/commands/planning-runtime.ts +95 -6
- package/src/input/commands/qrcode-runtime.ts +25 -5
- package/src/input/commands/remote-runtime-setup.ts +5 -3
- package/src/input/commands/session-content.ts +20 -9
- package/src/input/commands/settings-sync-runtime.ts +15 -3
- package/src/input/commands/shell-core.ts +10 -1
- package/src/input/commands/workstream-runtime.ts +168 -18
- package/src/input/config-modal-types.ts +15 -1
- package/src/input/config-modal.ts +227 -12
- package/src/input/feed-context-factory.ts +3 -0
- package/src/input/handler-command-route.ts +10 -3
- package/src/input/handler-content-actions.ts +17 -2
- package/src/input/handler-feed-routes.ts +43 -121
- package/src/input/handler-feed.ts +32 -4
- package/src/input/handler-modal-routes.ts +78 -13
- package/src/input/handler-modal-stack.ts +11 -2
- package/src/input/handler-onboarding-daemon-adopt.ts +149 -0
- package/src/input/handler-onboarding.ts +71 -59
- package/src/input/handler-picker-routes.ts +15 -8
- package/src/input/handler-shortcuts.ts +59 -9
- package/src/input/handler.ts +6 -1
- package/src/input/keybindings.ts +6 -5
- package/src/input/model-picker.ts +19 -2
- package/src/input/onboarding/onboarding-runtime-status.ts +10 -1
- package/src/input/onboarding/onboarding-wizard-apply.ts +8 -1
- package/src/input/onboarding/onboarding-wizard-constants.ts +4 -1
- package/src/input/onboarding/onboarding-wizard-network-adopt.ts +136 -0
- package/src/input/onboarding/onboarding-wizard-steps.ts +9 -6
- package/src/input/onboarding/onboarding-wizard-types.ts +3 -1
- package/src/input/panel-mouse-geometry.ts +97 -0
- package/src/input/panel-paste-flood-guard.ts +86 -0
- package/src/input/selection-modal.ts +11 -0
- package/src/input/session-picker-modal.ts +44 -4
- package/src/input/settings-modal-data.ts +51 -2
- package/src/input/settings-modal-types.ts +4 -2
- package/src/main.ts +28 -27
- package/src/panels/builtin/shared.ts +9 -3
- package/src/panels/fleet-deep-link.ts +31 -0
- package/src/panels/fleet-panel-format.ts +62 -0
- package/src/panels/fleet-panel-worktree-detail.ts +48 -0
- package/src/panels/fleet-panel.ts +89 -131
- package/src/panels/fleet-read-model.ts +43 -0
- package/src/panels/fleet-steer.ts +35 -2
- package/src/panels/fleet-stop.ts +29 -1
- package/src/panels/modals/keybindings-modal.ts +16 -1
- package/src/panels/modals/modal-theme.ts +35 -29
- package/src/panels/modals/pairing-modal.ts +25 -6
- package/src/panels/modals/planning-modal.ts +43 -21
- package/src/panels/modals/work-plan-modal.ts +28 -0
- package/src/panels/panel-manager.ts +15 -4
- package/src/panels/polish-core.ts +38 -25
- package/src/panels/project-planning-answer-actions.ts +8 -13
- package/src/panels/types.ts +24 -0
- package/src/permissions/prompt.ts +160 -13
- package/src/renderer/autocomplete-overlay.ts +28 -2
- package/src/renderer/compositor.ts +2 -3
- package/src/renderer/config-modal.ts +19 -5
- package/src/renderer/footer-tips.ts +5 -1
- package/src/renderer/fullscreen-primitives.ts +32 -22
- package/src/renderer/git-status.ts +3 -1
- package/src/renderer/layout.ts +0 -4
- package/src/renderer/markdown.ts +7 -3
- package/src/renderer/modal-factory.ts +25 -20
- package/src/renderer/model-workspace.ts +18 -3
- package/src/renderer/overlay-box.ts +21 -17
- package/src/renderer/process-indicator.ts +14 -3
- package/src/renderer/selection-modal-overlay.ts +6 -1
- package/src/renderer/session-picker-modal.ts +196 -3
- package/src/renderer/settings-modal-helpers.ts +2 -0
- package/src/renderer/settings-modal.ts +7 -0
- package/src/renderer/shell-surface.ts +8 -1
- package/src/renderer/status-glyphs.ts +14 -15
- package/src/renderer/system-message.ts +15 -3
- package/src/renderer/terminal-bg-probe.ts +339 -0
- package/src/renderer/terminal-escapes.ts +20 -0
- package/src/renderer/theme-mode-config.ts +67 -0
- package/src/renderer/theme.ts +91 -1
- package/src/renderer/thinking.ts +11 -3
- package/src/renderer/tool-call.ts +15 -9
- package/src/renderer/tool-result-summary.ts +148 -0
- package/src/renderer/turn-injection.ts +22 -3
- package/src/renderer/ui-factory.ts +154 -85
- package/src/renderer/ui-primitives.ts +30 -129
- package/src/runtime/bootstrap-command-context.ts +6 -0
- package/src/runtime/bootstrap-command-parts.ts +8 -4
- package/src/runtime/bootstrap-core.ts +33 -11
- package/src/runtime/bootstrap-hook-bridge.ts +7 -0
- package/src/runtime/bootstrap-shell.ts +19 -1
- package/src/runtime/bootstrap.ts +118 -5
- package/src/runtime/code-index-services.ts +25 -2
- package/src/runtime/legacy-daemon-migration.ts +516 -0
- package/src/runtime/memory-fold.ts +26 -0
- package/src/runtime/onboarding/derivation.ts +7 -2
- package/src/runtime/onboarding/snapshot.ts +27 -1
- package/src/runtime/onboarding/types.ts +31 -1
- package/src/runtime/operator-token-cleanup.ts +82 -1
- package/src/runtime/orchestrator-core-services.ts +10 -0
- package/src/runtime/resume-notice.ts +209 -0
- package/src/runtime/services.ts +12 -8
- package/src/runtime/session-inbound-inputs.ts +252 -0
- package/src/runtime/session-spine-transport.ts +64 -0
- package/src/runtime/terminal-output-guard.ts +15 -8
- package/src/runtime/ui-services.ts +19 -3
- package/src/runtime/workstream-services.ts +160 -28
- package/src/runtime/wrfc-persistence.ts +124 -17
- package/src/shell/blocking-input.ts +46 -3
- package/src/shell/recovery-input-helpers.ts +170 -1
- package/src/shell/ui-openers.ts +42 -9
- package/src/utils/terminal-width.ts +52 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* summarizeToolResult — one-line human summaries for tool RESULTS, so the
|
|
3
|
+
* transcript (and the fleet attach view, which shares the same rendering seam
|
|
4
|
+
* in conversation-rendering.ts) shows "wrote haiku.txt (532 B)" instead of a
|
|
5
|
+
* raw `{"files_written":1,"bytes_written":532,...}` JSON blob. The full payload
|
|
6
|
+
* stays reachable behind the existing collapse/expand toggle — this only
|
|
7
|
+
* changes the collapsed preview line, never destroys data. (UX-B item 3.)
|
|
8
|
+
*
|
|
9
|
+
* Returning `null` means "no honest summary for this shape" — the caller then
|
|
10
|
+
* falls back to the previous raw-first-line preview, so an unrecognised tool or
|
|
11
|
+
* an unexpected payload is never mis-summarised.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Compact byte count: "87 B", "1.2 KB", "3.4 MB". */
|
|
15
|
+
function formatBytes(n: number): string {
|
|
16
|
+
if (!Number.isFinite(n) || n < 0) return '? B';
|
|
17
|
+
if (n < 1024) return `${n} B`;
|
|
18
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
19
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Compact duration: "820ms", "1.2s", "1m3s". */
|
|
23
|
+
function formatDuration(ms: number): string {
|
|
24
|
+
if (!Number.isFinite(ms) || ms < 0) return '?';
|
|
25
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
26
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
27
|
+
const s = Math.round(ms / 1000);
|
|
28
|
+
return `${Math.floor(s / 60)}m${s % 60}s`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Trailing filename for compact display (paths can be long/absolute). */
|
|
32
|
+
function baseName(p: string): string {
|
|
33
|
+
const cleaned = p.replace(/[/\\]+$/, '');
|
|
34
|
+
const idx = Math.max(cleaned.lastIndexOf('/'), cleaned.lastIndexOf('\\'));
|
|
35
|
+
return idx >= 0 ? cleaned.slice(idx + 1) : cleaned;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function asNumber(v: unknown): number | undefined {
|
|
39
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function firstPath(files: unknown): string | undefined {
|
|
43
|
+
if (!Array.isArray(files) || files.length === 0) return undefined;
|
|
44
|
+
const first = files[0];
|
|
45
|
+
if (typeof first === 'string') return first;
|
|
46
|
+
if (first && typeof first === 'object') {
|
|
47
|
+
const rec = first as Record<string, unknown>;
|
|
48
|
+
if (typeof rec.path === 'string') return rec.path;
|
|
49
|
+
if (typeof rec.resolved_path === 'string') return rec.resolved_path;
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** write: { files_written, bytes_written, files?: [{path, bytes_written}], dry_run? } */
|
|
55
|
+
function summarizeWrite(obj: Record<string, unknown>): string | null {
|
|
56
|
+
const count = asNumber(obj.files_written);
|
|
57
|
+
const bytes = asNumber(obj.bytes_written);
|
|
58
|
+
if (count === undefined) return null;
|
|
59
|
+
const verb = obj.dry_run === true ? 'would write' : 'wrote';
|
|
60
|
+
const size = bytes !== undefined ? ` (${formatBytes(bytes)})` : '';
|
|
61
|
+
if (count === 1) {
|
|
62
|
+
const path = firstPath(obj.files);
|
|
63
|
+
return path ? `${verb} ${baseName(path)}${size}` : `${verb} 1 file${size}`;
|
|
64
|
+
}
|
|
65
|
+
return `${verb} ${count} files${size}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** read: { summary: { files_read, total_lines }, files?: [{path, lineCount}] } */
|
|
69
|
+
function summarizeRead(obj: Record<string, unknown>): string | null {
|
|
70
|
+
const summary = (obj.summary && typeof obj.summary === 'object')
|
|
71
|
+
? obj.summary as Record<string, unknown>
|
|
72
|
+
: obj;
|
|
73
|
+
const count = asNumber(summary.files_read);
|
|
74
|
+
const lines = asNumber(summary.total_lines);
|
|
75
|
+
if (count === undefined && lines === undefined) return null;
|
|
76
|
+
const lineText = lines !== undefined ? ` (${lines} line${lines === 1 ? '' : 's'})` : '';
|
|
77
|
+
if (count === 1) {
|
|
78
|
+
const path = firstPath(obj.files);
|
|
79
|
+
return path ? `read ${baseName(path)}${lineText}` : `read 1 file${lineText}`;
|
|
80
|
+
}
|
|
81
|
+
return `read ${count ?? '?'} files${lineText}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** edit: { applied, failed, dry_run } or richer with a path. */
|
|
85
|
+
function summarizeEdit(obj: Record<string, unknown>): string | null {
|
|
86
|
+
const applied = asNumber(obj.applied);
|
|
87
|
+
if (applied === undefined) return null;
|
|
88
|
+
const failed = asNumber(obj.failed) ?? 0;
|
|
89
|
+
const verb = obj.dry_run === true ? 'would apply' : 'applied';
|
|
90
|
+
const path = typeof obj.path === 'string' ? obj.path : firstPath(obj.files);
|
|
91
|
+
const target = path ? ` to ${baseName(path)}` : '';
|
|
92
|
+
const failText = failed > 0 ? `, ${failed} failed` : '';
|
|
93
|
+
return `${verb} ${applied} edit${applied === 1 ? '' : 's'}${target}${failText}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A single exec command result. */
|
|
97
|
+
function summarizeExecOne(obj: Record<string, unknown>): string | null {
|
|
98
|
+
const exit = obj.exit_code;
|
|
99
|
+
const dur = asNumber(obj.duration_ms);
|
|
100
|
+
const durText = dur !== undefined ? ` · ${formatDuration(dur)}` : '';
|
|
101
|
+
if (obj.timed_out === true) return `timed out${durText}`;
|
|
102
|
+
if (obj.cancelled === true) return `cancelled${durText}`;
|
|
103
|
+
const stdout = typeof obj.stdout === 'string' ? obj.stdout : '';
|
|
104
|
+
const lineCount = stdout.length === 0
|
|
105
|
+
? 0
|
|
106
|
+
: stdout.replace(/\n$/, '').split('\n').length;
|
|
107
|
+
const lineText = lineCount > 0 ? ` · ${lineCount} line${lineCount === 1 ? '' : 's'}` : '';
|
|
108
|
+
const code = typeof exit === 'number' ? String(exit) : '?';
|
|
109
|
+
return `exit ${code}${durText}${lineText}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** exec: single { cmd, exit_code, ... } or multi { commands: [...], total }. */
|
|
113
|
+
function summarizeExec(obj: Record<string, unknown>): string | null {
|
|
114
|
+
if (Array.isArray(obj.commands)) {
|
|
115
|
+
const total = asNumber(obj.total) ?? obj.commands.length;
|
|
116
|
+
const failed = obj.commands.filter(
|
|
117
|
+
(c) => c && typeof c === 'object' && (c as Record<string, unknown>).success === false,
|
|
118
|
+
).length;
|
|
119
|
+
return failed > 0
|
|
120
|
+
? `${total} commands · ${failed} failed`
|
|
121
|
+
: `${total} commands · all ok`;
|
|
122
|
+
}
|
|
123
|
+
if ('exit_code' in obj || 'timed_out' in obj || 'cancelled' in obj) {
|
|
124
|
+
return summarizeExecOne(obj);
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function summarizeToolResult(toolName: string | undefined, content: string): string | null {
|
|
130
|
+
if (!toolName) return null;
|
|
131
|
+
const family = toolName.includes('__') ? toolName.split('__').pop()! : toolName;
|
|
132
|
+
let data: unknown;
|
|
133
|
+
try {
|
|
134
|
+
data = JSON.parse(content);
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data)) return null;
|
|
139
|
+
const obj = data as Record<string, unknown>;
|
|
140
|
+
|
|
141
|
+
switch (family) {
|
|
142
|
+
case 'write': return summarizeWrite(obj);
|
|
143
|
+
case 'read': return summarizeRead(obj);
|
|
144
|
+
case 'edit': return summarizeEdit(obj);
|
|
145
|
+
case 'exec': return summarizeExec(obj);
|
|
146
|
+
default: return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -33,6 +33,24 @@ function fmtN(n: number): string {
|
|
|
33
33
|
return n.toLocaleString();
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Annotate each injected id with its source (Stage B). Code-index hits get a
|
|
38
|
+
* ` [code]` tag; memory records are left bare so a memory-only line renders
|
|
39
|
+
* byte-identically to the pre-Stage-B output. `injectedSources` is parallel to
|
|
40
|
+
* `injectedIds`; a missing/short entry defaults to memory (no tag).
|
|
41
|
+
*/
|
|
42
|
+
function labelInjectedIds(entry: TurnInjectionEntry): string {
|
|
43
|
+
const sources = entry.injectedSources ?? [];
|
|
44
|
+
return entry.injectedIds
|
|
45
|
+
.map((id, i) => (sources[i] === 'code-index' ? `${id} [code]` : id))
|
|
46
|
+
.join(', ');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Honest one-clause note when a wired code index was queried but injected nothing this turn. */
|
|
50
|
+
function codeSkipNote(entry: TurnInjectionEntry): string {
|
|
51
|
+
return entry.codeInjectionSkipped ? `, code skipped: ${entry.codeInjectionSkipped}` : '';
|
|
52
|
+
}
|
|
53
|
+
|
|
36
54
|
/** Render a single TurnInjectionRecord as one readable line. */
|
|
37
55
|
export function formatTurnInjectionEntry(entry: TurnInjectionEntry): string {
|
|
38
56
|
const backendTag = entry.embeddingBackend === 'fallback-lexical' ? ' [lexical fallback]' : '';
|
|
@@ -42,7 +60,8 @@ export function formatTurnInjectionEntry(entry: TurnInjectionEntry): string {
|
|
|
42
60
|
const reasonText = entry.reason === 'no records cleared relevance floor'
|
|
43
61
|
? 'nothing injected this turn — nothing cleared the relevance floor'
|
|
44
62
|
: `nothing injected this turn — ${entry.reason ?? 'unknown reason'}`;
|
|
45
|
-
|
|
63
|
+
const codeConsidered = entry.codeCandidatesConsidered ? `, code considered ${entry.codeCandidatesConsidered}` : '';
|
|
64
|
+
return ` turn ${entry.turn}: ${reasonText}${backendTag} (considered ${entry.candidatesConsidered}${codeConsidered}, floor ${entry.relevanceFloor})${codeSkipNote(entry)}`;
|
|
46
65
|
}
|
|
47
66
|
const droppedStr = entry.droppedForBudget.length > 0
|
|
48
67
|
? `, dropped for budget: ${entry.droppedForBudget.join(', ')}`
|
|
@@ -52,8 +71,8 @@ export function formatTurnInjectionEntry(entry: TurnInjectionEntry): string {
|
|
|
52
71
|
// (Wave-5 replay flagged the omission). Truncated to keep the line scannable.
|
|
53
72
|
const queryStr = entry.query ? ` for ${JSON.stringify(truncateQuery(entry.query))}` : '';
|
|
54
73
|
return (
|
|
55
|
-
` turn ${entry.turn}: injected ${entry
|
|
56
|
-
`(~${fmtN(entry.tokenCost)}/${fmtN(entry.budgetTokens)} tok, floor ${entry.relevanceFloor})${droppedStr}${backendTag}`
|
|
74
|
+
` turn ${entry.turn}: injected ${labelInjectedIds(entry)}${queryStr} ` +
|
|
75
|
+
`(~${fmtN(entry.tokenCost)}/${fmtN(entry.budgetTokens)} tok, floor ${entry.relevanceFloor})${droppedStr}${backendTag}${codeSkipNote(entry)}`
|
|
57
76
|
);
|
|
58
77
|
}
|
|
59
78
|
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
import { type Line, type Cell, createEmptyLine, createEmptyCell } from '../types/grid.ts';
|
|
2
2
|
import { LAYOUT } from './layout.ts';
|
|
3
3
|
import { VERSION } from '../version.ts';
|
|
4
|
-
import { fitDisplay, getDisplayWidth, truncateDisplay, wrapText, interpolateColor } from '../utils/terminal-width.ts';
|
|
4
|
+
import { fitDisplay, getDisplayWidth, truncateDisplay, wrapText, interpolateColor, joinPrioritizedSegments, type PrioritizedSegment } 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
|
|
7
|
+
import { GLYPHS } from './ui-primitives.ts';
|
|
8
|
+
import { activeUiTones } from './theme.ts';
|
|
8
9
|
import { formatElapsed } from '../utils/format-elapsed.ts';
|
|
9
10
|
import { abbreviateCount } from '../utils/format-number.ts';
|
|
10
11
|
import { computeContextUsage } from '../core/context-usage.ts';
|
|
11
12
|
import { calcSessionCost, isModelPriced } from '../export/cost-utils.ts';
|
|
12
13
|
import { buildFooterTip, isAgentActive } from './footer-tips.ts';
|
|
13
14
|
import type { StreamMetrics } from '../core/stream-event-wiring.ts';
|
|
15
|
+
import { waitingPhrase, type WaitingState } from '@pellux/goodvibes-sdk/platform/presentation';
|
|
14
16
|
|
|
15
17
|
/** Number of frames before the animated gradient completes one full cycle. */
|
|
16
18
|
const GRADIENT_CYCLE_FRAMES = 50;
|
|
17
|
-
/** Number of frames before rotating to the next thinking phrase (~30 seconds at 80ms/frame). */
|
|
18
|
-
const PHRASE_ROTATION_FRAMES = 375;
|
|
19
19
|
/**
|
|
20
20
|
* Ms since the last STREAM_DELTA before the whimsical phrase rotation freezes
|
|
21
21
|
* and createThinkingFragment shows an honest "stalled Ns" / "reconnecting"
|
|
@@ -72,13 +72,21 @@ function fmtCost(usd: number): string {
|
|
|
72
72
|
* UIFactory - Generates standard UI fragments without needing Ink/React overhead.
|
|
73
73
|
*/
|
|
74
74
|
export class UIFactory {
|
|
75
|
-
|
|
75
|
+
// `version` defaults to the live build VERSION; tests pass a pinned fixture so
|
|
76
|
+
// golden snapshots don't break on every release bump (version-decoupled goldens,
|
|
77
|
+
// mirroring the splash fixture-version pattern).
|
|
78
|
+
public static createHeader(width: number, model: string, provider: string, title?: string, gitInfo?: GitHeaderInfo, version: string = VERSION): Line[] {
|
|
76
79
|
const lines: Line[] = [];
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
+
// Header/footer/thinking paint on the transparent terminal background, so
|
|
81
|
+
// they read chrome.* (light-terminal-aware) — NOT fg.*/state.*, which stay
|
|
82
|
+
// tuned for the opaque dark modal/panel boxes. Read live per render so a
|
|
83
|
+
// mode flip re-resolves without any module reload (see theme.ts).
|
|
84
|
+
const t = activeUiTones();
|
|
85
|
+
const CYAN = t.accent.brand;
|
|
86
|
+
const GREY = t.chrome.faint;
|
|
87
|
+
const TITLE_COLOR = t.chrome.label;
|
|
80
88
|
const brand = ` GoodVibes `;
|
|
81
|
-
const ver = `v${
|
|
89
|
+
const ver = `v${version} `;
|
|
82
90
|
const stats = ` ${model} `;
|
|
83
91
|
const prov = `(${provider}) `;
|
|
84
92
|
const line = createEmptyLine(width);
|
|
@@ -110,11 +118,11 @@ export class UIFactory {
|
|
|
110
118
|
}
|
|
111
119
|
// Build git info segment
|
|
112
120
|
let gitStr = '';
|
|
113
|
-
let gitFg: string =
|
|
121
|
+
let gitFg: string = t.chrome.faint;
|
|
114
122
|
if (gitInfo) {
|
|
115
123
|
gitStr = buildGitSegment(gitInfo).text;
|
|
116
124
|
if (gitInfo.dirty || gitInfo.ahead > 0 || gitInfo.behind > 0) {
|
|
117
|
-
gitFg =
|
|
125
|
+
gitFg = t.chrome.warn; // yellow when dirty or out-of-sync
|
|
118
126
|
}
|
|
119
127
|
}
|
|
120
128
|
const rightSideText = stats + prov;
|
|
@@ -124,7 +132,7 @@ export class UIFactory {
|
|
|
124
132
|
for (const char of stats) { if (rightX < width) line[rightX++] = { char, fg: CYAN, bg: '', bold: true, dim: false, underline: false, italic: false, strikethrough: false }; }
|
|
125
133
|
for (const char of prov) { if (rightX < width) line[rightX++] = { char, fg: GREY, bg: '', bold: false, dim: true, underline: false, italic: false, strikethrough: false }; }
|
|
126
134
|
lines.push(line);
|
|
127
|
-
lines.push(this.stringToLine('━'.repeat(width), width, { fg:
|
|
135
|
+
lines.push(this.stringToLine('━'.repeat(width), width, { fg: t.chrome.faint }));
|
|
128
136
|
return lines;
|
|
129
137
|
}
|
|
130
138
|
|
|
@@ -134,12 +142,16 @@ export class UIFactory {
|
|
|
134
142
|
*/
|
|
135
143
|
public static createMessageBar(
|
|
136
144
|
width: number, text: string,
|
|
137
|
-
bgColor: string = '#2a2a2a', textColor: string =
|
|
145
|
+
bgColor: string = '#2a2a2a', textColor: string = activeUiTones().fg.secondary, prefixStr: string = ' › ',
|
|
138
146
|
strikethrough = false
|
|
139
147
|
): Line[] {
|
|
148
|
+
// A historical user-message pill: it carries its own dark bodyBg, so its fg
|
|
149
|
+
// reads fg.secondary/state.reasoning (light-on-dark) — this is conversation
|
|
150
|
+
// content, not the transparent-terminal chrome, so it is not part of the
|
|
151
|
+
// light-terminal chrome flip.
|
|
140
152
|
return renderConversationFragment(text, width, {
|
|
141
153
|
prefix: prefixStr,
|
|
142
|
-
prefixFg:
|
|
154
|
+
prefixFg: activeUiTones().state.reasoning,
|
|
143
155
|
text: textColor,
|
|
144
156
|
bodyBg: bgColor,
|
|
145
157
|
strikethrough,
|
|
@@ -150,10 +162,11 @@ export class UIFactory {
|
|
|
150
162
|
* createQueuedMessageFragment - Renders a dimmed message bar for queued prompts.
|
|
151
163
|
*/
|
|
152
164
|
public static createQueuedMessageFragment(width: number, text: string): Line[] {
|
|
165
|
+
const t = activeUiTones();
|
|
153
166
|
return renderConversationFragment(text, width, {
|
|
154
167
|
prefix: ' (...) ',
|
|
155
|
-
prefixFg:
|
|
156
|
-
text:
|
|
168
|
+
prefixFg: t.state.reasoning,
|
|
169
|
+
text: t.fg.dim,
|
|
157
170
|
bodyBg: '#1a1a1a',
|
|
158
171
|
dim: true,
|
|
159
172
|
});
|
|
@@ -162,7 +175,7 @@ export class UIFactory {
|
|
|
162
175
|
public static createFooter(
|
|
163
176
|
width: number,
|
|
164
177
|
prompt: string,
|
|
165
|
-
usage: { up: number; down: number; max?: number },
|
|
178
|
+
usage: { up: number; down: number; max?: number; fleetCostUsd?: number | null },
|
|
166
179
|
showExitNotice: boolean,
|
|
167
180
|
lastCopyTime: number,
|
|
168
181
|
model?: string,
|
|
@@ -182,6 +195,9 @@ export class UIFactory {
|
|
|
182
195
|
composerFlags?: readonly string[],
|
|
183
196
|
composerPendingRisk?: 'none' | 'approval-wait' | 'shell' | 'command' | 'remote',
|
|
184
197
|
compact: boolean = false,
|
|
198
|
+
// S3d: honest cross-surface spine posture. Defined ONLY in adopted-daemon
|
|
199
|
+
// mode (undefined in embedded/local), so the segment is absent otherwise.
|
|
200
|
+
sessionSpineStatus?: 'online' | 'offline',
|
|
185
201
|
): Line[] {
|
|
186
202
|
const lines: Line[] = [];
|
|
187
203
|
const promptLines = prompt.split('\n');
|
|
@@ -245,6 +261,21 @@ export class UIFactory {
|
|
|
245
261
|
}
|
|
246
262
|
}
|
|
247
263
|
|
|
264
|
+
// UX-C focus visibility (item 1c): when the panel workspace owns focus
|
|
265
|
+
// and the composer is empty, name the state and the way back — the
|
|
266
|
+
// dimmed prompt alone (TEXT_COLOR/BG_COLOR above) told you nothing was
|
|
267
|
+
// wrong, but not why keystrokes weren't landing here.
|
|
268
|
+
if (!promptFocused && text === '' && i === 0) {
|
|
269
|
+
const hintText = 'panel focused — Esc returns';
|
|
270
|
+
const hintStartX = boxStartX + 2 + prefix.length;
|
|
271
|
+
let hx = hintStartX;
|
|
272
|
+
for (const ch of hintText) {
|
|
273
|
+
if (hx >= boxStartX + boxWidth - 2) break;
|
|
274
|
+
contentLine[hx] = { char: ch, fg: '244', bg: BG_COLOR, bold: false, dim: true, underline: false, italic: false, strikethrough: false };
|
|
275
|
+
hx++;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
248
279
|
// Overlay args hint: dim grey text after cursor on the last prompt line.
|
|
249
280
|
// Only shown when a commandArgsHint is provided and cursor is at the end of input.
|
|
250
281
|
if (commandArgsHint && i === promptLines.length - 1) {
|
|
@@ -261,10 +292,17 @@ export class UIFactory {
|
|
|
261
292
|
if (cursorColOnLine >= text.length) {
|
|
262
293
|
// Hint starts one cell after the cursor block
|
|
263
294
|
const hintStartX = boxStartX + 2 + prefix.length + text.length + 1;
|
|
264
|
-
const
|
|
295
|
+
const hintLimit = boxStartX + boxWidth - 2; // exclusive right bound
|
|
296
|
+
const avail = Math.max(0, hintLimit - hintStartX);
|
|
297
|
+
// Clamp to the available width with an ellipsis so a long arg-spec
|
|
298
|
+
// (e.g. /marketplace) ends in "…" rather than a hard mid-word cut. (5b)
|
|
299
|
+
let hintText = ' ' + commandArgsHint;
|
|
300
|
+
if (hintText.length > avail) {
|
|
301
|
+
hintText = avail > 1 ? `${hintText.slice(0, avail - 1)}…` : hintText.slice(0, Math.max(0, avail));
|
|
302
|
+
}
|
|
265
303
|
let hx = hintStartX;
|
|
266
304
|
for (const ch of hintText) {
|
|
267
|
-
if (hx >=
|
|
305
|
+
if (hx >= hintLimit) break;
|
|
268
306
|
contentLine[hx] = { char: ch, fg: '238', bg: BG_COLOR, bold: false, dim: true, underline: false, italic: false, strikethrough: false };
|
|
269
307
|
hx++;
|
|
270
308
|
}
|
|
@@ -290,16 +328,19 @@ export class UIFactory {
|
|
|
290
328
|
// --- Composer posture block (mode / risk / status / flags) ------------
|
|
291
329
|
// Suppressed in compact mode; the ctx-info line no longer duplicates these
|
|
292
330
|
// tokens, so this block is the single home for mode/status/flags.
|
|
331
|
+
// Footer chrome paints on the transparent terminal bg → chrome.* accents
|
|
332
|
+
// (light-terminal-aware); state.info reads on both light and dark terminals.
|
|
333
|
+
const t = activeUiTones();
|
|
293
334
|
const composerTokens: Array<{ text: string; fg: string; bold?: boolean; dim?: boolean }> = [];
|
|
294
|
-
if (composerMode) composerTokens.push({ text: ` ${GLYPHS.status.active} ${composerMode} `, fg:
|
|
335
|
+
if (composerMode) composerTokens.push({ text: ` ${GLYPHS.status.active} ${composerMode} `, fg: t.state.info, bold: true });
|
|
295
336
|
if (composerPendingRisk && composerPendingRisk !== 'none') {
|
|
296
337
|
const riskColor = composerPendingRisk === 'approval-wait'
|
|
297
|
-
?
|
|
338
|
+
? t.chrome.warn
|
|
298
339
|
: composerPendingRisk === 'shell'
|
|
299
|
-
?
|
|
340
|
+
? t.chrome.bad
|
|
300
341
|
: composerPendingRisk === 'remote'
|
|
301
|
-
?
|
|
302
|
-
:
|
|
342
|
+
? t.chrome.remote
|
|
343
|
+
: t.chrome.warn;
|
|
303
344
|
composerTokens.push({ text: ` risk:${composerPendingRisk} `, fg: riskColor, bold: true });
|
|
304
345
|
}
|
|
305
346
|
if (composerStatus && composerStatus !== 'idle') composerTokens.push({ text: ` state:${composerStatus} `, fg: '244', dim: true });
|
|
@@ -329,7 +370,7 @@ export class UIFactory {
|
|
|
329
370
|
const isRecentlyCopied = Date.now() - lastCopyTime < 2000;
|
|
330
371
|
// Token usage line + running ~$ cost estimate (derived from the usage
|
|
331
372
|
// object and the active model via cost-utils).
|
|
332
|
-
const u = usage as { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; up?: number; down?: number };
|
|
373
|
+
const u = usage as { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; up?: number; down?: number; fleetCostUsd?: number | null };
|
|
333
374
|
const inp = u.input ?? u.up ?? 0;
|
|
334
375
|
const out = u.output ?? u.down ?? 0;
|
|
335
376
|
const cr = u.cacheRead ?? 0;
|
|
@@ -340,12 +381,29 @@ export class UIFactory {
|
|
|
340
381
|
// match the existing "no priceable data" convention used elsewhere
|
|
341
382
|
// (cockpit-panel formatCost, agent-inspector-shared) — the footer has no
|
|
342
383
|
// room for a longer marker before truncation kicks in.
|
|
343
|
-
const
|
|
384
|
+
const mainCostStr = model
|
|
344
385
|
? isModelPriced(model)
|
|
345
|
-
?
|
|
346
|
-
:
|
|
347
|
-
:
|
|
348
|
-
|
|
386
|
+
? `~$${fmtCost(calcSessionCost(inp, out, cr, cw, model))}`
|
|
387
|
+
: '~n/a'
|
|
388
|
+
: null;
|
|
389
|
+
// Honest total = your main session + the delegated fleet. We show a SPLIT
|
|
390
|
+
// ("you ~$X · fleet ~$Y") rather than one summed figure: the two costs are
|
|
391
|
+
// tracked and attributed to different actors, and the split directly answers
|
|
392
|
+
// "where did the money go" — the exact confusion in the eval where the footer
|
|
393
|
+
// showed only the main session (~$0.046) while the fleet cost ~10x more
|
|
394
|
+
// ($0.446). The fleet segment appears only when there is a real fleet cost, so
|
|
395
|
+
// the idle single-session footer is unchanged.
|
|
396
|
+
const fleetCost = u.fleetCostUsd;
|
|
397
|
+
const hasFleetCost = typeof fleetCost === 'number' && fleetCost > 0;
|
|
398
|
+
const costSegment = hasFleetCost
|
|
399
|
+
? `${tokenSep}you ${mainCostStr ?? '~n/a'} · fleet ~$${fmtCost(fleetCost)}`
|
|
400
|
+
: mainCostStr
|
|
401
|
+
? `${tokenSep}${mainCostStr}`
|
|
402
|
+
: '';
|
|
403
|
+
// Input tokens are unknown until the first assistant usage lands (often the
|
|
404
|
+
// first tool turn). Show "—" rather than a false 0 during that window. (5c)
|
|
405
|
+
const inpDisplay = inp > 0 ? fmtNum(inp) : '—';
|
|
406
|
+
const tokenLine = ` Token Usage [ Input: ${inpDisplay}${tokenSep}Output: ${fmtNum(out)}${tokenSep}Cache Read: ${fmtNum(cr)}${tokenSep}Cache Write: ${fmtNum(cw)}${tokenSep}Total: ${fmtNum(total)}${costSegment} ]`;
|
|
349
407
|
const copiedNotice = isRecentlyCopied ? ` [COPIED] ` : '';
|
|
350
408
|
const statsLine = ' ' + tokenLine + ' '.repeat(Math.max(0, width - 4 - getDisplayWidth(tokenLine) - getDisplayWidth(copiedNotice))) + copiedNotice;
|
|
351
409
|
lines.push(this.stringToLine(statsLine, width, { fg: isRecentlyCopied ? '81' : '244', bold: isRecentlyCopied }));
|
|
@@ -353,7 +411,9 @@ export class UIFactory {
|
|
|
353
411
|
if (!compact && contextWindow && contextWindow > 0) {
|
|
354
412
|
const ctxTokens = lastInputTokens ?? 0;
|
|
355
413
|
const label = ' Context Usage: ';
|
|
356
|
-
|
|
414
|
+
// "—" until the first input-token count is known, not a false 0. (5c)
|
|
415
|
+
const ctxDisplay = ctxTokens > 0 ? fmtNum(ctxTokens) : '—';
|
|
416
|
+
const suffix = ` [ ${ctxDisplay} / ${fmtNum(contextWindow)} ]`;
|
|
357
417
|
const barWidth = Math.max(10, Math.min(30, width - getDisplayWidth(label) - getDisplayWidth(suffix) - 8));
|
|
358
418
|
const ctxPct = computeContextUsage(ctxTokens, contextWindow).clampedRatio;
|
|
359
419
|
// Clamp threshold to [0..1]; undefined/0 means no threshold marker.
|
|
@@ -370,19 +430,30 @@ export class UIFactory {
|
|
|
370
430
|
const displayDir = workingDir && home && workingDir.startsWith(home)
|
|
371
431
|
? '~' + workingDir.slice(home.length)
|
|
372
432
|
: workingDir ?? '';
|
|
373
|
-
|
|
374
|
-
|
|
433
|
+
// Segments carry a survival priority (lower = survives longer) so that
|
|
434
|
+
// under width pressure whole low-value segments are dropped before any
|
|
435
|
+
// segment is character-truncated mid-word. cwd/model are the essential
|
|
436
|
+
// orientation segments (priority 0); the spine liveness marker is the
|
|
437
|
+
// daemon-honesty signal and must outlive the two decorative segments
|
|
438
|
+
// (tool count, notify mode) — it is ordered ahead of them too, so a
|
|
439
|
+
// narrow line drops "N tools"/"notify:x" whole before spine is at risk.
|
|
440
|
+
const ctxParts: PrioritizedSegment[] = [];
|
|
441
|
+
if (displayDir) ctxParts.push({ text: displayDir, priority: 0 });
|
|
375
442
|
if (model) {
|
|
376
|
-
ctxParts.push(model + (provider ? ` (${provider})` : ''));
|
|
443
|
+
ctxParts.push({ text: model + (provider ? ` (${provider})` : ''), priority: 0 });
|
|
377
444
|
}
|
|
378
|
-
|
|
445
|
+
// S3d: cross-surface spine posture — plain words, no blame. Adopted mode only.
|
|
446
|
+
if (sessionSpineStatus) ctxParts.push({ text: `spine:${sessionSpineStatus}`, priority: 1 });
|
|
447
|
+
if (toolCount) ctxParts.push({ text: `${toolCount} tools`, priority: 2 });
|
|
379
448
|
// Labeled "notify" (not "hitl") — /mode (aliased /hitl) governs UX
|
|
380
449
|
// notification verbosity (quiet/balanced/operator), not tool
|
|
381
450
|
// 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}
|
|
384
|
-
const
|
|
385
|
-
|
|
451
|
+
// risk banner rendered a few lines below. Lowest priority: dropped first.
|
|
452
|
+
if (hitlMode) ctxParts.push({ text: `notify:${hitlMode}`, priority: 3 });
|
|
453
|
+
const sep = ` ${GLYPHS.navigation.pipeSeparator} `;
|
|
454
|
+
const ctxBody = joinPrioritizedSegments(ctxParts, sep, width - 3);
|
|
455
|
+
const ctxLine = ' ' + ctxBody;
|
|
456
|
+
lines.push(this.stringToLine(ctxLine, width, { fg: '240', dim: true }));
|
|
386
457
|
}
|
|
387
458
|
if (showExitNotice) {
|
|
388
459
|
const notice = ` !!! Press Ctrl+C again to exit !!! `;
|
|
@@ -403,7 +474,7 @@ export class UIFactory {
|
|
|
403
474
|
for (const ch of dangerWarn) {
|
|
404
475
|
if (col >= width) break;
|
|
405
476
|
const cw = getDisplayWidth(ch);
|
|
406
|
-
line[col] = { char: ch, fg:
|
|
477
|
+
line[col] = { char: ch, fg: t.chrome.bad, bg: '', bold: true, dim: false, underline: false, italic: false, strikethrough: false };
|
|
407
478
|
if (cw === 2 && col + 1 < width) line[col + 1] = { ...line[col], char: '' };
|
|
408
479
|
col += cw;
|
|
409
480
|
}
|
|
@@ -413,33 +484,20 @@ export class UIFactory {
|
|
|
413
484
|
return lines;
|
|
414
485
|
}
|
|
415
486
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
'Entering flow state...',
|
|
426
|
-
'Consulting the void...',
|
|
427
|
-
'Absorbing aesthetics...',
|
|
428
|
-
'Synthesizing vibes...',
|
|
429
|
-
'Transcending...',
|
|
430
|
-
'Dreaming in neon...',
|
|
431
|
-
'Parsing the cosmos...',
|
|
432
|
-
'Loading good vibes...',
|
|
433
|
-
'Meditating...',
|
|
434
|
-
'Catching a vibe...',
|
|
435
|
-
'Harmonizing...',
|
|
436
|
-
'Feeling it...',
|
|
437
|
-
'In the zone...',
|
|
438
|
-
];
|
|
487
|
+
// W6-P1: the rotating "thinking" phrase pool and the honest waiting-state
|
|
488
|
+
// wording (approval/reconnecting/pre-first-token/stalled/thinking) are no
|
|
489
|
+
// longer minted here. They come from the SDK presentation contract's
|
|
490
|
+
// waitingPhrase() (@pellux/goodvibes-sdk/platform/presentation, landed by
|
|
491
|
+
// W4-S1, already adopted by the agent in W4-R4) — see createThinkingFragment
|
|
492
|
+
// below. This renderer still decides WHICH state applies from its own
|
|
493
|
+
// stall/reconnect/approval signals (computeStallInfo/computeRenderStallInfo
|
|
494
|
+
// stay renderer-local per the extraction decision record); only the exact
|
|
495
|
+
// wording is shared.
|
|
439
496
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
497
|
+
// Gradient colors for thinking text — cyan→purple in dark, teal→purple in
|
|
498
|
+
// light (matches splash/brand). Read live per render inside
|
|
499
|
+
// createThinkingFragment via activeUiTones() rather than baked into a static
|
|
500
|
+
// field, so a mode flip re-resolves without a module reload.
|
|
443
501
|
|
|
444
502
|
/**
|
|
445
503
|
* Per-frame stall info from stream metrics — computed from lastDeltaAtMs every render (not
|
|
@@ -478,25 +536,36 @@ export class UIFactory {
|
|
|
478
536
|
: undefined;
|
|
479
537
|
}
|
|
480
538
|
|
|
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[] {
|
|
539
|
+
public static createThinkingFragment(width: number, spinner: string, frame: number = 0, tokenSpeed?: number, toolPreview?: string, inputTokens?: number, outputTokens?: number, elapsedMs?: number, ttftMs?: number, stallInfo?: ThinkingStallInfo, approvalPending?: boolean): Line[] {
|
|
540
|
+
// Live thinking row paints on the transparent terminal bg → read the
|
|
541
|
+
// mode-resolved chrome tones per render (gradient/brand/tool accents flip).
|
|
542
|
+
const tones = activeUiTones();
|
|
482
543
|
// Freeze the whimsical phrase rotation once real silence has gone on
|
|
483
|
-
// long enough to be misleading (THINKING_STALL_FREEZE_MS)
|
|
484
|
-
// honest
|
|
485
|
-
//
|
|
544
|
+
// long enough to be misleading (THINKING_STALL_FREEZE_MS). Decide WHICH
|
|
545
|
+
// honest waiting state applies (renderer-local — this signal computation
|
|
546
|
+
// stays here per the extraction decision record), then defer the exact
|
|
547
|
+
// wording to the SDK presentation contract's waitingPhrase() (W6-P1;
|
|
548
|
+
// mirrors the agent's W4-R4 adoption). Precedence matches the contract:
|
|
549
|
+
// approval > reconnecting > pre-first-token > stalled > thinking.
|
|
486
550
|
const isStalled = stallInfo !== undefined && stallInfo.msSinceLastDelta >= THINKING_STALL_FREEZE_MS;
|
|
487
|
-
let
|
|
488
|
-
if (
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
551
|
+
let state: WaitingState;
|
|
552
|
+
if (approvalPending) state = 'approval';
|
|
553
|
+
else if (stallInfo?.reconnect) state = 'reconnecting';
|
|
554
|
+
else if (isStalled && (outputTokens ?? 0) === 0) state = 'pre-first-token';
|
|
555
|
+
else if (isStalled) state = 'stalled';
|
|
556
|
+
else state = 'thinking';
|
|
557
|
+
const phrase = waitingPhrase(state, {
|
|
558
|
+
reconnectAttempt: stallInfo?.reconnect?.attempt,
|
|
559
|
+
reconnectMaxAttempts: stallInfo?.reconnect?.maxAttempts,
|
|
560
|
+
msSinceLastDelta: stallInfo?.msSinceLastDelta,
|
|
561
|
+
frame,
|
|
562
|
+
});
|
|
563
|
+
// Token-rate and time-to-first-token readouts are meaningless while waiting on the user, and a
|
|
564
|
+
// "tok/s" figure next to an approval prompt reads as if the model were still working — suppress
|
|
565
|
+
// them; keep the elapsed timer since "how long the approval has waited" is honest.
|
|
566
|
+
const speedSuffix = (!approvalPending && tokenSpeed !== undefined && tokenSpeed > 0) ? ` (${Math.round(tokenSpeed)} tok/s)` : '';
|
|
498
567
|
const elapsedSuffix = elapsedMs !== undefined ? ` (${formatElapsed(elapsedMs)})` : '';
|
|
499
|
-
const ttftSuffix = (ttftMs !== undefined && ttftMs > 0) ? ` ttft:${ttftMs}ms` : '';
|
|
568
|
+
const ttftSuffix = (!approvalPending && ttftMs !== undefined && ttftMs > 0) ? ` ttft:${ttftMs}ms` : '';
|
|
500
569
|
const text = ` ${spinner} ${phrase}${speedSuffix}${elapsedSuffix}${ttftSuffix} `;
|
|
501
570
|
|
|
502
571
|
const textWidth = Math.max(1, getDisplayWidth(text) - 1);
|
|
@@ -506,7 +575,7 @@ export class UIFactory {
|
|
|
506
575
|
const gradientPos = raw <= 0.5 ? raw * 2 : (1 - raw) * 2;
|
|
507
576
|
return {
|
|
508
577
|
text: char,
|
|
509
|
-
fg: interpolateColor(
|
|
578
|
+
fg: interpolateColor(tones.accent.gradientStart, tones.accent.gradientEnd, gradientPos),
|
|
510
579
|
bold: true,
|
|
511
580
|
};
|
|
512
581
|
});
|
|
@@ -514,7 +583,7 @@ export class UIFactory {
|
|
|
514
583
|
const inTok = inputTokens ?? 0;
|
|
515
584
|
const outTok = outputTokens ?? 0;
|
|
516
585
|
segments.push({ text: ` in ${fmtNum(inTok)} `, fg: '243', dim: true });
|
|
517
|
-
segments.push({ text: `out ${fmtNum(outTok)}`, fg:
|
|
586
|
+
segments.push({ text: `out ${fmtNum(outTok)}`, fg: tones.accent.brand });
|
|
518
587
|
}
|
|
519
588
|
const line = createEmptyLine(width);
|
|
520
589
|
let col = 1;
|
|
@@ -554,7 +623,7 @@ export class UIFactory {
|
|
|
554
623
|
if (px >= width) break;
|
|
555
624
|
previewLine[px] = {
|
|
556
625
|
char: ch,
|
|
557
|
-
fg:
|
|
626
|
+
fg: tones.state.info,
|
|
558
627
|
bg: '',
|
|
559
628
|
bold: true,
|
|
560
629
|
dim: false,
|