@pellux/goodvibes-tui 0.27.0 → 0.28.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 +23 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/core/context-usage.ts +53 -0
- package/src/core/conversation.ts +4 -6
- package/src/core/session-recovery.ts +2 -0
- package/src/core/turn-event-wiring.ts +1 -0
- package/src/input/commands/eval.ts +4 -3
- package/src/input/handler-feed-routes.ts +61 -4
- package/src/input/handler-feed.ts +13 -0
- package/src/input/handler-picker-routes.ts +18 -13
- package/src/input/handler-ui-state.ts +4 -0
- package/src/input/keybindings.ts +5 -0
- package/src/input/model-picker.ts +5 -0
- package/src/input/panel-integration-actions.ts +10 -0
- package/src/main.ts +0 -1
- package/src/panels/agent-inspector-panel.ts +18 -13
- package/src/panels/agent-inspector-shared.ts +3 -3
- package/src/panels/agent-logs-panel.ts +22 -53
- package/src/panels/approval-panel.ts +146 -95
- package/src/panels/automation-control-panel.ts +24 -1
- package/src/panels/base-panel.ts +26 -2
- package/src/panels/builtin/development.ts +1 -1
- package/src/panels/cockpit-panel.ts +24 -5
- package/src/panels/cockpit-read-model.ts +2 -1
- package/src/panels/communication-panel.ts +108 -43
- package/src/panels/context-visualizer-panel.ts +25 -1
- package/src/panels/control-plane-panel.ts +27 -1
- package/src/panels/cost-tracker-panel.ts +82 -54
- package/src/panels/debug-panel.ts +51 -16
- package/src/panels/diff-panel.ts +59 -30
- package/src/panels/docs-panel.ts +19 -3
- package/src/panels/eval-panel.ts +130 -81
- package/src/panels/expandable-list-panel.ts +189 -0
- package/src/panels/file-explorer-panel.ts +40 -41
- package/src/panels/file-preview-panel.ts +9 -2
- package/src/panels/forensics-panel.ts +27 -13
- package/src/panels/git-panel.ts +58 -70
- package/src/panels/hooks-panel.ts +36 -2
- package/src/panels/incident-review-panel.ts +31 -10
- package/src/panels/intelligence-panel.ts +152 -71
- package/src/panels/knowledge-graph-panel.ts +89 -27
- package/src/panels/local-auth-panel.ts +17 -11
- package/src/panels/marketplace-panel.ts +31 -12
- package/src/panels/memory-panel.ts +5 -4
- package/src/panels/ops-control-panel.ts +69 -7
- package/src/panels/ops-strategy-panel.ts +61 -43
- package/src/panels/orchestration-panel.ts +34 -4
- package/src/panels/panel-list-panel.ts +33 -8
- package/src/panels/panel-manager.ts +23 -4
- package/src/panels/plan-dashboard-panel.ts +183 -66
- package/src/panels/plugins-panel.ts +48 -10
- package/src/panels/policy-panel.ts +60 -23
- package/src/panels/polish-core.ts +157 -0
- package/src/panels/polish-tables.ts +258 -0
- package/src/panels/polish.ts +44 -145
- package/src/panels/project-planning-panel.ts +22 -0
- package/src/panels/provider-accounts-panel.ts +55 -18
- package/src/panels/provider-health-panel.ts +65 -23
- package/src/panels/provider-stats-panel.ts +47 -22
- package/src/panels/qr-panel.ts +23 -11
- package/src/panels/remote-panel.ts +12 -5
- package/src/panels/routes-panel.ts +27 -5
- package/src/panels/sandbox-panel.ts +62 -27
- package/src/panels/schedule-panel.ts +40 -17
- package/src/panels/scrollable-list-panel.ts +124 -10
- package/src/panels/security-panel.ts +72 -20
- package/src/panels/services-panel.ts +68 -22
- package/src/panels/session-browser-panel.ts +29 -5
- package/src/panels/session-maintenance.ts +2 -2
- package/src/panels/settings-sync-panel.ts +30 -15
- package/src/panels/subscription-panel.ts +21 -3
- package/src/panels/symbol-outline-panel.ts +40 -47
- package/src/panels/system-messages-panel.ts +60 -27
- package/src/panels/tasks-panel.ts +34 -7
- package/src/panels/thinking-panel.ts +26 -25
- package/src/panels/token-budget-panel.ts +49 -38
- package/src/panels/tool-inspector-panel.ts +27 -21
- package/src/panels/types.ts +25 -0
- package/src/panels/watchers-panel.ts +25 -5
- package/src/panels/work-plan-panel.ts +125 -34
- package/src/panels/worktree-panel.ts +65 -20
- package/src/panels/wrfc-panel-format.ts +133 -0
- package/src/panels/wrfc-panel.ts +31 -127
- package/src/renderer/compaction-preview.ts +2 -1
- package/src/renderer/compositor.ts +46 -43
- package/src/renderer/model-picker-overlay.ts +9 -4
- package/src/renderer/model-workspace.ts +2 -3
- package/src/renderer/panel-composite.ts +7 -20
- package/src/renderer/panel-workspace-bar.ts +14 -8
- package/src/renderer/progress.ts +3 -2
- package/src/renderer/tab-strip.ts +148 -34
- package/src/renderer/ui-factory.ts +4 -6
- package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
- package/src/runtime/ui/model-picker/types.ts +4 -0
- package/src/utils/format-duration.ts +55 -0
- package/src/utils/format-number.ts +71 -0
- package/src/version.ts +1 -1
- package/src/panels/panel-picker.ts +0 -106
- package/src/renderer/panel-picker-overlay.ts +0 -202
- package/src/renderer/panel-tab-bar.ts +0 -69
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type Line } from '../types/grid.ts';
|
|
2
2
|
import { fitDisplay, getDisplayWidth, truncateDisplay } from '../utils/terminal-width.ts';
|
|
3
|
+
import { abbreviateCount } from '../utils/format-number.ts';
|
|
3
4
|
import type { ModelPickerModal } from '../input/model-picker.ts';
|
|
4
5
|
import { EFFORT_DESCRIPTIONS } from '@pellux/goodvibes-sdk/platform/providers';
|
|
5
6
|
import { getQualityTier, getQualityTierFromScore } from '@pellux/goodvibes-sdk/platform/providers';
|
|
@@ -15,9 +16,7 @@ import { getOverlaySurfaceMetrics } from './overlay-viewport.ts';
|
|
|
15
16
|
|
|
16
17
|
/** Format a context window number into a short human-readable string. */
|
|
17
18
|
function fmtContext(n: number): string {
|
|
18
|
-
|
|
19
|
-
if (n >= 1000) return `${Math.round(n / 1000)}k`;
|
|
20
|
-
return String(n);
|
|
19
|
+
return abbreviateCount(n, { rounding: 'round', decimals: 0 });
|
|
21
20
|
}
|
|
22
21
|
|
|
23
22
|
/** Title text per picker mode. */
|
|
@@ -360,6 +359,12 @@ export function renderModelPickerOverlay(
|
|
|
360
359
|
putRowText(promptRow, layout.margin + 2, contentW, fitDisplay(`${promptLabel} ${inputDisplay}`, contentW), '#ffffff');
|
|
361
360
|
lines.push(promptRow);
|
|
362
361
|
|
|
362
|
+
if (picker.contextCapError) {
|
|
363
|
+
const errRow = createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.bodyBg);
|
|
364
|
+
putRowText(errRow, layout.margin + 2, contentW, fitDisplay(picker.contextCapError, contentW), '#ff6666');
|
|
365
|
+
lines.push(errRow);
|
|
366
|
+
}
|
|
367
|
+
|
|
363
368
|
lines.push(createOverlayContentLine(width, layout, borderFg, DEFAULT_OVERLAY_PALETTE.bodyBg));
|
|
364
369
|
|
|
365
370
|
const hintText = `Leave blank to use default (current: ${currentCtx}, source: ${provenance})`;
|
|
@@ -458,7 +463,7 @@ function getRenderCacheKey(
|
|
|
458
463
|
} else if (picker.mode === 'effort') {
|
|
459
464
|
base.push(objectId(picker.effortLevels), picker.effortLevels.join('\u001f'), picker.pendingModel?.registryKey ?? picker.pendingModel?.id ?? '');
|
|
460
465
|
} else if (picker.mode === 'contextCap') {
|
|
461
|
-
base.push(picker.contextCapQuery, picker.contextCapPendingModel?.registryKey ?? picker.contextCapPendingModel?.id ?? '');
|
|
466
|
+
base.push(picker.contextCapQuery, picker.contextCapPendingModel?.registryKey ?? picker.contextCapPendingModel?.id ?? '', picker.contextCapError ?? '');
|
|
462
467
|
}
|
|
463
468
|
|
|
464
469
|
return base.join('\u001e');
|
|
@@ -11,6 +11,7 @@ import type { ModelPickerTargetInfo } from '../input/model-picker.ts';
|
|
|
11
11
|
import type { Line } from '../types/grid.ts';
|
|
12
12
|
import { createEmptyLine, createStyledCell } from '../types/grid.ts';
|
|
13
13
|
import { getDisplayWidth, wrapText } from '../utils/terminal-width.ts';
|
|
14
|
+
import { abbreviateCount } from '../utils/format-number.ts';
|
|
14
15
|
import { GLYPHS, UI_TONES } from './ui-primitives.ts';
|
|
15
16
|
|
|
16
17
|
const PALETTE = {
|
|
@@ -129,9 +130,7 @@ function stableWindow(total: number, selected: number, visible: number): { start
|
|
|
129
130
|
|
|
130
131
|
function formatContext(value: number | undefined): string {
|
|
131
132
|
if (!value) return '-';
|
|
132
|
-
|
|
133
|
-
if (value >= 1000) return `${Math.round(value / 1000)}k`;
|
|
134
|
-
return String(value);
|
|
133
|
+
return abbreviateCount(value, { rounding: 'round', decimals: 0 });
|
|
135
134
|
}
|
|
136
135
|
|
|
137
136
|
function modelKey(model: ModelDefinition): string {
|
|
@@ -4,7 +4,6 @@ import type { InputHandler } from '../input/handler.ts';
|
|
|
4
4
|
import type { PanelManager } from '../panels/panel-manager.ts';
|
|
5
5
|
import type { PanelCompositeData } from './compositor.ts';
|
|
6
6
|
import { createSplitPaneLayout } from './layout-engine.ts';
|
|
7
|
-
import { renderPanelTabBar } from './panel-tab-bar.ts';
|
|
8
7
|
import { renderPanelWorkspaceBar } from './panel-workspace-bar.ts';
|
|
9
8
|
|
|
10
9
|
/**
|
|
@@ -108,33 +107,22 @@ export function buildPanelCompositeData(
|
|
|
108
107
|
const workspaceBar = renderPanelWorkspaceBar(workspaceTabs, panelWidth, input.panelFocused);
|
|
109
108
|
|
|
110
109
|
let topContent: Line[];
|
|
111
|
-
let topTabBar: Line | undefined;
|
|
112
|
-
let bottomTabBar: Line | undefined;
|
|
113
110
|
let bottomContent: Line[] | undefined;
|
|
114
111
|
|
|
115
112
|
const topActivePanel = topPane.panels[topPane.activeIndex] ?? null;
|
|
116
113
|
|
|
117
114
|
if (hasBottom) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
);
|
|
125
|
-
const paneLayout = createSplitPaneLayout(Math.max(0, panelHeight - 1), verticalSplitRatio);
|
|
115
|
+
// Single consolidated workspace bar (1 row) + h-separator (1 row); the rest
|
|
116
|
+
// splits between the two panes' content.
|
|
117
|
+
const paneLayout = createSplitPaneLayout(Math.max(0, panelHeight - 1), verticalSplitRatio, {
|
|
118
|
+
topTabRows: 0,
|
|
119
|
+
bottomTabRows: 0,
|
|
120
|
+
});
|
|
126
121
|
const topH = paneLayout.topContentRows;
|
|
127
122
|
const bottomH = paneLayout.bottomContentRows;
|
|
128
123
|
topContent = topActivePanel ? renderPanel(topActivePanel, panelWidth, topH) : [];
|
|
129
124
|
|
|
130
125
|
const bottomActivePanel = bottomPane.panels[bottomPane.activeIndex] ?? null;
|
|
131
|
-
bottomTabBar = renderPanelTabBar(
|
|
132
|
-
bottomPane.panels,
|
|
133
|
-
bottomPane.activeIndex,
|
|
134
|
-
panelWidth,
|
|
135
|
-
input.panelFocused && focusedPane === 'bottom',
|
|
136
|
-
'bottom',
|
|
137
|
-
);
|
|
138
126
|
bottomContent = bottomActivePanel ? renderPanel(bottomActivePanel, panelWidth, bottomH) : [];
|
|
139
127
|
} else {
|
|
140
128
|
const topH = Math.max(0, panelHeight - 1);
|
|
@@ -144,10 +132,9 @@ export function buildPanelCompositeData(
|
|
|
144
132
|
return {
|
|
145
133
|
panelData: {
|
|
146
134
|
workspaceBar,
|
|
147
|
-
topTabBar,
|
|
148
135
|
topContent,
|
|
149
136
|
topFocused: input.panelFocused && focusedPane === 'top',
|
|
150
|
-
|
|
137
|
+
hasBottomPane: hasBottom,
|
|
151
138
|
bottomContent,
|
|
152
139
|
bottomFocused: input.panelFocused && focusedPane === 'bottom',
|
|
153
140
|
separator: true,
|
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
import type { Line } from '../types/grid.ts';
|
|
2
2
|
import type { WorkspaceTab } from '../panels/panel-manager.ts';
|
|
3
|
-
import { renderTabStrip } from './tab-strip.ts';
|
|
3
|
+
import { renderTabStrip, type TabHitRegion } from './tab-strip.ts';
|
|
4
|
+
import { UI_TONES } from './ui-primitives.ts';
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
const
|
|
6
|
+
// Theme tokens (no raw hex) — keeps the bar in sync with the shared palette.
|
|
7
|
+
const ACTIVE_FG = UI_TONES.fg.primary; // selected tab text
|
|
8
|
+
const ACTIVE_BG = UI_TONES.bg.selected; // selected tab fill (stronger contrast)
|
|
9
|
+
const INACTIVE_FG = UI_TONES.fg.muted; // unselected tabs
|
|
10
|
+
const SEPARATOR_FG = UI_TONES.fg.dim; // │ separators
|
|
11
|
+
const LABEL_FG = UI_TONES.fg.secondary; // PANELS prefix label
|
|
12
|
+
const LABEL_BG = UI_TONES.bg.title; // prefix label background when focused
|
|
13
|
+
const FOCUS_ACCENT_FG = UI_TONES.state.active; // the one focused tab
|
|
11
14
|
|
|
12
15
|
export function renderPanelWorkspaceBar(
|
|
13
16
|
tabs: readonly WorkspaceTab[],
|
|
14
17
|
width: number,
|
|
15
18
|
focused: boolean,
|
|
19
|
+
onLayout?: (regions: readonly TabHitRegion[]) => void,
|
|
16
20
|
): Line {
|
|
17
21
|
return renderTabStrip({
|
|
18
22
|
width,
|
|
23
|
+
onLayout,
|
|
19
24
|
tabs: tabs.map((tab) => ({
|
|
20
25
|
// tab.active = selected in its own pane (drives highlighted background).
|
|
21
26
|
// tab.focused = has keyboard focus (drives brighter text / focus indicator).
|
|
@@ -32,7 +37,8 @@ export function renderPanelWorkspaceBar(
|
|
|
32
37
|
activeBold: focused,
|
|
33
38
|
inactiveFg: INACTIVE_FG,
|
|
34
39
|
separatorFg: SEPARATOR_FG,
|
|
35
|
-
|
|
40
|
+
// Accent the PANELS chip when focused so it ties to the focus border.
|
|
41
|
+
labelFg: focused ? FOCUS_ACCENT_FG : LABEL_FG,
|
|
36
42
|
labelBg: focused ? LABEL_BG : '',
|
|
37
43
|
labelBold: focused,
|
|
38
44
|
overflowFg: SEPARATOR_FG,
|
package/src/renderer/progress.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type Line } from '../types/grid.ts';
|
|
2
2
|
import { UIFactory } from './ui-factory.ts';
|
|
3
3
|
import { getDisplayWidth, padDisplayEnd } from '../utils/terminal-width.ts';
|
|
4
|
+
import { abbreviateCount } from '../utils/format-number.ts';
|
|
4
5
|
|
|
5
6
|
// Rich spinner frames (used by progress indicators)
|
|
6
7
|
export const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
@@ -51,8 +52,8 @@ export function renderTokenBar(
|
|
|
51
52
|
const lines: Line[] = [];
|
|
52
53
|
|
|
53
54
|
// Stats row
|
|
54
|
-
const usedK = used
|
|
55
|
-
const maxK = max
|
|
55
|
+
const usedK = abbreviateCount(used, { noM: true });
|
|
56
|
+
const maxK = abbreviateCount(max, { noM: true });
|
|
56
57
|
const pct = max > 0 ? Math.min(100, Math.round((used / max) * 100)) : 0;
|
|
57
58
|
const toolStr = toolCount > 0 ? ` tools:${toolCount}` : '';
|
|
58
59
|
const statsText = ` ${model} in:${usedK}/${maxK} (${pct}%)${toolStr}`;
|
|
@@ -21,46 +21,148 @@ export interface TabStripStyle {
|
|
|
21
21
|
readonly trailingFg?: string;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/** Absolute column range a rendered tab occupies, for click hit-testing. */
|
|
25
|
+
export interface TabHitRegion {
|
|
26
|
+
/** Index into the original `tabs` array. */
|
|
27
|
+
readonly index: number;
|
|
28
|
+
/** First column (inclusive) the tab occupies on the line. */
|
|
29
|
+
readonly startCol: number;
|
|
30
|
+
/** Last column (exclusive) the tab occupies on the line. */
|
|
31
|
+
readonly endCol: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
24
34
|
export interface TabStripConfig {
|
|
25
35
|
readonly width: number;
|
|
26
36
|
readonly tabs: readonly TabStripItem[];
|
|
27
37
|
readonly style: TabStripStyle;
|
|
28
38
|
readonly prefixLabel?: string;
|
|
29
39
|
readonly suffixLabel?: string;
|
|
40
|
+
/** Right-side overflow glyph (more tabs after the window). Default '›'. */
|
|
30
41
|
readonly overflowIndicator?: string;
|
|
42
|
+
/** Left-side overflow glyph (more tabs before the window). Default '‹'. */
|
|
43
|
+
readonly overflowIndicatorLeft?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Optional callback invoked with the absolute column ranges of each rendered
|
|
46
|
+
* tab. Used by the compositor to hit-test mouse clicks onto tabs. Additive —
|
|
47
|
+
* the function still returns a `Line` so existing callers are unaffected.
|
|
48
|
+
*/
|
|
49
|
+
readonly onLayout?: (regions: readonly TabHitRegion[]) => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const SEPARATOR = '│';
|
|
53
|
+
|
|
54
|
+
interface WindowResult {
|
|
55
|
+
segments: StyledPanelSegment[];
|
|
56
|
+
/** Per-tab column ranges relative to the start of the tab block. */
|
|
57
|
+
regions: Array<{ index: number; startCol: number; endCol: number }>;
|
|
58
|
+
overflowLeft: boolean;
|
|
59
|
+
overflowRight: boolean;
|
|
31
60
|
}
|
|
32
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Build the visible tab segments, windowed so the ACTIVE tab is always shown.
|
|
64
|
+
*
|
|
65
|
+
* Tabs are laid out left-to-right, but when they don't all fit we keep a
|
|
66
|
+
* contiguous window that contains the active tab and grow it outward (biased
|
|
67
|
+
* toward following tabs) until the budget is exhausted. Left/right overflow is
|
|
68
|
+
* reported so the caller can render `‹`/`›` indicators.
|
|
69
|
+
*/
|
|
33
70
|
function makeSegments(
|
|
34
71
|
tabs: readonly TabStripItem[],
|
|
35
72
|
style: TabStripStyle,
|
|
36
73
|
maxWidth: number,
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
74
|
+
indicatorWidth: number,
|
|
75
|
+
): WindowResult {
|
|
76
|
+
const empty: WindowResult = { segments: [], regions: [], overflowLeft: false, overflowRight: false };
|
|
77
|
+
if (tabs.length === 0 || maxWidth <= 0) return empty;
|
|
78
|
+
|
|
79
|
+
const sepWidth = getDisplayWidth(SEPARATOR);
|
|
80
|
+
// Per-tab entry width: leading separator + (bracketed if active) label + trailing space.
|
|
81
|
+
const labelText = (tab: TabStripItem) => (tab.active ? `[${tab.label}]` : tab.label);
|
|
82
|
+
const entryWidths = tabs.map((tab) => sepWidth + getDisplayWidth(labelText(tab)) + 1);
|
|
83
|
+
|
|
84
|
+
const activeIndex = Math.max(0, tabs.findIndex((t) => t.active));
|
|
85
|
+
const n = tabs.length;
|
|
86
|
+
const closing = sepWidth; // trailing │ after the last visible tab
|
|
87
|
+
const totalAll = entryWidths.reduce((a, b) => a + b, 0) + closing;
|
|
88
|
+
|
|
89
|
+
let start = activeIndex;
|
|
90
|
+
let end = activeIndex; // inclusive
|
|
91
|
+
let used = entryWidths[activeIndex] + closing;
|
|
92
|
+
|
|
93
|
+
if (totalAll <= maxWidth) {
|
|
94
|
+
// Everything fits — show all tabs, no windowing.
|
|
95
|
+
start = 0;
|
|
96
|
+
end = n - 1;
|
|
97
|
+
} else {
|
|
98
|
+
// Need windowing. Reserve room for indicators on whichever side still has
|
|
99
|
+
// hidden tabs; the reserve is recomputed each step so reaching an edge
|
|
100
|
+
// frees its indicator column.
|
|
101
|
+
const budget = maxWidth;
|
|
102
|
+
let leftDone = start === 0;
|
|
103
|
+
let rightDone = end === n - 1;
|
|
104
|
+
let preferRight = true;
|
|
105
|
+
|
|
106
|
+
const fits = (extra: number, nextStart: number, nextEnd: number) => {
|
|
107
|
+
const leftRes = nextStart > 0 ? indicatorWidth : 0;
|
|
108
|
+
const rightRes = nextEnd < n - 1 ? indicatorWidth : 0;
|
|
109
|
+
return used + extra + leftRes + rightRes <= budget;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
while (!leftDone || !rightDone) {
|
|
113
|
+
const goRight = !rightDone && (preferRight || leftDone);
|
|
114
|
+
if (goRight) {
|
|
115
|
+
const ne = end + 1;
|
|
116
|
+
if (fits(entryWidths[ne], start, ne)) {
|
|
117
|
+
end = ne;
|
|
118
|
+
used += entryWidths[ne];
|
|
119
|
+
if (end === n - 1) rightDone = true;
|
|
120
|
+
preferRight = false;
|
|
121
|
+
} else {
|
|
122
|
+
rightDone = true;
|
|
123
|
+
}
|
|
124
|
+
} else if (!leftDone) {
|
|
125
|
+
const ns = start - 1;
|
|
126
|
+
if (fits(entryWidths[ns], ns, end)) {
|
|
127
|
+
start = ns;
|
|
128
|
+
used += entryWidths[ns];
|
|
129
|
+
if (start === 0) leftDone = true;
|
|
130
|
+
preferRight = true;
|
|
131
|
+
} else {
|
|
132
|
+
leftDone = true;
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
49
137
|
}
|
|
50
|
-
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const segments: StyledPanelSegment[] = [];
|
|
141
|
+
const regions: Array<{ index: number; startCol: number; endCol: number }> = [];
|
|
142
|
+
let col = 0;
|
|
143
|
+
for (let i = start; i <= end; i++) {
|
|
144
|
+
const tab = tabs[i];
|
|
145
|
+
const text = labelText(tab);
|
|
146
|
+
const entryStart = col;
|
|
147
|
+
segments.push({ text: SEPARATOR, fg: style.separatorFg });
|
|
51
148
|
segments.push({
|
|
52
|
-
text: `${
|
|
149
|
+
text: `${text} `,
|
|
53
150
|
fg: tab.active ? style.activeFg : style.inactiveFg,
|
|
54
151
|
bg: tab.active ? style.activeBg : style.inactiveBg,
|
|
55
152
|
bold: tab.active ? (style.activeBold ?? false) : false,
|
|
56
153
|
});
|
|
57
|
-
|
|
154
|
+
col += entryWidths[i];
|
|
155
|
+
regions.push({ index: i, startCol: entryStart, endCol: col });
|
|
58
156
|
}
|
|
157
|
+
// Closing separator.
|
|
158
|
+
segments.push({ text: SEPARATOR, fg: style.separatorFg });
|
|
59
159
|
|
|
60
|
-
|
|
61
|
-
segments
|
|
62
|
-
|
|
63
|
-
|
|
160
|
+
return {
|
|
161
|
+
segments,
|
|
162
|
+
regions,
|
|
163
|
+
overflowLeft: start > 0,
|
|
164
|
+
overflowRight: end < n - 1,
|
|
165
|
+
};
|
|
64
166
|
}
|
|
65
167
|
|
|
66
168
|
export function renderTabStrip(config: TabStripConfig): Line {
|
|
@@ -70,18 +172,18 @@ export function renderTabStrip(config: TabStripConfig): Line {
|
|
|
70
172
|
style,
|
|
71
173
|
prefixLabel,
|
|
72
174
|
suffixLabel,
|
|
73
|
-
|
|
175
|
+
overflowIndicator = '›',
|
|
176
|
+
overflowIndicatorLeft = '‹',
|
|
177
|
+
onLayout,
|
|
74
178
|
} = config;
|
|
75
179
|
|
|
180
|
+
const prefixWidth = prefixLabel ? getDisplayWidth(prefixLabel) + 1 : 0;
|
|
76
181
|
const reservedSuffixWidth = suffixLabel ? getDisplayWidth(suffixLabel) : 0;
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
width
|
|
81
|
-
- (prefixLabel ? getDisplayWidth(prefixLabel) + 1 : 0)
|
|
82
|
-
- (suffixLabel ? reservedSuffixWidth : 0)
|
|
83
|
-
- reservedOverflowWidth,
|
|
182
|
+
const indicatorWidth = Math.max(
|
|
183
|
+
getDisplayWidth(overflowIndicator),
|
|
184
|
+
getDisplayWidth(overflowIndicatorLeft),
|
|
84
185
|
);
|
|
186
|
+
const maxTabWidth = Math.max(0, width - prefixWidth - reservedSuffixWidth);
|
|
85
187
|
|
|
86
188
|
const baseSegments: StyledPanelSegment[] = [];
|
|
87
189
|
if (prefixLabel) {
|
|
@@ -94,15 +196,27 @@ export function renderTabStrip(config: TabStripConfig): Line {
|
|
|
94
196
|
baseSegments.push({ text: ' ', fg: style.separatorFg });
|
|
95
197
|
}
|
|
96
198
|
|
|
97
|
-
const { segments: tabSegments,
|
|
199
|
+
const { segments: tabSegments, regions, overflowLeft, overflowRight } =
|
|
200
|
+
makeSegments(tabs, style, maxTabWidth, indicatorWidth);
|
|
201
|
+
|
|
202
|
+
const leftSegments: StyledPanelSegment[] = [];
|
|
203
|
+
if (overflowLeft) {
|
|
204
|
+
leftSegments.push({ text: overflowIndicatorLeft, fg: style.overflowFg ?? style.separatorFg });
|
|
205
|
+
}
|
|
206
|
+
|
|
98
207
|
const suffixSegments: StyledPanelSegment[] = [];
|
|
99
|
-
if (
|
|
208
|
+
if (overflowRight) {
|
|
209
|
+
suffixSegments.push({ text: overflowIndicator, fg: style.overflowFg ?? style.separatorFg });
|
|
210
|
+
}
|
|
100
211
|
if (suffixLabel) {
|
|
101
|
-
suffixSegments.push({
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
212
|
+
suffixSegments.push({ text: suffixLabel, fg: style.trailingFg ?? style.inactiveFg });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Report absolute hit regions: prefix + optional left indicator, then tabs.
|
|
216
|
+
if (onLayout) {
|
|
217
|
+
const offset = prefixWidth + (overflowLeft ? getDisplayWidth(overflowIndicatorLeft) : 0);
|
|
218
|
+
onLayout(regions.map((r) => ({ index: r.index, startCol: offset + r.startCol, endCol: offset + r.endCol })));
|
|
105
219
|
}
|
|
106
220
|
|
|
107
|
-
return buildStyledPanelLine(width, [...baseSegments, ...tabSegments, ...suffixSegments]);
|
|
221
|
+
return buildStyledPanelLine(width, [...baseSegments, ...leftSegments, ...tabSegments, ...suffixSegments]);
|
|
108
222
|
}
|
|
@@ -6,6 +6,8 @@ import type { GitHeaderInfo } from './git-status.ts';
|
|
|
6
6
|
import { renderConversationFragment, renderConversationStatusLine, type ConversationStatusSegment } from './conversation-surface.ts';
|
|
7
7
|
import { GLYPHS } from './ui-primitives.ts';
|
|
8
8
|
import { formatElapsed } from '../utils/format-elapsed.ts';
|
|
9
|
+
import { abbreviateCount } from '../utils/format-number.ts';
|
|
10
|
+
import { computeContextUsage } from '../core/context-usage.ts';
|
|
9
11
|
|
|
10
12
|
/** Number of frames before the animated gradient completes one full cycle. */
|
|
11
13
|
const GRADIENT_CYCLE_FRAMES = 50;
|
|
@@ -30,11 +32,7 @@ function buildGitSegment(gitInfo: GitHeaderInfo): { text: string; width: number
|
|
|
30
32
|
|
|
31
33
|
/** Format a number: up to 999, then 1.0k, 1.0M, 1.0B, 1.0T */
|
|
32
34
|
function fmtNum(n: number): string {
|
|
33
|
-
|
|
34
|
-
if (n < 1_000_000) return (n / 1000).toFixed(1) + 'k';
|
|
35
|
-
if (n < 1_000_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
|
36
|
-
if (n < 1_000_000_000_000) return (n / 1_000_000_000).toFixed(1) + 'B';
|
|
37
|
-
return (n / 1_000_000_000_000).toFixed(1) + 'T';
|
|
35
|
+
return abbreviateCount(n, { bSuffix: true });
|
|
38
36
|
}
|
|
39
37
|
|
|
40
38
|
/**
|
|
@@ -312,7 +310,7 @@ export class UIFactory {
|
|
|
312
310
|
const label = ' Context Usage: ';
|
|
313
311
|
const suffix = ` [ ${fmtNum(ctxTokens)} / ${fmtNum(contextWindow)} ]`;
|
|
314
312
|
const barWidth = Math.max(10, Math.min(30, width - getDisplayWidth(label) - getDisplayWidth(suffix) - 8));
|
|
315
|
-
const ctxPct =
|
|
313
|
+
const ctxPct = computeContextUsage(ctxTokens, contextWindow).clampedRatio;
|
|
316
314
|
// Clamp threshold to [0..1]; undefined/0 means no threshold marker.
|
|
317
315
|
const thresholdFraction = (compactThreshold !== undefined && compactThreshold > 0)
|
|
318
316
|
? Math.min(1, compactThreshold)
|
|
@@ -154,6 +154,9 @@ export function enrichModelEntries(
|
|
|
154
154
|
// Determine source: custom/local providers carry provenance on ModelDefinition;
|
|
155
155
|
// for catalog models, if getContextWindowForModel returned more than the
|
|
156
156
|
// static contextWindow it came from OpenRouter, else it's the registry value.
|
|
157
|
+
// NOTE: This is a best-effort heuristic. When the OpenRouter live value equals
|
|
158
|
+
// the static registry value the badge shows 'registry' even though OpenRouter
|
|
159
|
+
// was consulted. The contextWindow value itself is always accurate.
|
|
157
160
|
let contextWindowSource: ModelPickerEntry['contextWindowSource'];
|
|
158
161
|
if (model.contextWindowProvenance) {
|
|
159
162
|
contextWindowSource = model.contextWindowProvenance;
|
|
@@ -97,6 +97,10 @@ export interface ModelPickerEntry {
|
|
|
97
97
|
* - `fallback` — default constant (no config or API source)
|
|
98
98
|
* - `openrouter` — sourced from OpenRouter model data (built-in catalog models)
|
|
99
99
|
* - `registry` — static value in the built-in model registry
|
|
100
|
+
*
|
|
101
|
+
* For built-in catalog models this badge is best-effort: when the OpenRouter
|
|
102
|
+
* resolved value equals the static registry value the source shows 'registry'
|
|
103
|
+
* even if OpenRouter was consulted at runtime.
|
|
100
104
|
*/
|
|
101
105
|
readonly contextWindowSource:
|
|
102
106
|
| 'provider_api'
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared ms-duration formatters for the panel layer.
|
|
3
|
+
*
|
|
4
|
+
* Three families are exported, each matching a distinct behavior cluster.
|
|
5
|
+
* DO NOT replace these with formatElapsed from utils/format-elapsed.ts —
|
|
6
|
+
* formatElapsed floors to integer seconds and lacks the null/'?ms' guards
|
|
7
|
+
* that the latency panels require.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Format a latency value in milliseconds with sub-second precision.
|
|
12
|
+
*
|
|
13
|
+
* Used by: debug-panel, provider-health-panel, provider-stats-panel
|
|
14
|
+
*
|
|
15
|
+
* ms <= 0 → 'n/a'
|
|
16
|
+
* ms >= 10000 → '12.3s' (one decimal)
|
|
17
|
+
* ms >= 1000 → '1.23s' (two decimals)
|
|
18
|
+
* else → '500ms' (integer ms)
|
|
19
|
+
*/
|
|
20
|
+
export function formatLatencyMs(ms: number): string {
|
|
21
|
+
if (ms <= 0) return 'n/a';
|
|
22
|
+
if (ms >= 10_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
23
|
+
if (ms >= 1_000) return `${(ms / 1000).toFixed(2)}s`;
|
|
24
|
+
return `${Math.round(ms)}ms`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Format a task/agent duration with minute-rolling notation.
|
|
29
|
+
*
|
|
30
|
+
* Used by: tool-inspector-panel, agent-inspector-shared
|
|
31
|
+
*
|
|
32
|
+
* ms < 1000 → '500ms'
|
|
33
|
+
* ms < 60000 → '3.5s'
|
|
34
|
+
* else → '1m30s'
|
|
35
|
+
*/
|
|
36
|
+
export function formatDuration(ms: number): string {
|
|
37
|
+
if (ms < 1000) return `${ms}ms`;
|
|
38
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
39
|
+
return `${Math.floor(ms / 60000)}m${Math.floor((ms % 60000) / 1000)}s`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Format a short eval/forensics duration; treats undefined as '?ms'.
|
|
44
|
+
*
|
|
45
|
+
* Used by: forensics-panel, eval-panel
|
|
46
|
+
*
|
|
47
|
+
* undefined → '?ms'
|
|
48
|
+
* ms < 1000 → '500ms'
|
|
49
|
+
* else → '1.5s'
|
|
50
|
+
*/
|
|
51
|
+
export function formatShortDuration(ms: number | undefined): string {
|
|
52
|
+
if (ms === undefined) return '?ms';
|
|
53
|
+
if (ms < 1000) return `${ms}ms`;
|
|
54
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
55
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parametrised number abbreviation helper.
|
|
3
|
+
*
|
|
4
|
+
* Each call site can reproduce its exact current output by passing the
|
|
5
|
+
* appropriate options rather than a single "agreed" format being imposed.
|
|
6
|
+
*
|
|
7
|
+
* Ranges (default opts: guard=1_000, decimals=1, rounding='fixed'):
|
|
8
|
+
* n < guard → String(n) e.g. 999 → '999'
|
|
9
|
+
* n < 1_000_000 → X.Xk e.g. 1500 → '1.5k'
|
|
10
|
+
* n < 1_000_000_000 → X.XM e.g. 1.5M → '1.5M'
|
|
11
|
+
* (with bSuffix) → X.XB / X.XT
|
|
12
|
+
* (with noM) → always k, never M
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface AbbreviateCountOpts {
|
|
16
|
+
/**
|
|
17
|
+
* Values strictly below this are returned as-is via String(n).
|
|
18
|
+
* Default: 1_000
|
|
19
|
+
*/
|
|
20
|
+
guard?: number;
|
|
21
|
+
/**
|
|
22
|
+
* Decimal places used for the k suffix (and for M when mDecimals is not set).
|
|
23
|
+
* Default: 1
|
|
24
|
+
*/
|
|
25
|
+
decimals?: number;
|
|
26
|
+
/**
|
|
27
|
+
* Override decimal places for the M suffix only.
|
|
28
|
+
* Default: same as decimals.
|
|
29
|
+
*/
|
|
30
|
+
mDecimals?: number;
|
|
31
|
+
/**
|
|
32
|
+
* 'fixed' → value.toFixed(decimals) (default)
|
|
33
|
+
* 'round' → String(Math.round(value)) — always integer, decimals ignored
|
|
34
|
+
*/
|
|
35
|
+
rounding?: 'fixed' | 'round';
|
|
36
|
+
/**
|
|
37
|
+
* When true, extend formatting to B (billions) and T (trillions).
|
|
38
|
+
* Default: false
|
|
39
|
+
*/
|
|
40
|
+
bSuffix?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* When true, never produce M/B/T — format as k even for millions.
|
|
43
|
+
* Default: false
|
|
44
|
+
*/
|
|
45
|
+
noM?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Format a non-negative integer with k/M/B/T magnitude suffix.
|
|
50
|
+
*
|
|
51
|
+
* Preserves each call site's exact current format by accepting
|
|
52
|
+
* per-site option overrides (guard, decimals, rounding, etc.).
|
|
53
|
+
*/
|
|
54
|
+
export function abbreviateCount(n: number, opts?: AbbreviateCountOpts): string {
|
|
55
|
+
const guard = opts?.guard ?? 1_000;
|
|
56
|
+
const decimals = opts?.decimals ?? 1;
|
|
57
|
+
const mDecimals = opts?.mDecimals ?? decimals;
|
|
58
|
+
const rounding = opts?.rounding ?? 'fixed';
|
|
59
|
+
const noM = opts?.noM ?? false;
|
|
60
|
+
const bSuffix = opts?.bSuffix ?? false;
|
|
61
|
+
|
|
62
|
+
/** Render a scaled value according to the configured rounding mode. */
|
|
63
|
+
const fmt = (val: number, d: number): string =>
|
|
64
|
+
rounding === 'round' ? String(Math.round(val)) : val.toFixed(d);
|
|
65
|
+
|
|
66
|
+
if (n < guard) return String(n);
|
|
67
|
+
if (noM || n < 1_000_000) return fmt(n / 1_000, decimals) + 'k';
|
|
68
|
+
if (!bSuffix || n < 1_000_000_000) return fmt(n / 1_000_000, mDecimals) + 'M';
|
|
69
|
+
if (n < 1_000_000_000_000) return fmt(n / 1_000_000_000, decimals) + 'B';
|
|
70
|
+
return fmt(n / 1_000_000_000_000, decimals) + 'T';
|
|
71
|
+
}
|
package/src/version.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { join } from 'node:path';
|
|
|
6
6
|
// The prebuild script updates the fallback value before compilation.
|
|
7
7
|
// Uses import.meta.dir (Bun) to locate package.json relative to this file,
|
|
8
8
|
// which is correct regardless of the process working directory.
|
|
9
|
-
let _version = '0.
|
|
9
|
+
let _version = '0.28.0';
|
|
10
10
|
try {
|
|
11
11
|
const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
|
|
12
12
|
_version = pkg.version ?? _version;
|