@pellux/goodvibes-tui 1.0.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 +66 -127
- 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.ts +40 -0
- 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 +22 -8
- 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/main.ts +17 -17
- package/src/panels/agent-inspector-shared.ts +33 -10
- 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 +112 -18
- package/src/panels/docs-panel.ts +9 -0
- package/src/panels/file-explorer-panel.ts +9 -0
- package/src/panels/git-panel.ts +9 -9
- 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 +5 -4
- package/src/renderer/process-modal.ts +17 -2
- package/src/renderer/shell-surface.ts +8 -1
- package/src/renderer/ui-factory.ts +91 -7
- 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/version.ts +1 -1
|
@@ -88,6 +88,7 @@ export interface InputHandlerLike {
|
|
|
88
88
|
lastCopyTime: number;
|
|
89
89
|
lastBlockCopyTime: number;
|
|
90
90
|
lastCtrlCTime: number;
|
|
91
|
+
lastCtrlCTimeoutId: ReturnType<typeof setTimeout> | null;
|
|
91
92
|
|
|
92
93
|
// ── Modal state ───────────────────────────────────────────────────────────
|
|
93
94
|
commandMode: boolean;
|
package/src/input/handler.ts
CHANGED
|
@@ -172,6 +172,10 @@ export class InputHandler implements InputHandlerLike {
|
|
|
172
172
|
public pasteRegistry = new Map<string, string>();
|
|
173
173
|
public nextPasteId = 1;
|
|
174
174
|
public lastCtrlCTime = 0;
|
|
175
|
+
/** Pending "hide the exit notice" timer from the last empty-prompt Ctrl+C
|
|
176
|
+
* press, if any — cleared before every subsequent press decides its own
|
|
177
|
+
* outcome (see handleCtrlC in handler-content-actions.ts). */
|
|
178
|
+
public lastCtrlCTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
175
179
|
/** Long-lived feed context — reused across every feed() call to avoid per-keystroke allocation. */
|
|
176
180
|
public feedContext!: import('./handler-feed.ts').InputFeedContext;
|
|
177
181
|
public commandRegistry: CommandRegistry | null = null;
|
package/src/main.ts
CHANGED
|
@@ -130,13 +130,9 @@ async function main() {
|
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
// TUI default: show token speed ON
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
// user has explicitly set this key to false in EITHER their global or project
|
|
137
|
-
// persisted config, their value is respected and the default is NOT applied.
|
|
138
|
-
// Only when the key is absent from both files (e.g. a new install) does the
|
|
139
|
-
// TUI default of true take effect in-memory — no disk write occurs either way.
|
|
133
|
+
// TUI default: show token speed ON (SDK schema default is false). applyRuntimeConfigDefault
|
|
134
|
+
// reads the global + project settings files and only applies the default in-memory when the
|
|
135
|
+
// key is absent from both (e.g. a new install); an explicit user value is respected. No disk write.
|
|
140
136
|
applyRuntimeConfigDefault(configManager, 'display.showTokenSpeed', true);
|
|
141
137
|
|
|
142
138
|
const panelManager = ctx.services.panelManager;
|
|
@@ -180,6 +176,8 @@ async function main() {
|
|
|
180
176
|
ttftRecorded: false,
|
|
181
177
|
activeToolStartedAtMs: undefined,
|
|
182
178
|
activeToolName: undefined,
|
|
179
|
+
lastDeltaAtMs: undefined, stallEpisode: 0,
|
|
180
|
+
reconnectAttempt: undefined, reconnectMaxAttempts: undefined,
|
|
183
181
|
};
|
|
184
182
|
|
|
185
183
|
const getPromptContentWidth = () => computePromptContentWidth(stdout.columns);
|
|
@@ -190,10 +188,8 @@ async function main() {
|
|
|
190
188
|
const currentModel = providerRegistry.getCurrentModel();
|
|
191
189
|
const contextWindow = providerRegistry.getContextWindowForModel(currentModel);
|
|
192
190
|
const rows = stdout.rows || 24;
|
|
193
|
-
// Compact threshold must match buildShellFooter's `compact: height < 30`
|
|
194
|
-
//
|
|
195
|
-
// estimateShellFooterHeight can answer a compact-vs-non-compact query
|
|
196
|
-
// with the wrong cached mode and throw the viewport math off by several rows.
|
|
191
|
+
// Compact threshold must match buildShellFooter's `compact: height < 30` posture below,
|
|
192
|
+
// else estimateShellFooterHeight's cached-height fast path answers with the wrong mode.
|
|
197
193
|
return rows - 2 - estimateShellFooterHeight(promptLines, contextWindow, rows < 30);
|
|
198
194
|
};
|
|
199
195
|
|
|
@@ -348,6 +344,7 @@ async function main() {
|
|
|
348
344
|
allowTerminalWrite(() => stdout.write(CLEAR_SCREEN));
|
|
349
345
|
render();
|
|
350
346
|
};
|
|
347
|
+
commandContext.requestFullRepaint = () => { compositor.resetDiff(); render(); };
|
|
351
348
|
permissionPromptRef.requestPermission = (request) =>
|
|
352
349
|
new Promise((resolve) => {
|
|
353
350
|
pendingPermission = {
|
|
@@ -514,6 +511,8 @@ async function main() {
|
|
|
514
511
|
hitlMode: modeManager.getHITLMode(),
|
|
515
512
|
runningAgentCount,
|
|
516
513
|
runningProcessCount,
|
|
514
|
+
// Composer must not read as focused while the panel/process indicator owns keyboard focus.
|
|
515
|
+
promptFocused: !input.panelFocused && !input.indicatorFocused,
|
|
517
516
|
indicatorFocused: input.indicatorFocused,
|
|
518
517
|
runningAgentProgress: runningAgentSummary.progress,
|
|
519
518
|
composerMode: composerState.modeLabel,
|
|
@@ -584,6 +583,8 @@ async function main() {
|
|
|
584
583
|
const partialToolPreview = showPreview ? sessionSnapshot.streamToolPreview : undefined;
|
|
585
584
|
// Elapsed from turn start (stream or tool execution), used for the thinking indicator timer.
|
|
586
585
|
const turnElapsedMs = streamMetrics.startTime > 0 ? Date.now() - streamMetrics.startTime : undefined;
|
|
586
|
+
// Suppressed while a tool executes — its ticking timer is the honest indicator then.
|
|
587
|
+
const stallInfo = UIFactory.computeRenderStallInfo(streamMetrics, Date.now());
|
|
587
588
|
const thinking = UIFactory.createThinkingFragment(
|
|
588
589
|
conversationWidth,
|
|
589
590
|
orchestrator.getSpinner(),
|
|
@@ -594,6 +595,7 @@ async function main() {
|
|
|
594
595
|
orchestrator.streamingOutputTokens > 0 ? orchestrator.streamingOutputTokens : undefined,
|
|
595
596
|
turnElapsedMs,
|
|
596
597
|
streamMetrics.ttftMs,
|
|
598
|
+
stallInfo,
|
|
597
599
|
);
|
|
598
600
|
viewport.push(...thinking);
|
|
599
601
|
// Live tool timer: render the currently executing tool row with ticking elapsed.
|
|
@@ -698,9 +700,8 @@ async function main() {
|
|
|
698
700
|
|
|
699
701
|
// Stable turn context for failover retry — set in submitInput, read by retryTurn.
|
|
700
702
|
let retryCtx: { count: number; text: string; content?: ContentPart[]; opts?: Parameters<typeof orchestrator.handleUserInput>[2] } | null = null;
|
|
701
|
-
// One-key retry affordance
|
|
702
|
-
//
|
|
703
|
-
// picker. Any other character clears the affordance and routes normally.
|
|
703
|
+
// One-key retry affordance, active right after a user-visible TURN_ERROR: 'r' re-submits on the
|
|
704
|
+
// current provider, 'm' opens the model picker, any other character clears it and routes normally.
|
|
704
705
|
let errorAffordanceActive = false;
|
|
705
706
|
const retryTurn = (): void => {
|
|
706
707
|
if (!retryCtx) return;
|
|
@@ -724,9 +725,8 @@ async function main() {
|
|
|
724
725
|
}
|
|
725
726
|
});
|
|
726
727
|
|
|
727
|
-
// Register terminal-restoring crash/termination handlers BEFORE entering raw mode so a
|
|
728
|
-
//
|
|
729
|
-
// 'exit' listener is the final safety net for any process.exit path.
|
|
728
|
+
// Register terminal-restoring crash/termination handlers BEFORE entering raw mode so a throw
|
|
729
|
+
// during setup or the initial render still restores the terminal; 'exit' is the final safety net.
|
|
730
730
|
process.on('uncaughtException', uncaughtExceptionHandler);
|
|
731
731
|
process.on('SIGTERM', terminationSignalHandler);
|
|
732
732
|
process.on('SIGHUP', terminationSignalHandler);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { formatDuration } from '../utils/format-duration.ts';
|
|
2
|
-
import { calcSessionCost } from '../export/cost-utils.ts';
|
|
2
|
+
import { calcSessionCost, isModelPriced } from '../export/cost-utils.ts';
|
|
3
3
|
import type { AgentEvent } from '@/runtime/index.ts';
|
|
4
4
|
import type { UiEventFeed } from '../runtime/ui-events.ts';
|
|
5
5
|
|
|
@@ -171,6 +171,8 @@ export function formatAgentCost(usd: number): string {
|
|
|
171
171
|
export interface AgentUsageSummary {
|
|
172
172
|
readonly tokens: number;
|
|
173
173
|
readonly cost: number;
|
|
174
|
+
/** False when the model never resolved to a real price — `cost` is a zero placeholder, not a real reading. */
|
|
175
|
+
readonly priced: boolean;
|
|
174
176
|
}
|
|
175
177
|
|
|
176
178
|
interface AgentUsageLike {
|
|
@@ -183,22 +185,43 @@ interface AgentUsageLike {
|
|
|
183
185
|
readonly model?: string | null;
|
|
184
186
|
}
|
|
185
187
|
|
|
188
|
+
/**
|
|
189
|
+
* True when a usage object carries actual reported token data rather than
|
|
190
|
+
* being present-but-empty. W0.9: the SDK's AgentRecord.usage is initialised
|
|
191
|
+
* to an all-zero object at agent spawn (platform/tools/agent/manager.ts) and
|
|
192
|
+
* — in the currently pinned SDK version — is never updated past that, so a
|
|
193
|
+
* plain truthiness check on `rec.usage` treats every agent, including ones
|
|
194
|
+
* that did real work, as "has data" and renders a fabricated $0.00/0-token
|
|
195
|
+
* reading. Guarding on real (nonzero) counts instead keeps today's "n/a"
|
|
196
|
+
* honest and lights up automatically once a future SDK actually populates
|
|
197
|
+
* usage — no further TUI change needed.
|
|
198
|
+
*/
|
|
199
|
+
export function hasReportedUsage(
|
|
200
|
+
usage: AgentUsageLike['usage'],
|
|
201
|
+
): usage is NonNullable<AgentUsageLike['usage']> {
|
|
202
|
+
if (!usage) return false;
|
|
203
|
+
return usage.inputTokens > 0 || usage.outputTokens > 0
|
|
204
|
+
|| (usage.cacheReadTokens ?? 0) > 0 || (usage.cacheWriteTokens ?? 0) > 0;
|
|
205
|
+
}
|
|
206
|
+
|
|
186
207
|
/**
|
|
187
208
|
* Total token count + cost for an agent record's usage, or null when no usage
|
|
188
209
|
* data has landed yet (honest-UX: never fabricate a $0.00/0-token reading for
|
|
189
210
|
* an agent that simply hasn't reported usage).
|
|
190
211
|
*/
|
|
191
212
|
export function summarizeAgentUsage(rec: AgentUsageLike): AgentUsageSummary | null {
|
|
192
|
-
if (!rec.usage) return null;
|
|
193
|
-
const
|
|
213
|
+
if (!hasReportedUsage(rec.usage)) return null;
|
|
214
|
+
const usage = rec.usage;
|
|
215
|
+
const model = rec.model ?? 'unknown';
|
|
216
|
+
const inputTokens = usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0);
|
|
194
217
|
const cost = calcSessionCost(
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
218
|
+
usage.inputTokens,
|
|
219
|
+
usage.outputTokens,
|
|
220
|
+
usage.cacheReadTokens ?? 0,
|
|
221
|
+
usage.cacheWriteTokens ?? 0,
|
|
222
|
+
model,
|
|
200
223
|
);
|
|
201
|
-
return { tokens: inputTokens +
|
|
224
|
+
return { tokens: inputTokens + usage.outputTokens, cost, priced: isModelPriced(model) };
|
|
202
225
|
}
|
|
203
226
|
|
|
204
227
|
// ---------------------------------------------------------------------------
|
|
@@ -226,7 +249,7 @@ export function buildWrfcCostSegments(
|
|
|
226
249
|
segments.push([segments.length > 0 ? ' Tokens ' : ' Tokens ', palette.label]);
|
|
227
250
|
segments.push([formatTokens(usage.tokens), palette.info]);
|
|
228
251
|
segments.push([' Cost ', palette.label]);
|
|
229
|
-
segments.push([formatAgentCost(usage.cost), palette.info]);
|
|
252
|
+
segments.push([usage.priced ? formatAgentCost(usage.cost) : 'unpriced', palette.info]);
|
|
230
253
|
}
|
|
231
254
|
return segments.length > 0 ? segments : null;
|
|
232
255
|
}
|
|
@@ -46,7 +46,7 @@ export function registerDevelopmentPanels(manager: PanelManager, deps: ResolvedB
|
|
|
46
46
|
icon: 'D',
|
|
47
47
|
category: 'development',
|
|
48
48
|
description: 'Unified diff view of agent file changes',
|
|
49
|
-
factory: () => new DiffPanel(requireUiServices(deps).environment.workingDirectory),
|
|
49
|
+
factory: () => new DiffPanel(requireUiServices(deps).environment.workingDirectory, deps.requestRender),
|
|
50
50
|
});
|
|
51
51
|
|
|
52
52
|
// WO-110: 'inspector' registration moved to builtin/agent.ts (category
|
|
@@ -20,11 +20,12 @@
|
|
|
20
20
|
|
|
21
21
|
import type { AgentRecord } from '@pellux/goodvibes-sdk/platform/tools';
|
|
22
22
|
import { truncateDisplay } from '../utils/terminal-width.ts';
|
|
23
|
-
import { calcSessionCost } from '../export/cost-utils.ts';
|
|
23
|
+
import { calcSessionCost, isModelPriced } from '../export/cost-utils.ts';
|
|
24
24
|
import {
|
|
25
25
|
AGENT_TERMINAL_STATUSES,
|
|
26
26
|
AGENT_STALL_THRESHOLD_MS,
|
|
27
27
|
countStalledAgents,
|
|
28
|
+
hasReportedUsage,
|
|
28
29
|
} from './agent-inspector-shared.ts';
|
|
29
30
|
|
|
30
31
|
// ---------------------------------------------------------------------------
|
|
@@ -114,24 +115,28 @@ export function buildCockpitRosterSnapshot(
|
|
|
114
115
|
let outputTokens: number | null = null;
|
|
115
116
|
let cost: number | null = null;
|
|
116
117
|
|
|
117
|
-
if (rec.usage) {
|
|
118
|
+
if (hasReportedUsage(rec.usage)) {
|
|
118
119
|
hasUsage = true;
|
|
120
|
+
const usage = rec.usage;
|
|
119
121
|
const inp =
|
|
120
|
-
|
|
121
|
-
(
|
|
122
|
-
(
|
|
123
|
-
const out =
|
|
122
|
+
usage.inputTokens +
|
|
123
|
+
(usage.cacheReadTokens ?? 0) +
|
|
124
|
+
(usage.cacheWriteTokens ?? 0);
|
|
125
|
+
const out = usage.outputTokens;
|
|
124
126
|
const agentCost = calcSessionCost(
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
127
|
+
usage.inputTokens,
|
|
128
|
+
usage.outputTokens,
|
|
129
|
+
usage.cacheReadTokens ?? 0,
|
|
130
|
+
usage.cacheWriteTokens ?? 0,
|
|
129
131
|
rec.model ?? 'unknown',
|
|
130
132
|
);
|
|
131
133
|
|
|
132
134
|
inputTokens = inp;
|
|
133
135
|
outputTokens = out;
|
|
134
|
-
|
|
136
|
+
// Usage exists but the model never resolved to a real price — reuse the
|
|
137
|
+
// existing "usage absent" null/n-a convention rather than showing a
|
|
138
|
+
// fabricated $0.00 (WO-315).
|
|
139
|
+
cost = isModelPriced(rec.model ?? 'unknown') ? agentCost : null;
|
|
135
140
|
|
|
136
141
|
totalInputTokens += inp;
|
|
137
142
|
totalOutputTokens += out;
|
|
@@ -22,13 +22,15 @@ import {
|
|
|
22
22
|
DEFAULT_PANEL_PALETTE,
|
|
23
23
|
type PanelWorkspaceSection,
|
|
24
24
|
} from './polish.ts';
|
|
25
|
-
import { calcSessionCost } from '../export/cost-utils.ts';
|
|
25
|
+
import { calcSessionCost, isModelPriced } from '../export/cost-utils.ts';
|
|
26
26
|
import { abbreviateCount } from '../utils/format-number.ts';
|
|
27
27
|
import { isTextBackspace } from '../input/delete-key-policy.ts';
|
|
28
28
|
|
|
29
29
|
// Pricing lookups are provided by ../export/cost-utils.ts (single source of truth).
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
/** unpriced=true renders an honest "unpriced" marker instead of a $0.00 that could be mistaken for a real zero cost. */
|
|
32
|
+
function formatCost(usd: number, unpriced = false): string {
|
|
33
|
+
if (unpriced) return 'unpriced';
|
|
32
34
|
if (usd === 0) return '$0.00';
|
|
33
35
|
if (usd < 0.0001) return '<$0.0001';
|
|
34
36
|
if (usd < 0.01) return `$${usd.toFixed(4)}`;
|
|
@@ -314,6 +316,16 @@ export class CostTrackerPanel extends BasePanel {
|
|
|
314
316
|
// Input
|
|
315
317
|
// -------------------------------------------------------------------------
|
|
316
318
|
|
|
319
|
+
/**
|
|
320
|
+
* The budget-threshold entry field wants every character of a burst
|
|
321
|
+
* (paste, or fast typing landing in one input.feed() call) delivered one
|
|
322
|
+
* at a time, same as it always has — see the interface doc on
|
|
323
|
+
* `Panel.isCapturingTextBurst`.
|
|
324
|
+
*/
|
|
325
|
+
isCapturingTextBurst(): boolean {
|
|
326
|
+
return this.budgetEntry !== null;
|
|
327
|
+
}
|
|
328
|
+
|
|
317
329
|
handleInput(key: string): boolean {
|
|
318
330
|
if (this.budgetEntry !== null) return this.handleBudgetEntryInput(key);
|
|
319
331
|
|
|
@@ -395,7 +407,7 @@ export class CostTrackerPanel extends BasePanel {
|
|
|
395
407
|
const sessionCost = calcSessionCost(this.sessionUsage.input, this.sessionUsage.output, this.sessionUsage.cacheRead, this.sessionUsage.cacheWrite, this.sessionModel);
|
|
396
408
|
const overBudget = this.budgetThreshold > 0 && sessionCost > this.budgetThreshold;
|
|
397
409
|
const sparkline = buildSparkline(this.costHistory);
|
|
398
|
-
const costStr = formatCost(sessionCost);
|
|
410
|
+
const costStr = formatCost(sessionCost, !isModelPriced(this.sessionModel));
|
|
399
411
|
const costFg = overBudget ? C.bad : C.cost;
|
|
400
412
|
const budgetStr = this.budgetThreshold > 0
|
|
401
413
|
? ` / ${formatCost(this.budgetThreshold)}`
|
|
@@ -451,10 +463,14 @@ export class CostTrackerPanel extends BasePanel {
|
|
|
451
463
|
const planCost = agentList.reduce((sum, a) => sum + a.cost, 0);
|
|
452
464
|
const running = agentList.filter((a) => a.status === 'running').length;
|
|
453
465
|
const failed = agentList.filter((a) => a.status === 'failed').length;
|
|
466
|
+
// Plan total mixes the session model with every agent's model — flag it
|
|
467
|
+
// as unpriced (rather than showing a sum that silently omits unpriced
|
|
468
|
+
// agents' contribution) if any one of them has no real pricing.
|
|
469
|
+
const planHasUnpriced = !isModelPriced(this.sessionModel) || agentList.some((a) => !isModelPriced(a.model));
|
|
454
470
|
const agentRows: Line[] = [
|
|
455
471
|
buildStyledPanelLine(width, [
|
|
456
472
|
{ text: ' Plan total ', fg: C.label },
|
|
457
|
-
{ text: formatCost(planCost + sessionCost), fg: C.cost, bold: true },
|
|
473
|
+
{ text: formatCost(planCost + sessionCost, planHasUnpriced), fg: C.cost, bold: true },
|
|
458
474
|
{ text: ` ${agentList.length} agent${agentList.length === 1 ? '' : 's'}`, fg: C.dim },
|
|
459
475
|
...(running > 0 ? [{ text: ` ${running} running`, fg: C.running }] : []),
|
|
460
476
|
...(failed > 0 ? [{ text: ` ${failed} failed`, fg: C.bad }] : []),
|
|
@@ -483,7 +499,14 @@ export class CostTrackerPanel extends BasePanel {
|
|
|
483
499
|
{ text: agent.model, fg: C.model },
|
|
484
500
|
{ text: agent.inputTokens > 0 ? formatTokens(agent.inputTokens) : '-', fg: C.dim },
|
|
485
501
|
{ text: agent.outputTokens > 0 ? formatTokens(agent.outputTokens) : '-', fg: C.dim },
|
|
486
|
-
{
|
|
502
|
+
{
|
|
503
|
+
// '-' means no usage reported yet; 'unpriced' means usage
|
|
504
|
+
// exists but the model has no real price; otherwise the cost.
|
|
505
|
+
text: agent.inputTokens > 0 && !isModelPriced(agent.model)
|
|
506
|
+
? 'unpriced'
|
|
507
|
+
: agent.cost > 0 ? formatCost(agent.cost) : '-',
|
|
508
|
+
fg: agent.cost > 0 ? C.cost : C.dim,
|
|
509
|
+
},
|
|
487
510
|
],
|
|
488
511
|
};
|
|
489
512
|
}),
|
|
@@ -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,6 +5,7 @@
|
|
|
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
10
|
import { UI_TONES, DIFF_TONES } from '../renderer/ui-primitives.ts';
|
|
10
11
|
import { FilePreviewPanel } from './file-preview-panel.ts';
|
|
@@ -166,20 +167,49 @@ function parseDiff(raw: string): ParsedLine[] {
|
|
|
166
167
|
// Split a full `git diff` output into per-file entries
|
|
167
168
|
// ---------------------------------------------------------------------------
|
|
168
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
|
+
|
|
169
203
|
function splitIntoDiffEntries(raw: string): DiffEntry[] {
|
|
170
204
|
const entries: DiffEntry[] = [];
|
|
171
|
-
// Split on "diff --git" lines
|
|
172
|
-
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);
|
|
173
207
|
for (const chunk of chunks) {
|
|
174
208
|
const trimmed = chunk.trim();
|
|
175
209
|
if (!trimmed) continue;
|
|
176
210
|
|
|
177
|
-
// Extract file path from "diff --git a/foo b/foo"
|
|
178
|
-
const match = trimmed.match(/^diff --git a\/.+? b\/(.+)$/m);
|
|
179
|
-
const filePath = match ? match[1]! : 'unknown';
|
|
180
|
-
|
|
181
211
|
entries.push({
|
|
182
|
-
filePath,
|
|
212
|
+
filePath: extractDiffFilePath(trimmed),
|
|
183
213
|
raw: chunk,
|
|
184
214
|
lines: parseDiff(chunk),
|
|
185
215
|
});
|
|
@@ -235,7 +265,15 @@ export class DiffPanel extends BasePanel {
|
|
|
235
265
|
/** Set by the 'o' key; consumed by handlePanelIntegrationAction on the next dispatch. */
|
|
236
266
|
private pendingOpenPreview = false;
|
|
237
267
|
|
|
238
|
-
|
|
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
|
+
) {
|
|
239
277
|
super('diff', 'Diff', 'D', 'development');
|
|
240
278
|
this.workingDirectory = workingDirectory;
|
|
241
279
|
}
|
|
@@ -271,12 +309,33 @@ export class DiffPanel extends BasePanel {
|
|
|
271
309
|
this.markDirty();
|
|
272
310
|
}
|
|
273
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
|
+
|
|
274
321
|
/** Run `git diff` against specific files and populate entries. */
|
|
275
322
|
async showFileDiffs(files: string[], ref?: string): Promise<void> {
|
|
323
|
+
if (!GitService.isGitRepo(this.workingDirectory)) {
|
|
324
|
+
this.showNotGitRepo();
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
276
327
|
const args = ['diff', ...(ref ? [ref] : []), '--', ...files];
|
|
277
|
-
const proc = Bun.spawn(['git', ...args], { stdout: 'pipe', cwd: this.workingDirectory });
|
|
278
|
-
const raw = await
|
|
279
|
-
|
|
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
|
+
}
|
|
280
339
|
this.loadRawDiff(raw);
|
|
281
340
|
this.enrichSemanticDiffs(files, ref ?? 'HEAD').catch((err) => { logger.debug('DiffPanel: semantic diff enrichment failed', { err }); });
|
|
282
341
|
}
|
|
@@ -287,6 +346,10 @@ export class DiffPanel extends BasePanel {
|
|
|
287
346
|
* enriches every loaded file with a semantic-diff summary in the background.
|
|
288
347
|
*/
|
|
289
348
|
async showGitDiff(ref?: string): Promise<void> {
|
|
349
|
+
if (!GitService.isGitRepo(this.workingDirectory)) {
|
|
350
|
+
this.showNotGitRepo();
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
290
353
|
const args = ['diff', ...(ref ? [ref] : [])];
|
|
291
354
|
const proc = Bun.spawn(['git', ...args], { stdout: 'pipe', stderr: 'pipe', cwd: this.workingDirectory });
|
|
292
355
|
const [raw, errText] = await Promise.all([
|
|
@@ -325,6 +388,10 @@ export class DiffPanel extends BasePanel {
|
|
|
325
388
|
* `/diff staged` command handler.
|
|
326
389
|
*/
|
|
327
390
|
async showStagedDiff(): Promise<void> {
|
|
391
|
+
if (!GitService.isGitRepo(this.workingDirectory)) {
|
|
392
|
+
this.showNotGitRepo();
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
328
395
|
const proc = Bun.spawn(['/bin/sh', '-c', 'git diff --cached'], { stdout: 'pipe', stderr: 'pipe', cwd: this.workingDirectory });
|
|
329
396
|
const [raw, errText] = await Promise.all([
|
|
330
397
|
new Response(proc.stdout).text(),
|
|
@@ -353,7 +420,7 @@ export class DiffPanel extends BasePanel {
|
|
|
353
420
|
if (files.length === 0) return;
|
|
354
421
|
const { computeSemanticDiff, formatSemanticDiffSummary } = await import('../renderer/semantic-diff.ts');
|
|
355
422
|
const { join, relative: pathRelative } = await import('path');
|
|
356
|
-
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 });
|
|
357
424
|
await repoRootProc.exited;
|
|
358
425
|
const repoRoot = (await new Response(repoRootProc.stdout).text()).trim() || this.workingDirectory;
|
|
359
426
|
await Promise.allSettled(
|
|
@@ -418,6 +485,22 @@ export class DiffPanel extends BasePanel {
|
|
|
418
485
|
// Input
|
|
419
486
|
// -------------------------------------------------------------------------
|
|
420
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
|
+
|
|
421
504
|
handleInput(key: string): boolean {
|
|
422
505
|
switch (key) {
|
|
423
506
|
case 'up': this.scrollUp(); return true;
|
|
@@ -433,9 +516,9 @@ export class DiffPanel extends BasePanel {
|
|
|
433
516
|
case 'backtab': this.prevFile(); return true;
|
|
434
517
|
case 'pageup': this.scrollPageUp(); return true;
|
|
435
518
|
case 'pagedown': this.scrollPageDown(); return true;
|
|
436
|
-
case 'w':
|
|
437
|
-
case 'h':
|
|
438
|
-
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;
|
|
439
522
|
case 'o': {
|
|
440
523
|
if (!this.currentEntry()) return false;
|
|
441
524
|
this.pendingOpenPreview = true;
|
|
@@ -623,11 +706,15 @@ export class DiffPanel extends BasePanel {
|
|
|
623
706
|
for (let i = 0; i < this.entries.length; i++) {
|
|
624
707
|
const entry = this.entries[i]!;
|
|
625
708
|
const active = i === this.selectedFile;
|
|
709
|
+
const placeholder = isPlaceholderPath(entry.filePath);
|
|
626
710
|
const stat = diffStat(entry);
|
|
627
|
-
|
|
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);
|
|
628
715
|
const bg = active ? COLOR.tabActiveBg : COLOR.tabBg;
|
|
629
716
|
// Active file gets a leading marker; every tab shows +adds/-dels at a glance.
|
|
630
|
-
const marker = active ? '▸ ' : ' ';
|
|
717
|
+
const marker = active ? '▸ ' : (placeholder ? '⚠ ' : ' ');
|
|
631
718
|
const label = `${marker}${basename(entry.filePath)} `;
|
|
632
719
|
for (const ch of label) push(ch, fg, bg, active);
|
|
633
720
|
if (stat.added > 0) for (const ch of `+${stat.added}`) push(ch, COLOR.addition, bg, active);
|
|
@@ -650,11 +737,15 @@ export class DiffPanel extends BasePanel {
|
|
|
650
737
|
return buildStyledPanelLine(width, [{ text: ' No file', fg: COLOR.tabInactive, bg: COLOR.statusBar }], { fillBg: COLOR.statusBar });
|
|
651
738
|
}
|
|
652
739
|
const stat = diffStat(entry);
|
|
740
|
+
const placeholder = isPlaceholderPath(entry.filePath);
|
|
653
741
|
// Keep the file path display-width-aware so a long/wide path can't overflow.
|
|
654
742
|
const pathBudget = Math.max(8, Math.floor(width / 2));
|
|
655
743
|
const fileInfo = truncateDisplay(entry.filePath, pathBudget);
|
|
656
744
|
const segments: Array<{ text: string; fg: string; bg?: string; bold?: boolean }> = [
|
|
657
|
-
|
|
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 },
|
|
658
749
|
{ text: `[${this.selectedFile + 1}/${this.entries.length}]`, fg: COLOR.tabInactive, bg: COLOR.statusBar },
|
|
659
750
|
{ text: ' +', fg: COLOR.tabInactive, bg: COLOR.statusBar },
|
|
660
751
|
{ text: String(stat.added), fg: COLOR.addition, bg: COLOR.statusBar },
|
|
@@ -666,6 +757,9 @@ export class DiffPanel extends BasePanel {
|
|
|
666
757
|
if (entry.semanticSummary) {
|
|
667
758
|
segments.push({ text: ` ◈ ${entry.semanticSummary}`, fg: COLOR.hunk, bg: COLOR.statusBar });
|
|
668
759
|
}
|
|
760
|
+
if (this.hotkeyStatus) {
|
|
761
|
+
segments.push({ text: ` ${this.hotkeyStatus}`, fg: COLOR.info, bg: COLOR.statusBar, bold: true });
|
|
762
|
+
}
|
|
669
763
|
return buildStyledPanelLine(width, segments, { fillBg: COLOR.statusBar });
|
|
670
764
|
}
|
|
671
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;
|