@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
package/CHANGELOG.md CHANGED
@@ -4,6 +4,29 @@ All notable changes to GoodVibes TUI.
4
4
 
5
5
  ---
6
6
 
7
+ ## [0.28.0] — 2026-06-30
8
+
9
+ Panel navigation & UI/UX overhaul, plus the accumulated post-0.27.0 UX fixes.
10
+
11
+ ### Added
12
+ - **Unified panel navigation**: a single consolidated tab bar with a focus-accent border; jump directly to any panel with `Alt+1`–`Alt+9`, click tabs, and toggle the active pane with `Ctrl+G`.
13
+ - **In-panel filtering**: press `/` to filter the list in panels that support it.
14
+ - **Mouse-wheel scrolling** on every panel (via `BasePanel.handleScroll`).
15
+
16
+ ### Changed
17
+ - **Width-aware formatting** across panels: shared toolkit primitives (`fitDisplay`, table/tree/badge/meter helpers) replace hand-rolled `.slice()`/`.padEnd()`, so rows stay aligned across emoji, CJK, and other wide characters.
18
+ - **Scroll-back**: you can scroll up during and after a turn; the view only auto-follows the tail when parked at the bottom.
19
+ - **Auto-compaction is now owned entirely by the SDK** (`@pellux/goodvibes-sdk` 0.35.0 `handlePostTurnContextMaintenance`). The redundant TUI-side trigger was removed to prevent double-compaction; manual `/compact` now builds a richer handoff context (running agents, WRFC chains, active plan, session lineage).
20
+
21
+ ### Fixed
22
+ - Timer/async panels repaint while the main thread is idle instead of looking frozen (repaints gated on panel-active state).
23
+ - Input/keybinding papercuts: reverse-search accept, Home/End, and the command palette.
24
+ - Crash/signal safety net: the terminal is always restored on uncaught exceptions and termination signals.
25
+ - Consolidated number/duration/context-usage formatting and routed the context window through one corrected `getContextWindowForModel` source across every surface.
26
+
27
+ ### Internal
28
+ - Split `polish.ts` into leaf `polish-core.ts` + `polish-tables.ts` and extracted `wrfc-panel-format.ts` to satisfy the 800-line architecture gate; no public API changes (all symbols re-exported).
29
+
7
30
  ## [0.27.0] — 2026-06-30
8
31
 
