@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.
Files changed (101) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/context-usage.ts +53 -0
  5. package/src/core/conversation.ts +4 -6
  6. package/src/core/session-recovery.ts +2 -0
  7. package/src/core/turn-event-wiring.ts +1 -0
  8. package/src/input/commands/eval.ts +4 -3
  9. package/src/input/handler-feed-routes.ts +61 -4
  10. package/src/input/handler-feed.ts +13 -0
  11. package/src/input/handler-picker-routes.ts +18 -13
  12. package/src/input/handler-ui-state.ts +4 -0
  13. package/src/input/keybindings.ts +5 -0
  14. package/src/input/model-picker.ts +5 -0
  15. package/src/input/panel-integration-actions.ts +10 -0
  16. package/src/main.ts +0 -1
  17. package/src/panels/agent-inspector-panel.ts +18 -13
  18. package/src/panels/agent-inspector-shared.ts +3 -3
  19. package/src/panels/agent-logs-panel.ts +22 -53
  20. package/src/panels/approval-panel.ts +146 -95
  21. package/src/panels/automation-control-panel.ts +24 -1
  22. package/src/panels/base-panel.ts +26 -2
  23. package/src/panels/builtin/development.ts +1 -1
  24. package/src/panels/cockpit-panel.ts +24 -5
  25. package/src/panels/cockpit-read-model.ts +2 -1
  26. package/src/panels/communication-panel.ts +108 -43
  27. package/src/panels/context-visualizer-panel.ts +25 -1
  28. package/src/panels/control-plane-panel.ts +27 -1
  29. package/src/panels/cost-tracker-panel.ts +82 -54
  30. package/src/panels/debug-panel.ts +51 -16
  31. package/src/panels/diff-panel.ts +59 -30
  32. package/src/panels/docs-panel.ts +19 -3
  33. package/src/panels/eval-panel.ts +130 -81
  34. package/src/panels/expandable-list-panel.ts +189 -0
  35. package/src/panels/file-explorer-panel.ts +40 -41
  36. package/src/panels/file-preview-panel.ts +9 -2
  37. package/src/panels/forensics-panel.ts +27 -13
  38. package/src/panels/git-panel.ts +58 -70
  39. package/src/panels/hooks-panel.ts +36 -2
  40. package/src/panels/incident-review-panel.ts +31 -10
  41. package/src/panels/intelligence-panel.ts +152 -71
  42. package/src/panels/knowledge-graph-panel.ts +89 -27
  43. package/src/panels/local-auth-panel.ts +17 -11
  44. package/src/panels/marketplace-panel.ts +31 -12
  45. package/src/panels/memory-panel.ts +5 -4
  46. package/src/panels/ops-control-panel.ts +69 -7
  47. package/src/panels/ops-strategy-panel.ts +61 -43
  48. package/src/panels/orchestration-panel.ts +34 -4
  49. package/src/panels/panel-list-panel.ts +33 -8
  50. package/src/panels/panel-manager.ts +23 -4
  51. package/src/panels/plan-dashboard-panel.ts +183 -66
  52. package/src/panels/plugins-panel.ts +48 -10
  53. package/src/panels/policy-panel.ts +60 -23
  54. package/src/panels/polish-core.ts +157 -0
  55. package/src/panels/polish-tables.ts +258 -0
  56. package/src/panels/polish.ts +44 -145
  57. package/src/panels/project-planning-panel.ts +22 -0
  58. package/src/panels/provider-accounts-panel.ts +55 -18
  59. package/src/panels/provider-health-panel.ts +65 -23
  60. package/src/panels/provider-stats-panel.ts +47 -22
  61. package/src/panels/qr-panel.ts +23 -11
  62. package/src/panels/remote-panel.ts +12 -5
  63. package/src/panels/routes-panel.ts +27 -5
  64. package/src/panels/sandbox-panel.ts +62 -27
  65. package/src/panels/schedule-panel.ts +40 -17
  66. package/src/panels/scrollable-list-panel.ts +124 -10
  67. package/src/panels/security-panel.ts +72 -20
  68. package/src/panels/services-panel.ts +68 -22
  69. package/src/panels/session-browser-panel.ts +29 -5
  70. package/src/panels/session-maintenance.ts +2 -2
  71. package/src/panels/settings-sync-panel.ts +30 -15
  72. package/src/panels/subscription-panel.ts +21 -3
  73. package/src/panels/symbol-outline-panel.ts +40 -47
  74. package/src/panels/system-messages-panel.ts +60 -27
  75. package/src/panels/tasks-panel.ts +34 -7
  76. package/src/panels/thinking-panel.ts +26 -25
  77. package/src/panels/token-budget-panel.ts +49 -38
  78. package/src/panels/tool-inspector-panel.ts +27 -21
  79. package/src/panels/types.ts +25 -0
  80. package/src/panels/watchers-panel.ts +25 -5
  81. package/src/panels/work-plan-panel.ts +125 -34
  82. package/src/panels/worktree-panel.ts +65 -20
  83. package/src/panels/wrfc-panel-format.ts +133 -0
  84. package/src/panels/wrfc-panel.ts +31 -127
  85. package/src/renderer/compaction-preview.ts +2 -1
  86. package/src/renderer/compositor.ts +46 -43
  87. package/src/renderer/model-picker-overlay.ts +9 -4
  88. package/src/renderer/model-workspace.ts +2 -3
  89. package/src/renderer/panel-composite.ts +7 -20
  90. package/src/renderer/panel-workspace-bar.ts +14 -8
  91. package/src/renderer/progress.ts +3 -2
  92. package/src/renderer/tab-strip.ts +148 -34
  93. package/src/renderer/ui-factory.ts +4 -6
  94. package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
  95. package/src/runtime/ui/model-picker/types.ts +4 -0
  96. package/src/utils/format-duration.ts +55 -0
  97. package/src/utils/format-number.ts +71 -0
  98. package/src/version.ts +1 -1
  99. package/src/panels/panel-picker.ts +0 -106
  100. package/src/renderer/panel-picker-overlay.ts +0 -202
  101. 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
