@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
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
import { type ConfirmState, handleConfirmInput, renderConfirmLines } from './confirm-state.ts';
|
|
23
23
|
import { abbreviateCount } from '../utils/format-number.ts';
|
|
24
24
|
import { formatLatencyMs } from '../utils/format-duration.ts';
|
|
25
|
-
import { calcSessionCost } from '../export/cost-utils.ts';
|
|
25
|
+
import { calcSessionCost, isModelPriced } from '../export/cost-utils.ts';
|
|
26
26
|
|
|
27
27
|
// ---------------------------------------------------------------------------
|
|
28
28
|
// Types
|
|
@@ -97,7 +97,9 @@ function fmtAgo(ts: number): string {
|
|
|
97
97
|
return `${Math.floor(sec / 3600)}h ago`;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
|
|
100
|
+
/** unpriced=true renders an honest "unpriced" marker instead of a $0 that could be mistaken for a real zero cost. */
|
|
101
|
+
function fmtUsd(value: number, unpriced = false): string {
|
|
102
|
+
if (unpriced) return 'unpriced';
|
|
101
103
|
if (value <= 0) return '$0';
|
|
102
104
|
return value >= 1 ? `$${value.toFixed(2)}` : `$${value.toFixed(4)}`;
|
|
103
105
|
}
|
|
@@ -394,7 +396,7 @@ export class DebugPanel extends ScrollableListPanel<ApiCallEntry> {
|
|
|
394
396
|
{ text: fmtTok(entry.inputTokens), fg: C.input },
|
|
395
397
|
{ text: fmtTok(entry.outputTokens), fg: C.output },
|
|
396
398
|
{ text: fmtMs(entry.latencyMs), fg: latColor(entry.latencyMs) },
|
|
397
|
-
{ text: fmtUsd(callCost(entry)), fg: C.value },
|
|
399
|
+
{ text: fmtUsd(callCost(entry), !isModelPriced(entry.model)), fg: C.value },
|
|
398
400
|
],
|
|
399
401
|
CALL_COLUMNS,
|
|
400
402
|
{ selected, selectedBg: C.selectBg },
|
|
@@ -498,7 +500,7 @@ export class DebugPanel extends ScrollableListPanel<ApiCallEntry> {
|
|
|
498
500
|
{ label: 'in', value: String(selected.inputTokens), valueColor: C.input },
|
|
499
501
|
{ label: 'out', value: String(selected.outputTokens), valueColor: C.output },
|
|
500
502
|
{ label: 'latency', value: fmtMs(selected.latencyMs), valueColor: latColor(selected.latencyMs) },
|
|
501
|
-
{ label: 'cost', value: fmtUsd(callCost(selected)), valueColor: C.value },
|
|
503
|
+
{ label: 'cost', value: fmtUsd(callCost(selected), !isModelPriced(selected.model)), valueColor: C.value },
|
|
502
504
|
], C),
|
|
503
505
|
...(selected.errorMessage
|
|
504
506
|
? buildBodyText(width, selected.errorMessage, C, C.bad)
|
|
@@ -573,6 +575,10 @@ export class DebugPanel extends ScrollableListPanel<ApiCallEntry> {
|
|
|
573
575
|
: 0;
|
|
574
576
|
const sessionTokens = this._calls.reduce((s, c) => s + c.inputTokens + c.outputTokens, 0);
|
|
575
577
|
const sessionCost = this._calls.reduce((s, c) => s + callCost(c), 0);
|
|
578
|
+
// Only flag the aggregate as unpriced when every call is — a partial mix
|
|
579
|
+
// still sums the priced calls honestly; only the fully-unpriced case
|
|
580
|
+
// would otherwise render as a misleading $0.
|
|
581
|
+
const sessionAllUnpriced = this._calls.length > 0 && this._calls.every((c) => !isModelPriced(c.model));
|
|
576
582
|
|
|
577
583
|
const lines: Line[] = [
|
|
578
584
|
buildStyledPanelLine(width, [
|
|
@@ -587,7 +593,7 @@ export class DebugPanel extends ScrollableListPanel<ApiCallEntry> {
|
|
|
587
593
|
{ text: ' Tokens ', fg: C.label },
|
|
588
594
|
{ text: fmtTok(sessionTokens), fg: C.value },
|
|
589
595
|
{ text: ' Cost ', fg: C.label },
|
|
590
|
-
{ text: fmtUsd(sessionCost), fg: C.value },
|
|
596
|
+
{ text: fmtUsd(sessionCost, sessionAllUnpriced), fg: C.value },
|
|
591
597
|
]),
|
|
592
598
|
];
|
|
593
599
|
// Live status: most recent call (latency / age) or wiring hint.
|
package/src/panels/diff-panel.ts
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
import type { Line } from '../types/grid.ts';
|
|
6
6
|
import { createStyledCell, createEmptyLine } from '../types/grid.ts';
|
|
7
7
|
import { truncateDisplay, getDisplayWidth } from '../utils/terminal-width.ts';
|
|
8
|
+
import { GitService } from '@pellux/goodvibes-sdk/platform/git';
|
|
8
9
|
import { BasePanel } from './base-panel.ts';
|
|
9
|
-
import { UI_TONES } from '../renderer/ui-primitives.ts';
|
|
10
|
+
import { UI_TONES, DIFF_TONES } from '../renderer/ui-primitives.ts';
|
|
10
11
|
import { FilePreviewPanel } from './file-preview-panel.ts';
|
|
11
12
|
import type { PanelIntegrationContext } from './types.ts';
|
|
12
13
|
import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
@@ -30,14 +31,20 @@ import {
|
|
|
30
31
|
// DEFAULT_PANEL_PALETTE.headerBg (WO-002: one title band everywhere).
|
|
31
32
|
// ---------------------------------------------------------------------------
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
// Hunk blue is the shared DIFF_TONES token (WO-204) — diff-view.ts (conversation)
|
|
35
|
+
// and git-panel.ts's inline diff converge onto this file's pre-existing value.
|
|
36
|
+
const HUNK_BLUE: string = DIFF_TONES.hunk;
|
|
34
37
|
// Context rows and line-number gutter use the shared theme's muted/dim
|
|
35
38
|
// foreground tones rather than dedicated gray hex literals.
|
|
36
39
|
const CONTEXT_GRAY = UI_TONES.fg.muted;
|
|
37
40
|
const FILENAME_WHITE = '#ffffff';
|
|
38
|
-
|
|
41
|
+
// Add/del text colors are the shared DIFF_TONES tokens, whose values ARE this
|
|
42
|
+
// panel's shipped colors — this panel is the reference diff look (diff-view
|
|
43
|
+
// was unwired dead code until WO-204, so "majority of surfaces" was a mirage);
|
|
44
|
+
// the conversation surface converges onto these, not the reverse.
|
|
45
|
+
const ADD_GREEN: string = DIFF_TONES.add;
|
|
39
46
|
const ADD_BG = '#001a0d';
|
|
40
|
-
const DEL_RED =
|
|
47
|
+
const DEL_RED: string = DIFF_TONES.del;
|
|
41
48
|
const DEL_BG = '#1a0000';
|
|
42
49
|
const HUNK_BG = '#0a0a1a';
|
|
43
50
|
const MARKER_GRAY = '#aaaaaa';
|
|
@@ -160,20 +167,49 @@ function parseDiff(raw: string): ParsedLine[] {
|
|
|
160
167
|
// Split a full `git diff` output into per-file entries
|
|
161
168
|
// ---------------------------------------------------------------------------
|
|
162
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Synthetic placeholder entries ('(error)', '(no changes)', '(no staged
|
|
172
|
+
* changes)', '(not a git repo)') are not real diffs — parenthesized so they
|
|
173
|
+
* can be styled distinctly in the tab/status bar instead of reading as a fake
|
|
174
|
+
* diff of a real file.
|
|
175
|
+
*/
|
|
176
|
+
function isPlaceholderPath(filePath: string): boolean {
|
|
177
|
+
return filePath.startsWith('(') && filePath.endsWith(')');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Extract the file path from one `diff --git`-delimited chunk. Tries, in
|
|
182
|
+
* order: the standard two-sided header (`diff --git a/x b/y`, bare or
|
|
183
|
+
* quoted), the combined/merge-conflict header (`diff --cc <path>` /
|
|
184
|
+
* `diff --combined <path>`, which carries no a/b prefixes), then falls back
|
|
185
|
+
* to the `+++`/`---` file lines (present across every variant, uniformly) —
|
|
186
|
+
* skipping a `/dev/null` side (deleted/new file). 'unknown' is reserved for
|
|
187
|
+
* genuinely unparseable input.
|
|
188
|
+
*/
|
|
189
|
+
function extractDiffFilePath(trimmed: string): string {
|
|
190
|
+
const quotedGit = trimmed.match(/^diff --git "a\/(.+)" "b\/(.+)"$/m);
|
|
191
|
+
if (quotedGit) return quotedGit[2]!;
|
|
192
|
+
const plainGit = trimmed.match(/^diff --git a\/.+? b\/(.+)$/m);
|
|
193
|
+
if (plainGit) return plainGit[1]!;
|
|
194
|
+
const combined = trimmed.match(/^diff --(?:cc|combined) "?([^"\n]+?)"?$/m);
|
|
195
|
+
if (combined) return combined[1]!;
|
|
196
|
+
const plus = trimmed.match(/^\+\+\+ (?:b\/)?"?([^"\n]+?)"?\s*$/m);
|
|
197
|
+
if (plus && plus[1] !== '/dev/null') return plus[1]!;
|
|
198
|
+
const minus = trimmed.match(/^--- (?:a\/)?"?([^"\n]+?)"?\s*$/m);
|
|
199
|
+
if (minus && minus[1] !== '/dev/null') return minus[1]!;
|
|
200
|
+
return 'unknown';
|
|
201
|
+
}
|
|
202
|
+
|
|
163
203
|
function splitIntoDiffEntries(raw: string): DiffEntry[] {
|
|
164
204
|
const entries: DiffEntry[] = [];
|
|
165
|
-
// Split on "diff --git" lines
|
|
166
|
-
const chunks = raw.split(/(?=^diff --git )/m);
|
|
205
|
+
// Split on "diff --git" / "diff --cc" / "diff --combined" lines
|
|
206
|
+
const chunks = raw.split(/(?=^diff --git |^diff --cc |^diff --combined )/m);
|
|
167
207
|
for (const chunk of chunks) {
|
|
168
208
|
const trimmed = chunk.trim();
|
|
169
209
|
if (!trimmed) continue;
|
|
170
210
|
|
|
171
|
-
// Extract file path from "diff --git a/foo b/foo"
|
|
172
|
-
const match = trimmed.match(/^diff --git a\/.+? b\/(.+)$/m);
|
|
173
|
-
const filePath = match ? match[1]! : 'unknown';
|
|
174
|
-
|
|
175
211
|
entries.push({
|
|
176
|
-
filePath,
|
|
212
|
+
filePath: extractDiffFilePath(trimmed),
|
|
177
213
|
raw: chunk,
|
|
178
214
|
lines: parseDiff(chunk),
|
|
179
215
|
});
|
|
@@ -229,7 +265,15 @@ export class DiffPanel extends BasePanel {
|
|
|
229
265
|
/** Set by the 'o' key; consumed by handlePanelIntegrationAction on the next dispatch. */
|
|
230
266
|
private pendingOpenPreview = false;
|
|
231
267
|
|
|
232
|
-
|
|
268
|
+
/** One-line confirmation for the 'w'/'h'/'s' self-load hotkeys — otherwise a
|
|
269
|
+
* reload that produces identical content is visually indistinguishable
|
|
270
|
+
* from the keypress doing nothing at all. */
|
|
271
|
+
private hotkeyStatus: string | null = null;
|
|
272
|
+
|
|
273
|
+
constructor(
|
|
274
|
+
workingDirectory: string,
|
|
275
|
+
private readonly requestRender: () => void = () => {},
|
|
276
|
+
) {
|
|
233
277
|
super('diff', 'Diff', 'D', 'development');
|
|
234
278
|
this.workingDirectory = workingDirectory;
|
|
235
279
|
}
|
|
@@ -265,12 +309,33 @@ export class DiffPanel extends BasePanel {
|
|
|
265
309
|
this.markDirty();
|
|
266
310
|
}
|
|
267
311
|
|
|
312
|
+
/**
|
|
313
|
+
* Defensive gate mirroring GitPanel's own notGitRepo messaging — short-
|
|
314
|
+
* circuits before any of this panel's self-load git spawns run, instead of
|
|
315
|
+
* letting git fail per-subcommand with an inconsistent error shape.
|
|
316
|
+
*/
|
|
317
|
+
private showNotGitRepo(): void {
|
|
318
|
+
this.showDiff('(not a git repo)', '@@ -0,0 +0,0 @@\n Not a git repository here.');
|
|
319
|
+
}
|
|
320
|
+
|
|
268
321
|
/** Run `git diff` against specific files and populate entries. */
|
|
269
322
|
async showFileDiffs(files: string[], ref?: string): Promise<void> {
|
|
323
|
+
if (!GitService.isGitRepo(this.workingDirectory)) {
|
|
324
|
+
this.showNotGitRepo();
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
270
327
|
const args = ['diff', ...(ref ? [ref] : []), '--', ...files];
|
|
271
|
-
const proc = Bun.spawn(['git', ...args], { stdout: 'pipe', cwd: this.workingDirectory });
|
|
272
|
-
const raw = await
|
|
273
|
-
|
|
328
|
+
const proc = Bun.spawn(['git', ...args], { stdout: 'pipe', stderr: 'pipe', cwd: this.workingDirectory });
|
|
329
|
+
const [raw, errText] = await Promise.all([
|
|
330
|
+
new Response(proc.stdout).text(),
|
|
331
|
+
new Response(proc.stderr).text(),
|
|
332
|
+
]);
|
|
333
|
+
const exitCode = await proc.exited;
|
|
334
|
+
if (exitCode !== 0) {
|
|
335
|
+
const errorText = errText.trim() || 'git diff failed';
|
|
336
|
+
this.showDiff('(error)', `--- error\n+++ error\n@@ -0,0 +1,1 @@\n+${errorText}`);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
274
339
|
this.loadRawDiff(raw);
|
|
275
340
|
this.enrichSemanticDiffs(files, ref ?? 'HEAD').catch((err) => { logger.debug('DiffPanel: semantic diff enrichment failed', { err }); });
|
|
276
341
|
}
|
|
@@ -281,6 +346,10 @@ export class DiffPanel extends BasePanel {
|
|
|
281
346
|
* enriches every loaded file with a semantic-diff summary in the background.
|
|
282
347
|
*/
|
|
283
348
|
async showGitDiff(ref?: string): Promise<void> {
|
|
349
|
+
if (!GitService.isGitRepo(this.workingDirectory)) {
|
|
350
|
+
this.showNotGitRepo();
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
284
353
|
const args = ['diff', ...(ref ? [ref] : [])];
|
|
285
354
|
const proc = Bun.spawn(['git', ...args], { stdout: 'pipe', stderr: 'pipe', cwd: this.workingDirectory });
|
|
286
355
|
const [raw, errText] = await Promise.all([
|
|
@@ -319,6 +388,10 @@ export class DiffPanel extends BasePanel {
|
|
|
319
388
|
* `/diff staged` command handler.
|
|
320
389
|
*/
|
|
321
390
|
async showStagedDiff(): Promise<void> {
|
|
391
|
+
if (!GitService.isGitRepo(this.workingDirectory)) {
|
|
392
|
+
this.showNotGitRepo();
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
322
395
|
const proc = Bun.spawn(['/bin/sh', '-c', 'git diff --cached'], { stdout: 'pipe', stderr: 'pipe', cwd: this.workingDirectory });
|
|
323
396
|
const [raw, errText] = await Promise.all([
|
|
324
397
|
new Response(proc.stdout).text(),
|
|
@@ -347,7 +420,7 @@ export class DiffPanel extends BasePanel {
|
|
|
347
420
|
if (files.length === 0) return;
|
|
348
421
|
const { computeSemanticDiff, formatSemanticDiffSummary } = await import('../renderer/semantic-diff.ts');
|
|
349
422
|
const { join, relative: pathRelative } = await import('path');
|
|
350
|
-
const repoRootProc = Bun.spawn(['git', 'rev-parse', '--show-toplevel'], { stdout: 'pipe', cwd: this.workingDirectory });
|
|
423
|
+
const repoRootProc = Bun.spawn(['git', 'rev-parse', '--show-toplevel'], { stdout: 'pipe', stderr: 'pipe', cwd: this.workingDirectory });
|
|
351
424
|
await repoRootProc.exited;
|
|
352
425
|
const repoRoot = (await new Response(repoRootProc.stdout).text()).trim() || this.workingDirectory;
|
|
353
426
|
await Promise.allSettled(
|
|
@@ -412,6 +485,22 @@ export class DiffPanel extends BasePanel {
|
|
|
412
485
|
// Input
|
|
413
486
|
// -------------------------------------------------------------------------
|
|
414
487
|
|
|
488
|
+
/**
|
|
489
|
+
* Wraps a self-load hotkey ('w'/'h'/'s') with visible before/after status
|
|
490
|
+
* text in the status bar — otherwise a reload whose content is unchanged
|
|
491
|
+
* from what's already shown is indistinguishable from the keypress having
|
|
492
|
+
* done nothing at all (contrast with `/diff working`'s ctx.print() calls).
|
|
493
|
+
*/
|
|
494
|
+
private runHotkeyReload(label: string, load: () => Promise<void>): void {
|
|
495
|
+
this.hotkeyStatus = `Loading ${label} diff…`;
|
|
496
|
+
this.markDirty();
|
|
497
|
+
void load().then(() => {
|
|
498
|
+
this.hotkeyStatus = `Reloaded ${label} diff.`;
|
|
499
|
+
this.markDirty();
|
|
500
|
+
this.requestRender();
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
415
504
|
handleInput(key: string): boolean {
|
|
416
505
|
switch (key) {
|
|
417
506
|
case 'up': this.scrollUp(); return true;
|
|
@@ -427,9 +516,9 @@ export class DiffPanel extends BasePanel {
|
|
|
427
516
|
case 'backtab': this.prevFile(); return true;
|
|
428
517
|
case 'pageup': this.scrollPageUp(); return true;
|
|
429
518
|
case 'pagedown': this.scrollPageDown(); return true;
|
|
430
|
-
case 'w':
|
|
431
|
-
case 'h':
|
|
432
|
-
case 's':
|
|
519
|
+
case 'w': this.runHotkeyReload('working tree', () => this.showGitDiff()); return true;
|
|
520
|
+
case 'h': this.runHotkeyReload('HEAD', () => this.showGitDiff('HEAD')); return true;
|
|
521
|
+
case 's': this.runHotkeyReload('staged', () => this.showStagedDiff()); return true;
|
|
433
522
|
case 'o': {
|
|
434
523
|
if (!this.currentEntry()) return false;
|
|
435
524
|
this.pendingOpenPreview = true;
|
|
@@ -617,11 +706,15 @@ export class DiffPanel extends BasePanel {
|
|
|
617
706
|
for (let i = 0; i < this.entries.length; i++) {
|
|
618
707
|
const entry = this.entries[i]!;
|
|
619
708
|
const active = i === this.selectedFile;
|
|
709
|
+
const placeholder = isPlaceholderPath(entry.filePath);
|
|
620
710
|
const stat = diffStat(entry);
|
|
621
|
-
|
|
711
|
+
// Placeholder entries ('(error)', '(no changes)', ...) get a distinct
|
|
712
|
+
// warn color regardless of active/inactive — they are not a real diff
|
|
713
|
+
// of a real file and must not read as one.
|
|
714
|
+
const fg = placeholder ? COLOR.warn : (active ? COLOR.tabActive : COLOR.tabInactive);
|
|
622
715
|
const bg = active ? COLOR.tabActiveBg : COLOR.tabBg;
|
|
623
716
|
// Active file gets a leading marker; every tab shows +adds/-dels at a glance.
|
|
624
|
-
const marker = active ? '▸ ' : ' ';
|
|
717
|
+
const marker = active ? '▸ ' : (placeholder ? '⚠ ' : ' ');
|
|
625
718
|
const label = `${marker}${basename(entry.filePath)} `;
|
|
626
719
|
for (const ch of label) push(ch, fg, bg, active);
|
|
627
720
|
if (stat.added > 0) for (const ch of `+${stat.added}`) push(ch, COLOR.addition, bg, active);
|
|
@@ -644,11 +737,15 @@ export class DiffPanel extends BasePanel {
|
|
|
644
737
|
return buildStyledPanelLine(width, [{ text: ' No file', fg: COLOR.tabInactive, bg: COLOR.statusBar }], { fillBg: COLOR.statusBar });
|
|
645
738
|
}
|
|
646
739
|
const stat = diffStat(entry);
|
|
740
|
+
const placeholder = isPlaceholderPath(entry.filePath);
|
|
647
741
|
// Keep the file path display-width-aware so a long/wide path can't overflow.
|
|
648
742
|
const pathBudget = Math.max(8, Math.floor(width / 2));
|
|
649
743
|
const fileInfo = truncateDisplay(entry.filePath, pathBudget);
|
|
650
744
|
const segments: Array<{ text: string; fg: string; bg?: string; bold?: boolean }> = [
|
|
651
|
-
|
|
745
|
+
// Placeholder states ('(error)', '(no changes)', ...) are not a real
|
|
746
|
+
// diff of a real file — a distinct warn color keeps them from being
|
|
747
|
+
// mistaken for one.
|
|
748
|
+
{ text: ` ${fileInfo} `, fg: placeholder ? COLOR.warn : COLOR.filename, bg: COLOR.statusBar, bold: true },
|
|
652
749
|
{ text: `[${this.selectedFile + 1}/${this.entries.length}]`, fg: COLOR.tabInactive, bg: COLOR.statusBar },
|
|
653
750
|
{ text: ' +', fg: COLOR.tabInactive, bg: COLOR.statusBar },
|
|
654
751
|
{ text: String(stat.added), fg: COLOR.addition, bg: COLOR.statusBar },
|
|
@@ -660,6 +757,9 @@ export class DiffPanel extends BasePanel {
|
|
|
660
757
|
if (entry.semanticSummary) {
|
|
661
758
|
segments.push({ text: ` ◈ ${entry.semanticSummary}`, fg: COLOR.hunk, bg: COLOR.statusBar });
|
|
662
759
|
}
|
|
760
|
+
if (this.hotkeyStatus) {
|
|
761
|
+
segments.push({ text: ` ${this.hotkeyStatus}`, fg: COLOR.info, bg: COLOR.statusBar, bold: true });
|
|
762
|
+
}
|
|
663
763
|
return buildStyledPanelLine(width, segments, { fillBg: COLOR.statusBar });
|
|
664
764
|
}
|
|
665
765
|
|
package/src/panels/docs-panel.ts
CHANGED
|
@@ -122,6 +122,15 @@ export class DocsPanel extends BasePanel {
|
|
|
122
122
|
return null;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* The `/`-to-search buffer wants every character of a burst (paste, or a
|
|
127
|
+
* fast-typed query landing in one input.feed() call), same as it always
|
|
128
|
+
* has — see the interface doc on `Panel.isCapturingTextBurst`.
|
|
129
|
+
*/
|
|
130
|
+
isCapturingTextBurst(): boolean {
|
|
131
|
+
return this.searching;
|
|
132
|
+
}
|
|
133
|
+
|
|
125
134
|
handleInput(key: string): boolean {
|
|
126
135
|
const searchResult = this._handleSearchKey(key);
|
|
127
136
|
if (searchResult !== null) return searchResult;
|
|
@@ -215,6 +215,15 @@ export class FileExplorerPanel extends BasePanel {
|
|
|
215
215
|
return null;
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
/**
|
|
219
|
+
* The `/`-to-search buffer wants every character of a burst (paste, or a
|
|
220
|
+
* fast-typed query landing in one input.feed() call), same as it always
|
|
221
|
+
* has — see the interface doc on `Panel.isCapturingTextBurst`.
|
|
222
|
+
*/
|
|
223
|
+
isCapturingTextBurst(): boolean {
|
|
224
|
+
return this.searchMode;
|
|
225
|
+
}
|
|
226
|
+
|
|
218
227
|
handleInput(key: string): boolean {
|
|
219
228
|
const filterResult = this._handleFilterKey(key);
|
|
220
229
|
if (filterResult !== null) return filterResult;
|
package/src/panels/git-panel.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { truncateDisplay, getDisplayWidth } from '../utils/terminal-width.ts';
|
|
|
4
4
|
import { GitService } from '@pellux/goodvibes-sdk/platform/git';
|
|
5
5
|
import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
6
6
|
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
7
|
-
import { UI_TONES } from '../renderer/ui-primitives.ts';
|
|
7
|
+
import { UI_TONES, DIFF_TONES } from '../renderer/ui-primitives.ts';
|
|
8
8
|
import {
|
|
9
9
|
buildEmptyState,
|
|
10
10
|
buildKeyboardHints,
|
|
@@ -77,7 +77,7 @@ const C = extendPalette(DEFAULT_PANEL_PALETTE, {
|
|
|
77
77
|
commitAuthor: '244',
|
|
78
78
|
selected: '#1c1c1c',
|
|
79
79
|
selectedFg: '#ffffff',
|
|
80
|
-
diffMeta:
|
|
80
|
+
diffMeta: DIFF_TONES.hunk, // WO-204: shared diff-hunk token, was a local literal
|
|
81
81
|
diffNeutral: '250',
|
|
82
82
|
// Reuses the existing workflow accent token rather than adding a new hex
|
|
83
83
|
// literal (architecture gate ratchets the raw-hex-literal count).
|
|
@@ -178,11 +178,9 @@ export class GitPanel extends BasePanel {
|
|
|
178
178
|
const changed = this.getChangedFiles ? new Set(this.getChangedFiles()) : null;
|
|
179
179
|
const markChanged = (path: string): boolean | undefined => (changed ? changed.has(path) : undefined);
|
|
180
180
|
|
|
181
|
-
// Classify from the raw per-file porcelain columns (index/working_dir)
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
// 'M', working_dir ' '), which would otherwise show it as both staged
|
|
185
|
-
// AND unstaged and make 's'/'u' land on the wrong (phantom) row.
|
|
181
|
+
// Classify from the raw per-file porcelain columns (index/working_dir), not simple-git's
|
|
182
|
+
// modified/staged arrays: those double-count a staged-with-no-further-edits file (index 'M',
|
|
183
|
+
// working_dir ' ') as both staged AND unstaged, landing 's'/'u' on a phantom row.
|
|
186
184
|
const stagedFiles: GitFileEntry[] = [];
|
|
187
185
|
const unstagedFiles: GitFileEntry[] = [];
|
|
188
186
|
for (const f of statusResult.files) {
|
|
@@ -278,6 +276,11 @@ export class GitPanel extends BasePanel {
|
|
|
278
276
|
|
|
279
277
|
// Input handling
|
|
280
278
|
|
|
279
|
+
/** Commit-message entry wants every char of a burst delivered one at a time — see Panel.isCapturingTextBurst. */
|
|
280
|
+
isCapturingTextBurst(): boolean {
|
|
281
|
+
return this.commitMessage !== null;
|
|
282
|
+
}
|
|
283
|
+
|
|
281
284
|
handleInput(key: string): boolean {
|
|
282
285
|
// Confirm (init repo / commit) takes priority over everything else.
|
|
283
286
|
const confirmResult = handleConfirmInput(this.confirm, key);
|
|
@@ -728,10 +731,7 @@ export class GitPanel extends BasePanel {
|
|
|
728
731
|
});
|
|
729
732
|
}
|
|
730
733
|
|
|
731
|
-
/**
|
|
732
|
-
* Map an item index in `this.items` to the row index in the rendered row list.
|
|
733
|
-
* Header items expand to 2 rows (branch + spacer).
|
|
734
|
-
*/
|
|
734
|
+
/** Map an item index in `this.items` to its rendered row index (header items expand to 2 rows: branch + spacer). */
|
|
735
735
|
private getRowIndexForItem(itemIndex: number): number {
|
|
736
736
|
let row = 0;
|
|
737
737
|
for (let i = 0; i < itemIndex && i < this.items.length; i++) {
|
|
@@ -266,6 +266,15 @@ export class KnowledgeGraphPanel extends ScrollableListPanel<BrowseRow> {
|
|
|
266
266
|
// Input
|
|
267
267
|
// ---------------------------------------------------------------------------
|
|
268
268
|
|
|
269
|
+
/**
|
|
270
|
+
* The `/`-to-search buffer wants every character of a burst (paste, or a
|
|
271
|
+
* fast-typed query landing in one input.feed() call), same as it always
|
|
272
|
+
* has — see the interface doc on `Panel.isCapturingTextBurst`.
|
|
273
|
+
*/
|
|
274
|
+
override isCapturingTextBurst(): boolean {
|
|
275
|
+
return this.searchFocused;
|
|
276
|
+
}
|
|
277
|
+
|
|
269
278
|
handleInput(key: string): boolean {
|
|
270
279
|
if (this.lastError !== null) this.clearError();
|
|
271
280
|
|
|
@@ -96,6 +96,16 @@ export class LocalAuthPanel extends ScrollableListPanel<LocalAuthUser> {
|
|
|
96
96
|
return this.maskedState !== null;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Masked password/username entry wants every character of a burst (paste,
|
|
101
|
+
* or fast typing landing in one input.feed() call) delivered one at a
|
|
102
|
+
* time, same as it always has — see the interface doc on
|
|
103
|
+
* `Panel.isCapturingTextBurst`.
|
|
104
|
+
*/
|
|
105
|
+
public override isCapturingTextBurst(): boolean {
|
|
106
|
+
return this.isMaskedEntryActive || this.usernameEntry !== null || super.isCapturingTextBurst();
|
|
107
|
+
}
|
|
108
|
+
|
|
99
109
|
public override handleInput(key: KeyName): boolean {
|
|
100
110
|
// Masked entry takes priority when active — it must capture every
|
|
101
111
|
// keystroke (including letters that would otherwise be p/a/d/b actions)
|
|
@@ -224,6 +224,15 @@ export class PanelListPanel extends BasePanel {
|
|
|
224
224
|
});
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
+
/**
|
|
228
|
+
* The `/`-to-filter buffer wants every character of a burst (paste, or a
|
|
229
|
+
* fast-typed query landing in one input.feed() call), same as it always
|
|
230
|
+
* has — see the interface doc on `Panel.isCapturingTextBurst`.
|
|
231
|
+
*/
|
|
232
|
+
public isCapturingTextBurst(): boolean {
|
|
233
|
+
return this._filterActive;
|
|
234
|
+
}
|
|
235
|
+
|
|
227
236
|
public handleInput(key: string): boolean {
|
|
228
237
|
const filterResult = this._handleFilterKey(key);
|
|
229
238
|
if (filterResult !== null) return filterResult;
|
|
@@ -81,6 +81,16 @@ export class ProjectPlanningPanel extends BasePanel {
|
|
|
81
81
|
this.refresh();
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* The draft-answer text field wants every character of a burst (paste, or
|
|
86
|
+
* fast typing landing in one input.feed() call) delivered one at a time,
|
|
87
|
+
* same as it always has — see the interface doc on
|
|
88
|
+
* `Panel.isCapturingTextBurst`.
|
|
89
|
+
*/
|
|
90
|
+
public isCapturingTextBurst(): boolean {
|
|
91
|
+
return this.getCurrentQuestion() !== null;
|
|
92
|
+
}
|
|
93
|
+
|
|
84
94
|
public handleInput(key: string): boolean {
|
|
85
95
|
if (this.lastError !== null) this.clearError();
|
|
86
96
|
|
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
ProviderHealthRecord,
|
|
6
6
|
ProviderStatus,
|
|
7
7
|
} from '@/runtime/index.ts';
|
|
8
|
-
import { calcSessionCost } from '../export/cost-utils.ts';
|
|
8
|
+
import { calcSessionCost, isModelPriced } from '../export/cost-utils.ts';
|
|
9
9
|
|
|
10
10
|
export type { ProviderStatus };
|
|
11
11
|
|
|
@@ -40,6 +40,8 @@ export interface ProviderHealth {
|
|
|
40
40
|
totalTokens: number;
|
|
41
41
|
/** Session USD cost accumulated per call via calcSessionCost. */
|
|
42
42
|
totalCostUsd: number;
|
|
43
|
+
/** True when at least one call's model resolved to no real price (cost-utils isModelPriced === false) — totalCostUsd may understate the true cost. */
|
|
44
|
+
hasUnpricedModel: boolean;
|
|
43
45
|
/** Ring buffer of recent request latencies in ms (most-recent last). */
|
|
44
46
|
latencies: number[];
|
|
45
47
|
}
|
|
@@ -237,6 +239,7 @@ export class ProviderHealthTracker {
|
|
|
237
239
|
cacheWriteTokens: 0,
|
|
238
240
|
totalTokens: 0,
|
|
239
241
|
totalCostUsd: 0,
|
|
242
|
+
hasUnpricedModel: false,
|
|
240
243
|
latencies: [],
|
|
241
244
|
};
|
|
242
245
|
this.records.set(name, record);
|
|
@@ -259,6 +262,7 @@ export class ProviderHealthTracker {
|
|
|
259
262
|
const model = usage.model ?? record.lastModelId;
|
|
260
263
|
if (model) {
|
|
261
264
|
record.totalCostUsd += calcSessionCost(input, output, cacheRead, cacheWrite, model);
|
|
265
|
+
if (!isModelPriced(model)) record.hasUnpricedModel = true;
|
|
262
266
|
}
|
|
263
267
|
}
|
|
264
268
|
|
|
@@ -357,7 +357,14 @@ export function buildProviderRow(width: number, tones: ProviderConsoleTones, inp
|
|
|
357
357
|
{ text: errPct, fg: hasCalls && (entry?.errorRate ?? 0) > 0 ? tones.bad : tones.dim },
|
|
358
358
|
{ text: buildSparkline(entry?.timeline.points ?? []), fg: hasCalls ? latencyColor(tones, avgMs) : tones.dim },
|
|
359
359
|
{ text: health && health.totalTokens > 0 ? fmtTokens(health.totalTokens) : '-', fg: tones.dim },
|
|
360
|
-
{
|
|
360
|
+
{
|
|
361
|
+
// Tokens flowed but the model never resolved to a real price — say so
|
|
362
|
+
// instead of collapsing into the same '-' shown for "no calls yet".
|
|
363
|
+
text: health && health.totalTokens > 0
|
|
364
|
+
? (health.hasUnpricedModel ? 'unpriced' : fmtUsd(health.totalCostUsd))
|
|
365
|
+
: '-',
|
|
366
|
+
fg: health?.hasUnpricedModel ? tones.dim : tones.value,
|
|
367
|
+
},
|
|
361
368
|
{ text: account ? account.authFreshness : '-', fg: account ? freshnessColor(tones, account.authFreshness) : tones.dim },
|
|
362
369
|
],
|
|
363
370
|
PROVIDER_TABLE_COLUMNS,
|
|
@@ -242,6 +242,15 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
|
|
|
242
242
|
* Returns `true` if the key was consumed, `false` to let the panel manager try another
|
|
243
243
|
* handler.
|
|
244
244
|
*/
|
|
245
|
+
/**
|
|
246
|
+
* The `/`-to-filter buffer wants every character of a burst (paste, or a
|
|
247
|
+
* fast-typed query landing in one input.feed() call), same as it always
|
|
248
|
+
* has — see the interface doc on `Panel.isCapturingTextBurst`.
|
|
249
|
+
*/
|
|
250
|
+
isCapturingTextBurst(): boolean {
|
|
251
|
+
return this.filterEnabled && this.filterActive;
|
|
252
|
+
}
|
|
253
|
+
|
|
245
254
|
handleInput(key: string): boolean {
|
|
246
255
|
// I2: auto-clear transient errors on the next keystroke so stale errors don't linger.
|
|
247
256
|
// Subclasses that override handleInput should call super.handleInput(key) OR manually
|
|
@@ -139,6 +139,15 @@ export class SessionBrowserPanel extends BasePanel {
|
|
|
139
139
|
super.onDeactivate();
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* The `/`-to-search buffer wants every character of a burst (paste, or a
|
|
144
|
+
* fast-typed query landing in one input.feed() call), same as it always
|
|
145
|
+
* has — see the interface doc on `Panel.isCapturingTextBurst`.
|
|
146
|
+
*/
|
|
147
|
+
isCapturingTextBurst(): boolean {
|
|
148
|
+
return this.searching;
|
|
149
|
+
}
|
|
150
|
+
|
|
142
151
|
handleInput(key: string): boolean {
|
|
143
152
|
// Confirmation dialog — use shared handleConfirmInput for y/n/Esc UX
|
|
144
153
|
const confirmResult = handleConfirmInput(this.confirm, key);
|
|
@@ -7,7 +7,7 @@ import type { TurnEvent } from '@/runtime/index.ts';
|
|
|
7
7
|
import type { UiEventFeed } from '../runtime/ui-events.ts';
|
|
8
8
|
import type { UiReadModel, UiSessionSnapshot } from '../runtime/ui-read-models.ts';
|
|
9
9
|
import type { SessionMemoryQuery } from '../runtime/ui-service-queries.ts';
|
|
10
|
-
import { calcSessionCost } from '../export/cost-utils.ts';
|
|
10
|
+
import { calcSessionCost, isModelPriced } from '../export/cost-utils.ts';
|
|
11
11
|
import { type ConfirmState, handleConfirmInput, renderConfirmLines } from './confirm-state.ts';
|
|
12
12
|
import type { PanelIntegrationContext } from './types.ts';
|
|
13
13
|
import {
|
|
@@ -615,10 +615,12 @@ export class TokenBudgetPanel extends BasePanel {
|
|
|
615
615
|
// Inline cost estimate — absorbed pricing wiring (cost-utils.ts), shown
|
|
616
616
|
// whenever the active model id is known.
|
|
617
617
|
if (this.getCurrentModelId) {
|
|
618
|
-
const
|
|
618
|
+
const modelId = this.getCurrentModelId();
|
|
619
|
+
const priced = isModelPriced(modelId);
|
|
620
|
+
const cost = calcSessionCost(u.input, u.output, u.cacheRead, u.cacheWrite, modelId);
|
|
619
621
|
lines.push(buildStyledPanelLine(width, [
|
|
620
622
|
{ text: ' Cost: ', fg: C.label },
|
|
621
|
-
{ text: `$${cost.toFixed(4)}
|
|
623
|
+
{ text: priced ? `$${cost.toFixed(4)}` : 'unpriced', fg: C.value, bold: true },
|
|
622
624
|
]));
|
|
623
625
|
}
|
|
624
626
|
|
package/src/panels/types.ts
CHANGED
|
@@ -100,6 +100,19 @@ export interface Panel {
|
|
|
100
100
|
// Input (optional)
|
|
101
101
|
handleInput?(key: KeyName): boolean;
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Optional: report `true` while this panel has an active inline text
|
|
105
|
+
* capture (e.g. a `/`-to-filter or search buffer) that deliberately wants
|
|
106
|
+
* every character of a burst — paste, or several keystrokes typed fast
|
|
107
|
+
* enough to land in one `input.feed()` call — delivered to `handleInput`
|
|
108
|
+
* one at a time, same as it always has. When this returns `false` or is
|
|
109
|
+
* not implemented, the input router treats a printable burst as never a
|
|
110
|
+
* deliberate single-key panel hotkey and routes it to the composer
|
|
111
|
+
* instead of exploding it into per-char `handleInput` calls (see
|
|
112
|
+
* `handlePanelFocusToken` in `src/input/handler-feed-routes.ts`).
|
|
113
|
+
*/
|
|
114
|
+
isCapturingTextBurst?(): boolean;
|
|
115
|
+
|
|
103
116
|
// Scroll input (optional)
|
|
104
117
|
// Positive delta scrolls down; negative delta scrolls up.
|
|
105
118
|
handleScroll?(deltaRows: number): boolean;
|
|
@@ -100,6 +100,16 @@ export class WorkPlanPanel extends ScrollableListPanel<WorkPlanItem> {
|
|
|
100
100
|
});
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* The add/edit draft form's title/owner/notes fields want every character
|
|
105
|
+
* of a burst (paste, or fast typing landing in one input.feed() call)
|
|
106
|
+
* delivered one at a time, same as it always has — see the interface doc
|
|
107
|
+
* on `Panel.isCapturingTextBurst`.
|
|
108
|
+
*/
|
|
109
|
+
isCapturingTextBurst(): boolean {
|
|
110
|
+
return this.draftMode !== null;
|
|
111
|
+
}
|
|
112
|
+
|
|
103
113
|
handleInput(key: string): boolean {
|
|
104
114
|
if (this.lastError !== null) this.clearError();
|
|
105
115
|
|