9
32
  Full deep-review audit of the TUI (33 findings fixed, each reviewed to a score of 10) and adoption of `@pellux/goodvibes-sdk` 0.35.0.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml/badge.svg)](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
- [![Version](https://img.shields.io/badge/version-0.27.0-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
5
+ [![Version](https://img.shields.io/badge/version-0.28.0-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
6
6
 
7
7
  A terminal-native AI coding, operations, automation, knowledge, and integration console with a typed runtime, omnichannel surfaces, structured memory/knowledge, and a raw ANSI renderer.
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Shared context-window usage computation.
3
+ *
4
+ * Centralises the tokens/contextWindow ratio so all panels, auto-compact,
5
+ * and session-maintenance read from one formula rather than six diverging
6
+ * hand-rolled copies.
7
+ *
8
+ * IMPORTANT: compaction-preview.ts intentionally displays usage > 100%
9
+ * ("exceeds window") — callers that need the unclamped value must use
10
+ * rawRatio, not clampedRatio / pct.
11
+ */
12
+
13
+ export interface ContextUsage {
14
+ /**
15
+ * Raw (unclamped) ratio: max(0, tokens) / contextWindow.
16
+ * May exceed 1.0 when token count surpasses the context window.
17
+ * 0 when contextWindow <= 0.
18
+ */
19
+ rawRatio: number;
20
+ /**
21
+ * Ratio clamped to [0, 1]. Use for progress-bar fill and color bands.
22
+ */
23
+ clampedRatio: number;
24
+ /**
25
+ * Integer percentage clamped to [0, 100].
26
+ * Equivalent to Math.min(100, Math.round(rawRatio * 100)).
27
+ * Use for numeric displays and threshold comparisons.
28
+ */
29
+ pct: number;
30
+ /**
31
+ * Remaining tokens: max(0, contextWindow - tokens).
32
+ * 0 when contextWindow <= 0.
33
+ */
34
+ remaining: number;
35
+ }
36
+
37
+ /**
38
+ * Compute context-window usage metrics from raw token counts.
39
+ *
40
+ * @param tokens Current input-token count (negative values are treated as 0).
41
+ * @param contextWindow Model context window size (0 or negative → all fields return 0).
42
+ */
43
+ export function computeContextUsage(tokens: number, contextWindow: number): ContextUsage {
44
+ if (contextWindow <= 0) {
45
+ return { rawRatio: 0, clampedRatio: 0, pct: 0, remaining: 0 };
46
+ }
47
+ const safeTokens = Math.max(0, tokens);
48
+ const rawRatio = safeTokens / contextWindow;
49
+ const clampedRatio = Math.min(1, rawRatio);
50
+ const pct = Math.min(100, Math.round(rawRatio * 100));
51
+ const remaining = Math.max(0, contextWindow - tokens);
52
+ return { rawRatio, clampedRatio, pct, remaining };
53
+ }
@@ -110,7 +110,7 @@ export class ConversationManager extends SdkConversationManager {
110
110
  * Message index at the time of the last clearDisplay() call.
111
111
  * rebuildHistory() renders only messages at or after this index, so the
112
112
  * display stays blank for messages added before the clear while LLM history
113
- * is fully preserved. Reset to 0 on resetAll() or rebuildHistory() width change.
113
+ * is fully preserved. Persists until resetAll(). rebuildHistory() never resets this value.
114
114
  */
115
115
  private _displayFromMessageIndex = 0;
116
116
 
@@ -404,8 +404,6 @@ export class ConversationManager extends SdkConversationManager {
404
404
 
405
405
  // When _displayFromMessageIndex > 0, clearDisplay() was called. Only render
406
406
  // messages added after the clear — the pre-clear history stays off-screen.
407
- // On a full rebuild (e.g. width change), reset the display-start to 0 so the
408
- // user can scroll back to the full history if needed.
409
407
  const displayStart = this._displayFromMessageIndex;
410
408
  const visibleSnapshot = displayStart > 0 ? renderSnapshot.slice(displayStart) : renderSnapshot;
411
409
 
@@ -662,9 +660,9 @@ export class ConversationManager extends SdkConversationManager {
662
660
  // messageKindRegistry is NOT cleared here. The underlying messages array is
663
661
  // preserved by clearDisplay(); kind entries for pre-clear messages are harmless
664
662
  // because those messages are hidden by _displayFromMessageIndex and never rendered.
665
- // Clearing the registry would cause kind loss for pre-clear messages that become
666
- // visible again after a subsequent width-change rebuild (which resets displayStart
667
- // to 0), incorrectly making operational messages navigable.
663
+ // Clearing the registry would cause kind loss for pre-clear messages; if
664
+ // resetAll() later restores displayStart to 0, those messages re-render
665
+ // and their kind entries must still be present to suppress navigation.
668
666
  // Advance _displayFromMessageIndex to exclude all current messages from display.
669
667
  // rebuildHistory() will only render messages added AFTER this point.
670
668
  this._displayFromMessageIndex = this.getMessageSnapshot().length;
@@ -115,6 +115,8 @@ export function replayJournalIntoConversation(
115
115
  conversation.fromJSON({
116
116
  ...preserved,
117
117
  messages: replayedMessages as never[],
118
+ title: conversation.title,
119
+ titleSource: conversation.getTitleSource(),
118
120
  });
119
121
  conversation.rebuildHistory();
120
122
 
@@ -19,6 +19,7 @@ interface TurnOrchestrator {
19
19
  /** Minimal provider registry surface required by turn-event wiring. */
20
20
  interface TurnProviderRegistry {
21
21
  getCurrentModel(): { readonly contextWindow: number };
22
+ getContextWindowForModel(model: { readonly contextWindow: number }): number;
22
23
  }
23
24
 
24
25
  /** Minimal config manager surface required by turn-event wiring. */
@@ -109,8 +109,9 @@ async function handleCompare(args: string[], context: CommandContext): Promise<v
109
109
  // ── /eval gate ────────────────────────────────────────────────────────────────
110
110
 
111
111
  async function handleGate(args: string[], context: CommandContext): Promise<void> {
112
- const suiteName = args[0];
113
- const baselineFile = args[1] ?? '.goodvibes/eval/baseline.json';
112
+ const positionals = args.filter(a => !a.startsWith('--'));
113
+ const suiteName = positionals[0];
114
+ const baselineFile = positionals[1] ?? '.goodvibes/eval/baseline.json';
114
115
  const saveFlag = args.includes('--save-baseline');
115
116
  const projectRoot = requireShellPaths(context).workingDirectory;
116
117
 
@@ -141,7 +142,7 @@ async function handleGate(args: string[], context: CommandContext): Promise<void
141
142
  context.print(formatGateResult(gate));
142
143
 
143
144
  if (saveFlag || !baseline) {
144
- const label = args[0] ?? 'latest';
145
+ const label = positionals[0] ?? 'latest';
145
146
  const newBaseline = captureBaseline(label, [fresh]);
146
147
  try {
147
148
  await writeBaseline(baselineFile, newBaseline, projectRoot);
@@ -13,6 +13,8 @@ import {
13
13
  } from './handler-prompt-buffer.ts';
14
14
  import { cleanupMarkerRegistry, expandPrompt, findMarkerAtPos, registerPaste } from './handler-content-actions.ts';
15
15
  import type { PanelManager } from '../panels/panel-manager.ts';
16
+ import { renderPanelWorkspaceBar } from '../renderer/panel-workspace-bar.ts';
17
+ import type { TabHitRegion } from '../renderer/tab-strip.ts';
16
18
  import type { KeybindingsManager } from './keybindings.ts';
17
19
  import type { KillRing } from './kill-ring.ts';
18
20
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
@@ -84,6 +86,23 @@ export function handlePanelFocusToken(state: PanelFocusRouteState, token: InputT
84
86
  state.cyclePanelTab('prev');
85
87
  return { handled: true, panelFocused };
86
88
  }
89
+ if (kb.matches('panel-focus-toggle', token)) {
90
+ // Switch keyboard focus between the top and bottom panes (no-op when
91
+ // there is no visible, non-empty bottom pane).
92
+ state.panelManager.togglePaneFocus();
93
+ state.requestRender();
94
+ return { handled: true, panelFocused };
95
+ }
96
+ // Alt+1..9 — jump directly to the Nth workspace tab (across both panes).
97
+ // The tokenizer maps the Alt modifier onto `meta`, so an Alt-held digit
98
+ // arrives as { logicalName: '1'..'9', meta: true }. Gating on the modifier
99
+ // keeps plain digits reaching the focused panel.
100
+ if (token.meta && !token.ctrl && /^[1-9]$/.test(token.logicalName ?? '')) {
101
+ const index = Number(token.logicalName) - 1;
102
+ state.panelManager.activateWorkspaceIndex(index);
103
+ state.requestRender();
104
+ return { handled: true, panelFocused };
105
+ }
87
106
  if (kb.matches('panel-close-all', token)) {
88
107
  const pm = state.panelManager;
89
108
  for (const p of pm.getAllOpen()) pm.close(p.id);
@@ -577,18 +596,48 @@ function getPanelUnderMouse(
577
596
  return getActivePanelInPane(panelManager, 'top');
578
597
  }
579
598
 
599
+ // Single consolidated workspace bar (row 0) + h-separator; the rest splits
600
+ // between the two panes' content.
580
601
  const panelAreaRows = Math.max(0, layout.height - 1);
581
- const contentRows = Math.max(0, panelAreaRows - 3);
602
+ const contentRows = Math.max(0, panelAreaRows - 1);
582
603
  const topContentRows = contentRows <= 1
583
604
  ? contentRows
584
605
  : Math.max(1, Math.floor(contentRows * clampRatio(layout.verticalSplitRatio)));
585
- const topLastRow = 2 + topContentRows;
586
-
587
- return panelRow <= topLastRow
606
+ // panelRow 0 = workspace bar; rows 1..topContentRows = top pane; rest = bottom.
607
+ return panelRow <= topContentRows
588
608
  ? getActivePanelInPane(panelManager, 'top')
589
609
  : getActivePanelInPane(panelManager, 'bottom');
590
610
  }
591
611
 
612
+ /**
613
+ * If the mouse is over the consolidated workspace tab bar (the first panel
614
+ * row), return the index of the tab under the cursor, else null. Recomputes the
615
+ * tab hit regions by rendering the bar with a layout callback — cheap and keeps
616
+ * the click geometry in lockstep with what was drawn.
617
+ */
618
+ function workspaceTabAtMouse(
619
+ panelManager: PanelManager,
620
+ layout: PanelMouseLayout | null,
621
+ row: number,
622
+ col: number,
623
+ ): number | null {
624
+ if (
625
+ layout === null
626
+ || !panelManager.isVisible()
627
+ || panelManager.getAllOpen().length === 0
628
+ || row !== layout.y // workspace bar is the first panel row
629
+ || col < layout.x
630
+ || col >= layout.x + layout.width
631
+ ) {
632
+ return null;
633
+ }
634
+ let regions: readonly TabHitRegion[] = [];
635
+ renderPanelWorkspaceBar(panelManager.getWorkspaceTabs(), layout.width, true, (r) => { regions = r; });
636
+ const relCol = col - layout.x;
637
+ const hit = regions.find((rg) => relCol >= rg.startCol && relCol < rg.endCol);
638
+ return hit ? hit.index : null;
639
+ }
640
+
592
641
  function scrollPanelUnderMouse(
593
642
  state: MouseRouteState,
594
643
  token: Extract<InputToken, { type: 'mouse' }>,
@@ -634,6 +683,14 @@ export function handleMouseToken(state: MouseRouteState, token: InputToken): {
634
683
  return { handled: true, mouseDownRow, mouseDownCol };
635
684
  }
636
685
  if (token.button === 0 && token.action === 'press') {
686
+ // Click on a workspace tab activates it (checked before starting a text
687
+ // selection so a tab click doesn't begin a drag-select).
688
+ const tabIndex = workspaceTabAtMouse(state.panelManager, state.panelMouseLayout, token.row, token.col);
689
+ if (tabIndex !== null) {
690
+ state.panelManager.activateWorkspaceIndex(tabIndex);
691
+ state.requestRender();
692
+ return { handled: true, mouseDownRow, mouseDownCol };
693
+ }
637
694
  mouseDownRow = token.row;
638
695
  mouseDownCol = token.col;
639
696
  state.selection.startSelection(token.col, viewportRow, state.scrollTop, state.viewportHeight, state.lineCount);
@@ -296,6 +296,19 @@ export function feedInputTokens(context: InputFeedContext, tokens: readonly Inpu
296
296
  context.cursorPos = shortcutState.cursorPos;
297
297
  context.commandMode = shortcutState.commandMode;
298
298
  context.panelFocused = shortcutState.panelFocused;
299
+ if (context.commandMode) {
300
+ if (!context.prompt.startsWith('/')) {
301
+ context.commandMode = false;
302
+ context.autocomplete?.reset();
303
+ } else {
304
+ const q = context.prompt.slice(1);
305
+ if (q.indexOf(' ') === -1) {
306
+ context.autocomplete?.update(q);
307
+ } else {
308
+ context.autocomplete?.reset();
309
+ }
310
+ }
311
+ }
299
312
  continue;
300
313
  }
301
314
  }
@@ -98,13 +98,22 @@ export function handleModelPickerToken(state: ModelPickerRouteState, token: Inpu
98
98
  if (capModel) {
99
99
  const rawInput = state.modelPicker.contextCapQuery.trim();
100
100
  const parsedCap = rawInput.length > 0 ? parseInt(rawInput, 10) : null;
101
- const validCap = parsedCap !== null && parsedCap > 0 && parsedCap <= 10_000_000 ? parsedCap : null;
102
- const effort = state.commandContext?.session.runtime.reasoningEffort ?? 'medium';
103
- const handled = state.onModelPickerCommit?.() ?? false;
104
- if (!handled) state.commandContext?.completeModelSelection?.({ model: capModel, effort, contextCap: validCap, target: state.modelPicker.target });
101
+ if (parsedCap !== null && (parsedCap <= 0 || parsedCap > 10_000_000)) {
102
+ // Non-empty but out of valid range — surface notice; keep picker open for correction.
103
+ state.modelPicker.contextCapError = 'Context cap must be 1–10,000,000';
104
+ } else {
105
+ state.modelPicker.contextCapError = null;
106
+ const validCap = parsedCap !== null && parsedCap > 0 && parsedCap <= 10_000_000 ? parsedCap : null;
107
+ const effort = state.commandContext?.session.runtime.reasoningEffort ?? 'medium';
108
+ const handled = state.onModelPickerCommit?.() ?? false;
109
+ if (!handled) state.commandContext?.completeModelSelection?.({ model: capModel, effort, contextCap: validCap, target: state.modelPicker.target });
110
+ state.modelPicker.close();
111
+ if (state.modalStack[state.modalStack.length - 1] === 'modelPicker') state.modalStack.pop();
112
+ }
113
+ } else {
114
+ state.modelPicker.close();
115
+ if (state.modalStack[state.modalStack.length - 1] === 'modelPicker') state.modalStack.pop();
105
116
  }
106
- state.modelPicker.close();
107
- if (state.modalStack[state.modalStack.length - 1] === 'modelPicker') state.modalStack.pop();
108
117
  }
109
118
  } else if (token.logicalName === 'up') {
110
119
  if (state.modelPicker.focusPane === 'targets') {
@@ -158,14 +167,10 @@ export function handleModelPickerToken(state: ModelPickerRouteState, token: Inpu
158
167
  if (state.modelPicker.mode === 'contextCap') {
159
168
  if (token.value.length === 1) state.modelPicker.appendContextCapChar(token.value);
160
169
  } else if ((state.modelPicker.mode === 'model' || state.modelPicker.mode === 'provider') && state.modelPicker.searchFocused) {
170
+ // When search is focused every printable char — including space — goes to the query.
171
+ // The space=context-cap shortcut remains active only in the non-search branch below.
161
172
  const ch = token.value;
162
- if (ch === ' ' && state.modelPicker.mode === 'model') {
163
- const selected = state.modelPicker.getSelected();
164
- if (selected && state.modelPicker.isLocalModel(selected)) state.modelPicker.enterContextCapMode(selected);
165
- else if (ch.length === 1 && ch >= ' ') state.modelPicker.appendChar(ch);
166
- } else if (ch.length === 1 && ch >= ' ') {
167
- state.modelPicker.appendChar(ch);
168
- }
173
+ if (ch.length === 1 && ch >= ' ') state.modelPicker.appendChar(ch);
169
174
  } else if (token.value === ' ' && state.modelPicker.mode === 'model') {
170
175
  const selected = state.modelPicker.getSelected();
171
176
  if (selected && state.modelPicker.isLocalModel(selected)) state.modelPicker.enterContextCapMode(selected);
@@ -354,6 +354,10 @@ export function handleHistorySearchToken(state: HistorySearchRouteState, token:
354
354
  state.historySearch.stepOlder();
355
355
  } else if (token.ctrl && token.logicalName === 's') {
356
356
  state.historySearch.stepNewer();
357
+ } else if (token.logicalName === 'up') {
358
+ state.historySearch.stepOlder();
359
+ } else if (token.logicalName === 'down') {
360
+ state.historySearch.stepNewer();
357
361
  }
358
362
  }
359
363
 
@@ -38,6 +38,7 @@ export type KeyAction =
38
38
  | 'panel-close-all'
39
39
  | 'panel-tab-next'
40
40
  | 'panel-tab-prev'
41
+ | 'panel-focus-toggle'
41
42
  | 'history-search'
42
43
  | 'search'
43
44
  | 'block-copy'
@@ -69,6 +70,7 @@ export const ACTION_DESCRIPTIONS: Record<KeyAction, string> = {
69
70
  'panel-close-all': 'Close all open panels',
70
71
  'panel-tab-next': 'Next workspace panel tab',
71
72
  'panel-tab-prev': 'Previous workspace panel tab',
73
+ 'panel-focus-toggle': 'Switch keyboard focus between top and bottom pane',
72
74
  'history-search': 'Reverse input history search',
73
75
  'search': 'Toggle conversation search',
74
76
  'block-copy': 'Copy nearest block to clipboard',
@@ -101,6 +103,9 @@ export const DEFAULT_KEYBINDINGS: Record<KeyAction, KeyCombo[]> = {
101
103
  'panel-close-all': [{ key: 'x', ctrl: true, shift: true }],
102
104
  'panel-tab-next': [{ key: ']', ctrl: true }],
103
105
  'panel-tab-prev': [{ key: '[', ctrl: true }],
106
+ // Ctrl+G: toggle keyboard focus between the top and bottom panes. Ctrl+G is
107
+ // otherwise unbound in the default table.
108
+ 'panel-focus-toggle': [{ key: 'g', ctrl: true }],
104
109
  'history-search': [{ key: 'r', ctrl: true }],
105
110
  'search': [{ key: 'f', ctrl: true }],
106
111
  'block-copy': [{ key: 'y', ctrl: true }],
@@ -92,6 +92,8 @@ export class ModelPickerModal {
92
92
  public contextCapPendingModel: ModelDefinition | null = null;
93
93
  /** Current input string in contextCap mode. */
94
94
  public contextCapQuery = '';
95
+ /** Validation error from the last context cap commit (e.g. out-of-range value); null when no error. */
96
+ public contextCapError: string | null = null;
95
97
 
96
98
  // ── Search / filter ─────────────────────────────────────────────────────────────────────────────
97
99
  /** Current search query string (empty = no filter). */
@@ -215,6 +217,7 @@ export class ModelPickerModal {
215
217
  this.previousMode = 'model';
216
218
  this.contextCapPendingModel = model;
217
219
  this.contextCapQuery = '';
220
+ this.contextCapError = null;
218
221
  this.mode = 'contextCap';
219
222
  }
220
223
 
@@ -245,6 +248,7 @@ export class ModelPickerModal {
245
248
  this.query = '';
246
249
  this.categoryFilter = 'all';
247
250
  this.capabilityFilter = 'none';
251
+ this.availableOnly = true;
248
252
  const filtered = this.getFilteredModels();
249
253
  const idx = filtered.findIndex(m => m.id === currentModelId);
250
254
  this.selectedIndex = idx >= 0 ? idx : 0;
@@ -310,6 +314,7 @@ export class ModelPickerModal {
310
314
  this.pendingModel = null;
311
315
  this.contextCapPendingModel = null;
312
316
  this.contextCapQuery = '';
317
+ this.contextCapError = null;
313
318
  this.searchFocused = false;
314
319
  this.selectedIndex = 0;
315
320
  this.scrollOffset = 0;
@@ -41,6 +41,16 @@ export function handlePanelIntegrationAction(
41
41
  ): boolean {
42
42
  if (!activePanel) return false;
43
43
 
44
+ // Prefer the panel's own integration hook when it provides one. Panels migrated
45
+ // onto the formal hook opt out of the instanceof routing below.
46
+ if (activePanel.handlePanelIntegrationAction) {
47
+ const consumed = activePanel.handlePanelIntegrationAction(key, {
48
+ panelManager,
49
+ executeCommand: commandContext?.executeCommand,
50
+ });
51
+ if (consumed) return true;
52
+ }
53
+
44
54
  if ((key === 'enter' || key === 'return' || key === 'right') && activePanel instanceof FileExplorerPanel) {
45
55
  const filePath = activePanel.getFocusedFilePath();
46
56
  if (!filePath) return false;
package/src/main.ts CHANGED
@@ -29,7 +29,6 @@ import { applyConversationOverlays } from './renderer/conversation-overlays.ts';
29
29
  import { buildPanelCompositeData } from './renderer/panel-composite.ts';
30
30
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
31
31
  import { registerBuiltinPanels } from './panels/builtin-panels.ts';
32
- import { renderPanelTabBar } from './renderer/panel-tab-bar.ts';
33
32
  import { bootstrapRuntime } from './runtime/bootstrap.ts';
34
33
  import type { BootstrapContext } from './runtime/bootstrap.ts';
35
34
  import type { HITLMode } from '@pellux/goodvibes-sdk/platform/state';
@@ -77,6 +77,8 @@ const COLOR = {
77
77
  selected: '#ffee88',
78
78
  dimmed: '#555566',
79
79
  expandHint: '#445566',
80
+ stalled: '#f59e0b', // amber — stalled-agent warning
81
+ cursorBg: '#1a2233', // timeline cursor row background
80
82
  } as const;
81
83
 
82
84
  // ---------------------------------------------------------------------------
@@ -256,9 +258,11 @@ export class AgentInspectorPanel extends BasePanel {
256
258
  width,
257
259
  agents.length === 0 ? ' No agents running' : ' No agent selected',
258
260
  agents.length === 0
259
- ? 'Spawn an agent to inspect its live and historical timeline.'
261
+ ? 'Spawn an agent to inspect its live and historical timeline, tool calls, and output.'
260
262
  : 'Press Tab to cycle through available agents and open one in the inspector.',
261
- [],
263
+ agents.length === 0
264
+ ? [{ command: '/spawn <task>', summary: 'launch an agent, then Tab here to inspect its timeline' }]
265
+ : [{ command: 'Tab', summary: 'select the first available agent' }],
262
266
  DEFAULT_PANEL_PALETTE,
263
267
  ),
264
268
  },
@@ -274,7 +278,7 @@ export class AgentInspectorPanel extends BasePanel {
274
278
  const now = Date.now();
275
279
  const isStalled = this._isAgentStalled(rec, now);
276
280
  if (isStalled) {
277
- summaryLines.push(buildPanelLine(width, [[' STALLED', '#f59e0b'], [' — no activity for 5+ minutes', DEFAULT_PANEL_PALETTE.dim]]));
281
+ summaryLines.push(buildPanelLine(width, [[' STALLED', COLOR.stalled], [' — no activity for 5+ minutes', DEFAULT_PANEL_PALETTE.dim]]));
278
282
  }
279
283
  const allRows = this._getCachedRows();
280
284
  if (allRows.length === 0) {
@@ -288,8 +292,12 @@ export class AgentInspectorPanel extends BasePanel {
288
292
  lines: buildEmptyState(
289
293
  width,
290
294
  ' No messages yet',
291
- 'The selected agent has not emitted any visible timeline entries yet.',
292
- [],
295
+ rec.status === 'running'
296
+ ? 'The selected agent is running but has not emitted visible timeline entries yet — live output streams in as it works.'
297
+ : 'The selected agent has not emitted any visible timeline entries.',
298
+ rec.status === 'running'
299
+ ? [{ command: 'c', summary: 'cancel this running agent if it appears stalled' }]
300
+ : [{ command: 'Tab', summary: 'cycle to another agent with timeline activity' }],
293
301
  DEFAULT_PANEL_PALETTE,
294
302
  ),
295
303
  },
@@ -397,9 +405,7 @@ export class AgentInspectorPanel extends BasePanel {
397
405
  const elapsed = (rec.completedAt ?? now) - rec.startedAt;
398
406
  const taskPreview = rec.task.split('\n')[0] ?? '';
399
407
  const maxTask = Math.max(0, width - 40);
400
- const taskDisplay = taskPreview.length > maxTask
401
- ? taskPreview.slice(0, maxTask - 1) + '\u2026'
402
- : taskPreview;
408
+ const taskDisplay = truncateDisplay(taskPreview, maxTask);
403
409
  return buildPanelLine(width, [
404
410
  [' Status ', DEFAULT_PANEL_PALETTE.label],
405
411
  [rec.status.toUpperCase(), rec.status === 'running' ? DEFAULT_PANEL_PALETTE.good : rec.status === 'failed' ? DEFAULT_PANEL_PALETTE.bad : DEFAULT_PANEL_PALETTE.dim],
@@ -425,7 +431,7 @@ export class AgentInspectorPanel extends BasePanel {
425
431
  row: DisplayRow,
426
432
  isCursor: boolean,
427
433
  ): Line {
428
- const bg = isCursor ? '#1a2233' : '';
434
+ const bg = isCursor ? COLOR.cursorBg : '';
429
435
  const ts = shortTime(row.timestamp);
430
436
  const { fg, prefix } = agentKindStyle(row.kind, COLOR);
431
437
  const hint = row.hasDetail ? (row.expanded ? ' ▾' : ' ▸') : '';
@@ -527,9 +533,7 @@ export class AgentInspectorPanel extends BasePanel {
527
533
  kind,
528
534
  timestamp: msg.timestamp,
529
535
  label: msg.from,
530
- content: msg.content.length > 200
531
- ? msg.content.slice(0, 197) + '\u2026'
532
- : msg.content,
536
+ content: truncateDisplay(msg.content, 200),
533
537
  expanded: false,
534
538
  } satisfies TimelineEntry);
535
539
  }
@@ -669,7 +673,8 @@ export class AgentInspectorPanel extends BasePanel {
669
673
  if (!this.selectedAgentId) return;
670
674
  const rec = this.deps.agentManager.getStatus(this.selectedAgentId);
671
675
  if (!rec || AGENT_TERMINAL_STATUSES.has(rec.status)) return;
672
- const label = rec.task.split('\n')[0]?.slice(0, 40) ?? rec.id.slice(-8);
676
+ const firstLine = rec.task.split('\n')[0];
677
+ const label = firstLine ? truncateDisplay(firstLine, 40) : rec.id.slice(-8);
673
678
  this.confirmCancel = { subject: rec.id, label };
674
679
  this.markDirty();
675
680
  }
@@ -1,3 +1,5 @@
1
+ import { formatDuration } from '../utils/format-duration.ts';
2
+
1
3
  export type AgentInspectorEntryKind = 'user' | 'assistant' | 'tool_call' | 'tool_result' | 'session' | 'error';
2
4
 
3
5
  // ---------------------------------------------------------------------------
@@ -61,9 +63,7 @@ export function agentStatusColor(status: string, colors: Record<string, string>)
61
63
  }
62
64
 
63
65
  export function formatAgentDuration(ms: number): string {
64
- if (ms < 1000) return `${ms}ms`;
65
- if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
66
- return `${Math.floor(ms / 60000)}m${Math.floor((ms % 60000) / 1000)}s`;
66
+ return formatDuration(ms);
67
67
  }
68
68
 
69
69
  export function formatAgentTime(ts: number): string {