- if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(0)}M`;
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
- if (value >= 1_000_000) return `${Math.round(value / 1_000_000)}M`;
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
- topTabBar = renderPanelTabBar(
119
- topPane.panels,
120
- topPane.activeIndex,
121
- panelWidth,
122
- input.panelFocused && focusedPane === 'top',
123
- 'top',
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
- bottomTabBar,
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
- const ACTIVE_FG = '#e2e8f0';
6
- const ACTIVE_BG = '#0f172a';
7
- const INACTIVE_FG = '#94a3b8';
8
- const SEPARATOR_FG = '238';
9
- const LABEL_FG = '#cbd5e1';
10
- const LABEL_BG = '#1e293b';
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
- labelFg: LABEL_FG,
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,
@@ -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 >= 1000 ? `${(used / 1000).toFixed(1)}k` : String(used);
55
- const maxK = max >= 1000 ? `${(max / 1000).toFixed(1)}k` : String(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
- ): { segments: StyledPanelSegment[]; hasOverflow: boolean } {
38
- const segments: StyledPanelSegment[] = [];
39
- let used = 0;
40
- let hasOverflow = false;
41
- for (const tab of tabs) {
42
- const separatorWidth = getDisplayWidth('│');
43
- const labelText = tab.active ? `[${tab.label}]` : tab.label;
44
- const labelWidth = getDisplayWidth(labelText);
45
- const entryWidth = separatorWidth + labelWidth + 1;
46
- if (used + entryWidth > maxWidth) {
47
- hasOverflow = true;
48
- break;
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
- segments.push({ text: '│', fg: style.separatorFg });
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: `${labelText} `,
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
- used += entryWidth;
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
- if (segments.length > 0 && used + getDisplayWidth('│') <= maxWidth) {
61
- segments.push({ text: '│', fg: style.separatorFg });
62
- }
63
- return { segments, hasOverflow };
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
- overflowIndicator = '›',
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 reservedOverflowWidth = getDisplayWidth(overflowIndicator);
78
- const maxTabWidth = Math.max(
79
- 0,
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, hasOverflow } = makeSegments(tabs, style, maxTabWidth);
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 (hasOverflow) suffixSegments.push({ text: overflowIndicator, fg: style.overflowFg ?? style.separatorFg });
208
+ if (overflowRight) {
209
+ suffixSegments.push({ text: overflowIndicator, fg: style.overflowFg ?? style.separatorFg });
210
+ }
100
211
  if (suffixLabel) {
101
- suffixSegments.push({
102
- text: suffixLabel,
103
- fg: style.trailingFg ?? style.inactiveFg,
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
- if (n < 1000) return String(n);
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 = Math.min(1, ctxTokens / contextWindow);
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.27.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;