@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
@@ -10,6 +10,7 @@
10
10
  * that passes configManager has a coherent call site.
11
11
  */
12
12
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
13
+ import { computeContextUsage } from '../core/context-usage.ts';
13
14
 
14
15
  export type PanelGuidanceMode = 'off' | 'minimal' | 'guided';
15
16
  export type PanelSessionMaintenanceLevel = 'stable' | 'watch' | 'suggest-compact' | 'compacting' | 'needs-repair' | 'unknown';
@@ -76,8 +77,7 @@ export function evaluateSessionMaintenance(input: PanelSessionMaintenanceInput):
76
77
  const thresholdPct = Math.max(0, Number.isFinite(rawThreshold) ? rawThreshold : 0);
77
78
  const autoCompactEnabled = thresholdPct > 0;
78
79
 
79
- const usagePct = input.contextWindow > 0 ? Math.min(100, Math.round((Math.max(0, input.currentTokens) / input.contextWindow) * 100)) : 0;
80
- const remainingTokens = Math.max(0, input.contextWindow - input.currentTokens);
80
+ const { pct: usagePct, remaining: remainingTokens } = computeContextUsage(input.currentTokens, input.contextWindow);
81
81
  const sessionMemoryCount = Math.max(0, input.sessionMemoryCount ?? 0);
82
82
  const compactionCount = Math.max(0, input.session?.lineage?.filter((entry) => entry.branchReason === 'compaction').length ?? 0);
83
83
  const lastCompactedAt = input.session?.lastCompactedAt;
@@ -1,8 +1,10 @@
1
1
  import type { Line } from '../types/grid.ts';
2
+ import { fitDisplay, truncateDisplay } from '../utils/terminal-width.ts';
2
3
  import { ScrollableListPanel } from './scrollable-list-panel.ts';
3
4
  import {
4
5
  buildDetailBlock,
5
6
  buildGuidanceLine,
7
+ buildKeyboardHints,
6
8
  buildPanelListRow,
7
9
  buildPanelLine,
8
10
  buildStatusPill,
@@ -28,6 +30,14 @@ export class SettingsSyncPanel extends ScrollableListPanel<ResolvedEntry> {
28
30
  public constructor(private readonly configManager: ConfigManager) {
29
31
  super('settings-sync', 'Settings Sync', 'S', 'monitoring');
30
32
  this.showSelectionGutter = true; // I5: non-color selection affordance
33
+ this.filterEnabled = true;
34
+ this.filterLabel = 'Filter settings';
35
+ }
36
+
37
+ protected override filterMatches(entry: ResolvedEntry, q: string): boolean {
38
+ return entry.key.toLowerCase().includes(q)
39
+ || String(entry.effectiveSource).toLowerCase().includes(q)
40
+ || String(entry.effectiveValue).toLowerCase().includes(q);
31
41
  }
32
42
 
33
43
  protected override getPalette(): PanelPalette {
@@ -40,9 +50,9 @@ export class SettingsSyncPanel extends ScrollableListPanel<ResolvedEntry> {
40
50
 
41
51
  protected renderItem(entry: ResolvedEntry, _index: number, selected: boolean, width: number): Line {
42
52
  return buildPanelListRow(width, [
43
- { text: entry.key.padEnd(32), fg: C.value },
53
+ { text: fitDisplay(entry.key, 32).padEnd(32), fg: C.value },
44
54
  { text: ` ${entry.effectiveSource}`.padEnd(11), fg: entry.effectiveSource === 'managed' ? C.warn : entry.effectiveSource === 'synced' ? C.ok : entry.effectiveSource === 'local' ? C.info : C.dim },
45
- { text: `${String(entry.effectiveValue)}`.slice(0, Math.max(0, width - 47)), fg: entry.locked ? C.warn : C.dim },
55
+ { text: truncateDisplay(`${String(entry.effectiveValue)}`, Math.max(0, width - 47)), fg: entry.locked ? C.warn : C.dim },
46
56
  ], C, { selected });
47
57
  }
48
58
 
@@ -69,28 +79,28 @@ export class SettingsSyncPanel extends ScrollableListPanel<ResolvedEntry> {
69
79
  ...(snapshot.stagedManagedBundle
70
80
  ? [
71
81
  buildPanelLine(width, [[' profile ', C.label], [snapshot.stagedManagedBundle.profileName, C.value], [' risk ', C.label], [snapshot.stagedManagedBundle.risk, snapshot.stagedManagedBundle.risk === 'high' ? C.error : snapshot.stagedManagedBundle.risk === 'medium' ? C.warn : C.ok], [' changes ', C.label], [String(snapshot.stagedManagedBundle.changeCount), C.info]]),
72
- buildPanelLine(width, [[' path ', C.label], [snapshot.stagedManagedBundle.path.slice(0, Math.max(0, width - 9)), C.dim]]),
82
+ buildPanelLine(width, [[' path ', C.label], [truncateDisplay(snapshot.stagedManagedBundle.path, Math.max(0, width - 9)), C.dim]]),
73
83
  ]
74
84
  : [buildPanelLine(width, [[' No staged managed settings bundle.', C.dim]])]),
75
85
  // Recent Events
76
86
  ...(snapshot.recentEvents.length > 0
77
- ? snapshot.recentEvents.map((event) => buildPanelLine(width, [[` ${event.surface}/${event.direction}`.padEnd(18), C.info], [` ${event.detail}`.slice(0, Math.max(0, width - 20)), C.dim]]))
87
+ ? snapshot.recentEvents.map((event) => buildPanelLine(width, [[fitDisplay(` ${event.surface}/${event.direction}`, 18).padEnd(18), C.info], [truncateDisplay(` ${event.detail}`, Math.max(0, width - 20)), C.dim]]))
78
88
  : [buildPanelLine(width, [[' No sync or managed-setting events recorded yet.', C.dim]])]),
79
89
  // Managed Locks
80
90
  ...(snapshot.managedLocks.length > 0
81
- ? snapshot.managedLocks.slice(0, 10).map((lock) => buildPanelLine(width, [[` ${lock.key}`.padEnd(30), C.value], [` source=${lock.source}`.padEnd(24), C.info], [` ${lock.reason}`.slice(0, Math.max(0, width - 56)), C.dim]]))
91
+ ? snapshot.managedLocks.slice(0, 10).map((lock) => buildPanelLine(width, [[fitDisplay(` ${lock.key}`, 30).padEnd(30), C.value], [fitDisplay(` source=${lock.source}`, 24).padEnd(24), C.info], [truncateDisplay(` ${lock.reason}`, Math.max(0, width - 56)), C.dim]]))
82
92
  : [buildPanelLine(width, [[' No managed locks are currently active.', C.dim]])]),
83
93
  // Failures
84
94
  ...(snapshot.recentFailures.length > 0
85
- ? snapshot.recentFailures.map((failure) => buildPanelLine(width, [[` ${failure.surface}`.padEnd(10), C.error], [` ${failure.message}`.slice(0, Math.max(0, width - 12)), C.dim]]))
95
+ ? snapshot.recentFailures.map((failure) => buildPanelLine(width, [[` ${failure.surface}`.padEnd(10), C.error], [truncateDisplay(` ${failure.message}`, Math.max(0, width - 12)), C.dim]]))
86
96
  : [buildPanelLine(width, [[' No recent sync or managed-setting failures.', C.dim]])]),
87
97
  // Conflicts
88
98
  ...(snapshot.conflicts.length > 0
89
- ? snapshot.conflicts.map((conflict) => buildPanelLine(width, [[` ${conflict.key}`.padEnd(30), C.value], [` ${conflict.source}`.padEnd(10), C.warn], [` resolve: /settings-sync resolve ${conflict.key} local|synced`.slice(0, Math.max(0, width - 42)), C.dim]]))
99
+ ? snapshot.conflicts.map((conflict) => buildPanelLine(width, [[fitDisplay(` ${conflict.key}`, 30).padEnd(30), C.value], [fitDisplay(` ${conflict.source}`, 10).padEnd(10), C.warn], [truncateDisplay(` resolve: /settings-sync resolve ${conflict.key} local|synced`, Math.max(0, width - 42)), C.dim]]))
90
100
  : [buildPanelLine(width, [[' No settings conflicts detected.', C.dim]])]),
91
101
  // Rollback History
92
102
  ...(snapshot.rollbackHistory.length > 0
93
- ? snapshot.rollbackHistory.map((entry) => buildPanelLine(width, [[` ${entry.token}`.padEnd(18), C.info], [` ${entry.profileName}`.padEnd(18), C.value], [` restored=${String(entry.restoredKeys.length).padEnd(4)}`, C.warn], [` ${new Date(entry.appliedAt).toLocaleString()}`.slice(0, Math.max(0, width - 46)), C.dim]]))
103
+ ? snapshot.rollbackHistory.map((entry) => buildPanelLine(width, [[fitDisplay(` ${entry.token}`, 18).padEnd(18), C.info], [fitDisplay(` ${entry.profileName}`, 18).padEnd(18), C.value], [` restored=${String(entry.restoredKeys.length).padEnd(4)}`, C.warn], [truncateDisplay(` ${new Date(entry.appliedAt).toLocaleString()}`, Math.max(0, width - 46)), C.dim]]))
94
104
  : [buildPanelLine(width, [[' No managed rollback records yet.', C.dim]])]),
95
105
  ];
96
106
 
@@ -101,14 +111,19 @@ export class SettingsSyncPanel extends ScrollableListPanel<ResolvedEntry> {
101
111
  ? buildDetailBlock(width, 'Selected setting', [
102
112
  buildPanelLine(width, [[' key ', C.label], [selectedEntry.key, C.value], [' category ', C.label], [selectedEntry.category, C.info]]),
103
113
  buildPanelLine(width, [[' effective ', C.label], [selectedEntry.effectiveSource, selectedEntry.effectiveSource === 'managed' ? C.warn : selectedEntry.effectiveSource === 'synced' ? C.ok : selectedEntry.effectiveSource === 'local' ? C.info : C.dim], [' locked ', C.label], [selectedEntry.locked ? 'yes' : 'no', selectedEntry.locked ? C.warn : C.dim], [' conflict ', C.label], [selectedEntry.conflict ? 'yes' : 'no', selectedEntry.conflict ? C.error : C.good]]),
104
- buildPanelLine(width, [[' source ', C.label], [(selectedEntry.sourceLabel ?? 'local/default').slice(0, Math.max(0, width - 10)), C.dim]]),
105
- buildPanelLine(width, [[' overrides ', C.label], [(selectedEntry.overriddenSources.length > 0 ? selectedEntry.overriddenSources.join(', ') : 'none').slice(0, Math.max(0, width - 13)), C.dim]]),
106
- buildPanelLine(width, [[' local ', C.label], [String(selectedEntry.localValue).slice(0, Math.max(0, width - 9)), C.dim]]),
107
- buildPanelLine(width, [[' synced ', C.label], [String(selectedEntry.syncedValue ?? '(unset)').slice(0, Math.max(0, width - 10)), C.ok]]),
108
- buildPanelLine(width, [[' managed ', C.label], [String(selectedEntry.managedValue ?? '(unset)').slice(0, Math.max(0, width - 11)), C.warn]]),
114
+ buildPanelLine(width, [[' source ', C.label], [truncateDisplay(selectedEntry.sourceLabel ?? 'local/default', Math.max(0, width - 10)), C.dim]]),
115
+ buildPanelLine(width, [[' overrides ', C.label], [truncateDisplay(selectedEntry.overriddenSources.length > 0 ? selectedEntry.overriddenSources.join(', ') : 'none', Math.max(0, width - 13)), C.dim]]),
116
+ buildPanelLine(width, [[' local ', C.label], [truncateDisplay(String(selectedEntry.localValue), Math.max(0, width - 9)), C.dim]]),
117
+ buildPanelLine(width, [[' synced ', C.label], [truncateDisplay(String(selectedEntry.syncedValue ?? '(unset)'), Math.max(0, width - 10)), C.ok]]),
118
+ buildPanelLine(width, [[' managed ', C.label], [truncateDisplay(String(selectedEntry.managedValue ?? '(unset)'), Math.max(0, width - 11)), C.warn]]),
109
119
  ], C)
110
- : []),
111
- buildPanelLine(width, [[' ↑/↓ browse /settings-sync show <key> /settings-sync resolve <key> <local|synced> /managed apply-staged [key...] ', C.dim]]),
120
+ : [buildPanelLine(width, [[' Select a setting above to inspect its effective value, source, and overrides.', C.dim]])]),
121
+ buildKeyboardHints(width, [
122
+ { keys: '↑/↓', label: 'browse' },
123
+ { keys: '/', label: 'filter' },
124
+ { keys: '/settings-sync show <key>', label: 'inspect' },
125
+ { keys: '/settings-sync resolve <key> <local|synced>', label: 'resolve conflict' },
126
+ ], C),
112
127
  ];
113
128
 
114
129
  return this.renderList(width, height, {
@@ -1,5 +1,6 @@
1
1
  import type { Line } from '../types/grid.ts';
2
2
  import { createEmptyLine } from '../types/grid.ts';
3
+ import { fitDisplay } from '../utils/terminal-width.ts';
3
4
  import { ScrollableListPanel } from './scrollable-list-panel.ts';
4
5
  import type { KeyName } from './types.ts';
5
6
  import type { ProviderSubscription, PendingSubscriptionLogin } from '@pellux/goodvibes-sdk/platform/config';
@@ -9,6 +10,7 @@ import type { ServiceInspectionQuery, SubscriptionAccessQuery } from '../runtime
9
10
  import {
10
11
  buildEmptyState,
11
12
  buildGuidanceLine,
13
+ buildKeyboardHints,
12
14
  buildKeyValueLine,
13
15
  buildPanelListRow,
14
16
  buildPanelLine,
@@ -94,7 +96,7 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
94
96
  protected renderItem(row: SubscriptionRow, index: number, selected: boolean, width: number): Line {
95
97
  const status = statusOf(row);
96
98
  return buildPanelListRow(width, [
97
- { text: row.provider.padEnd(16).slice(0, 16), fg: C.value },
99
+ { text: fitDisplay(row.provider, 16), fg: C.value },
98
100
  { text: ` ${status.toUpperCase().padEnd(12)}`, fg: statusColor(status) },
99
101
  { text: ` oauth=${row.hasOAuthConfig ? 'yes' : 'no'} `, fg: row.hasOAuthConfig ? C.info : C.dim },
100
102
  { text: ` override=${row.subscription ? 'active' : 'off'}`, fg: row.subscription ? C.good : C.dim },
@@ -177,6 +179,22 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
177
179
  this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.rows.length - 1));
178
180
  }
179
181
 
182
+ /** Footer hints that reflect the current view: confirm-pending vs browsing. */
183
+ private buildFooterHint(width: number): Line {
184
+ if (this.confirm) {
185
+ return buildKeyboardHints(width, [
186
+ { keys: 'y/Enter', label: 'confirm sign out' },
187
+ { keys: 'n/Esc', label: 'cancel' },
188
+ ], C);
189
+ }
190
+ const selected = this.rows[this.selectedIndex];
191
+ const hints: Array<{ keys: string; label: string }> = [{ keys: 'Up/Down', label: 'select' }];
192
+ if (selected?.subscription) hints.push({ keys: 'Enter', label: 'sign out' });
193
+ else if (selected?.hasOAuthConfig) hints.push({ keys: '/subscription login <p> start', label: 'sign in' });
194
+ hints.push({ keys: 'r', label: 'refresh' });
195
+ return buildKeyboardHints(width, hints, C);
196
+ }
197
+
180
198
  public render(width: number, height: number): Line[] {
181
199
  this.refresh();
182
200
  this.clampSelection();
@@ -214,7 +232,7 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
214
232
  sections: [{ lines: [...summaryLines, ...emptyLines] }],
215
233
  footerLines: [
216
234
  buildGuidanceLine(width, '/subscription login <provider> start', 'start browser-based provider login from the packaged subscription surface', C),
217
- buildPanelLine(width, [[' Up/Down move Enter sign out selected provider y/Esc confirm/cancel r refresh', C.dim]]),
235
+ this.buildFooterHint(width),
218
236
  ],
219
237
  palette: C,
220
238
  });
@@ -264,7 +282,7 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
264
282
  footer: [
265
283
  ...detailRows,
266
284
  buildGuidanceLine(width, '/subscription login <provider> start', 'start browser-based provider login from the packaged subscription surface', C),
267
- buildPanelLine(width, [[' Up/Down move Enter sign out selected provider y/Esc confirm/cancel r refresh', C.dim]]),
285
+ this.buildFooterHint(width),
268
286
  ],
269
287
  });
270
288
  }
@@ -3,13 +3,14 @@ import { createEmptyLine } from '../types/grid.ts';
3
3
  import type { Line } from '../types/grid.ts';
4
4
  import {
5
5
  buildEmptyState,
6
+ buildKeyboardHints,
6
7
  buildPanelLine,
7
- buildSelectablePanelLine,
8
+ buildTreeRow,
8
9
  buildPanelWorkspace,
9
10
  resolveScrollablePanelSection,
10
11
  DEFAULT_PANEL_PALETTE,
12
+ extendPalette,
11
13
  } from './polish.ts';
12
- import { getDisplayWidth } from '../utils/terminal-width.ts';
13
14
 
14
15
  // ── Symbol types ────────────────────────────────────────────────────────────
15
16
 
@@ -25,6 +26,10 @@ export interface SymbolEntry {
25
26
 
26
27
  // ── Rendering constants ──────────────────────────────────────────────────────
27
28
 
29
+ const C = extendPalette(DEFAULT_PANEL_PALETTE, {
30
+ selectedBg: '236',
31
+ });
32
+
28
33
  /** ANSI 256-color fg codes per symbol kind. */
29
34
  const KIND_COLORS: Record<SymbolKind, string> = {
30
35
  function: '87', // cyan
@@ -36,15 +41,15 @@ const KIND_COLORS: Record<SymbolKind, string> = {
36
41
  const: '245', // grey
37
42
  };
38
43
 
39
- /** Short type indicator labels. */
40
- const KIND_LABELS: Record<SymbolKind, string> = {
41
- function: 'fn ',
42
- method: 'fn ',
43
- class: 'cls',
44
- namespace: 'ns ',
45
- interface: 'int',
46
- type: 'typ',
47
- const: 'cst',
44
+ /** Single-char type icon for the tree row (kept ASCII for column safety). */
45
+ const KIND_ICONS: Record<SymbolKind, string> = {
46
+ function: 'ƒ',
47
+ method: 'ƒ',
48
+ class: 'C',
49
+ namespace: 'N',
50
+ interface: 'I',
51
+ type: 'T',
52
+ const: 'k',
48
53
  };
49
54
 
50
55
  /** Regex patterns to extract symbols. Each produces named groups: kind, name, line. */
@@ -188,8 +193,10 @@ export class SymbolOutlinePanel extends BasePanel {
188
193
  this.currentPath
189
194
  ? 'The current file did not produce outline entries with the lightweight parser heuristics.'
190
195
  : 'Load a file in the preview panel to populate its outline here.',
191
- [],
192
- DEFAULT_PANEL_PALETTE,
196
+ this.currentPath
197
+ ? []
198
+ : [{ command: '/explorer', summary: 'pick a file in the explorer, then Enter to preview and outline it' }],
199
+ C,
193
200
  ),
194
201
  },
195
202
  ],
@@ -201,7 +208,7 @@ export class SymbolOutlinePanel extends BasePanel {
201
208
  const outlineSection = resolveScrollablePanelSection(width, height, {
202
209
  intro: this.currentPath ? this.currentPath : 'Outline the current file into navigable symbols and lightweight parent/child structure.',
203
210
  footerLines: [
204
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Space', DEFAULT_PANEL_PALETTE.info], [' collapse', DEFAULT_PANEL_PALETTE.dim], [' Enter', DEFAULT_PANEL_PALETTE.info], [' jump target', DEFAULT_PANEL_PALETTE.dim]]),
211
+ buildKeyboardHints(width, [{ keys: '↑/↓', label: 'navigate' }, { keys: 'Space/←/→', label: 'collapse' }, { keys: 'Enter', label: 'jump to source →' }], DEFAULT_PANEL_PALETTE),
205
212
  ],
206
213
  palette: DEFAULT_PANEL_PALETTE,
207
214
  beforeSections: [
@@ -291,7 +298,7 @@ export class SymbolOutlinePanel extends BasePanel {
291
298
  },
292
299
  ],
293
300
  footerLines: [
294
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Space', DEFAULT_PANEL_PALETTE.info], [' collapse', DEFAULT_PANEL_PALETTE.dim], [' Enter', DEFAULT_PANEL_PALETTE.info], [' jump target', DEFAULT_PANEL_PALETTE.dim]]),
301
+ buildKeyboardHints(width, [{ keys: '↑/↓', label: 'navigate' }, { keys: 'Space/←/→', label: 'collapse' }, { keys: 'Enter', label: 'jump to source →' }], DEFAULT_PANEL_PALETTE),
295
302
  ],
296
303
  palette: DEFAULT_PANEL_PALETTE,
297
304
  });
@@ -434,10 +441,7 @@ function buildVisibleRows(symbols: SymbolEntry[], collapsed: Set<string>): Visib
434
441
  return rows;
435
442
  }
436
443
 
437
- /**
438
- * Write a string into a Line starting at column x, applying fg/bg/style.
439
- */
440
- /** Render a container header row (class / namespace). */
444
+ /** Render a container header row (class / namespace) via the shared tree row. */
441
445
  function _renderHeader(
442
446
  width: number,
443
447
  row: Extract<VisibleRow, { kind: 'header' }>,
@@ -445,24 +449,19 @@ function _renderHeader(
445
449
  bgColor: string,
446
450
  collapsed: Set<string>,
447
451
  ): Line {
448
- // Collapse indicator
449
452
  const isCollapsed = collapsed.has(row.name);
450
- const chevron = row.hasChildren ? (isCollapsed ? '▸ ' : '▾ ') : ' ';
451
- const lineNumStr = `:${row.line}`;
452
- const kindLabel = KIND_LABELS[row.symbolKind];
453
- const leadingWidth = 1 + getDisplayWidth(chevron) + getDisplayWidth(kindLabel) + 1 + getDisplayWidth(row.name);
454
- const gap = Math.max(1, width - leadingWidth - getDisplayWidth(lineNumStr) - 1);
455
- return buildSelectablePanelLine(width, [
456
- { text: ` ${chevron}`, fg: '245' },
457
- { text: kindLabel, fg: KIND_COLORS[row.symbolKind], bold: true },
458
- { text: ' ', fg: isSelected ? '255' : '252' },
459
- { text: row.name, fg: isSelected ? '255' : '252', bold: isSelected },
460
- { text: ' '.repeat(gap), fg: DEFAULT_PANEL_PALETTE.dim },
461
- { text: lineNumStr, fg: DEFAULT_PANEL_PALETTE.dim },
462
- ], { selected: isSelected, selectedBg: bgColor, fillFg: isSelected ? '255' : '' });
453
+ return buildTreeRow(width, {
454
+ depth: 0,
455
+ label: row.name,
456
+ icon: KIND_ICONS[row.symbolKind],
457
+ expandable: row.hasChildren,
458
+ expanded: !isCollapsed,
459
+ labelColor: KIND_COLORS[row.symbolKind],
460
+ metadata: [{ text: `:${row.line}`, fg: DEFAULT_PANEL_PALETTE.dim }],
461
+ }, C, { selected: isSelected, selectedBg: bgColor || C.selectedBg });
463
462
  }
464
463
 
465
- /** Render a regular symbol row. */
464
+ /** Render a regular symbol row via the shared tree row. */
466
465
  function _renderSymbol(
467
466
  width: number,
468
467
  row: Extract<VisibleRow, { kind: 'symbol' }>,
@@ -470,17 +469,11 @@ function _renderSymbol(
470
469
  bgColor: string,
471
470
  ): Line {
472
471
  const { symbol, depth } = row;
473
- const indent = depth === 0 ? 1 : 3; // children indented by 3 (chevron + space)
474
- const kindLabel = KIND_LABELS[symbol.kind];
475
- const lineNumStr = `:${symbol.line}`;
476
- const leadingWidth = indent + getDisplayWidth(kindLabel) + 1 + getDisplayWidth(symbol.name);
477
- const gap = Math.max(1, width - leadingWidth - getDisplayWidth(lineNumStr) - 1);
478
- return buildSelectablePanelLine(width, [
479
- { text: ' '.repeat(indent), fg: DEFAULT_PANEL_PALETTE.dim },
480
- { text: kindLabel, fg: KIND_COLORS[symbol.kind] },
481
- { text: ' ', fg: isSelected ? '255' : '251' },
482
- { text: symbol.name, fg: isSelected ? '255' : '251', bold: isSelected },
483
- { text: ' '.repeat(gap), fg: DEFAULT_PANEL_PALETTE.dim },
484
- { text: lineNumStr, fg: DEFAULT_PANEL_PALETTE.dim },
485
- ], { selected: isSelected, selectedBg: bgColor, fillFg: isSelected ? '255' : '' });
472
+ return buildTreeRow(width, {
473
+ depth,
474
+ label: symbol.name,
475
+ icon: KIND_ICONS[symbol.kind],
476
+ labelColor: KIND_COLORS[symbol.kind],
477
+ metadata: [{ text: `:${symbol.line}`, fg: DEFAULT_PANEL_PALETTE.dim }],
478
+ }, C, { selected: isSelected, selectedBg: bgColor || C.selectedBg });
486
479
  }
@@ -14,8 +14,10 @@ import {
14
14
  buildEmptyState,
15
15
  buildGuidanceLine,
16
16
  buildKeyValueLine,
17
+ buildKeyboardHints,
17
18
  buildPanelListRow,
18
19
  buildPanelLine,
20
+ buildStatusBadge,
19
21
  buildSummaryBlock,
20
22
  buildPanelWorkspace,
21
23
  resolvePrimaryScrollableSection,
@@ -59,6 +61,12 @@ export class SystemMessagesPanel extends ScrollableListPanel<SystemMessageEntry>
59
61
  constructor(configManager: ConfigManager, componentHealthMonitor?: ComponentHealthMonitor) {
60
62
  super('system-messages', 'System Messages', 'J', 'monitoring', componentHealthMonitor);
61
63
  this.configManager = configManager;
64
+ this.filterEnabled = true;
65
+ this.filterLabel = 'Filter messages';
66
+ }
67
+
68
+ protected override filterMatches(entry: SystemMessageEntry, q: string): boolean {
69
+ return entry.text.toLowerCase().includes(q) || entry.priority.toLowerCase().includes(q);
62
70
  }
63
71
 
64
72
  // ---------------------------------------------------------------------------
@@ -79,7 +87,7 @@ export class SystemMessagesPanel extends ScrollableListPanel<SystemMessageEntry>
79
87
  return buildPanelListRow(width, [
80
88
  { text: `${fmtTime(entry.ts)} `, fg: C.ts },
81
89
  {
82
- text: `${entry.priority === 'high' ? 'HIGH' : 'LOW '.padEnd(4)} `,
90
+ text: `${(entry.priority === 'high' ? 'HIGH' : 'LOW').padEnd(4)} `,
83
91
  fg: entry.priority === 'high' ? C.high : C.low,
84
92
  bold: entry.priority === 'high',
85
93
  },
@@ -129,6 +137,23 @@ export class SystemMessagesPanel extends ScrollableListPanel<SystemMessageEntry>
129
137
  // Render — multi-section layout (posture + list + detail)
130
138
  // ---------------------------------------------------------------------------
131
139
 
140
+ // Context-aware footer: navigation keys plus filter keys that reflect the
141
+ // current filter state (active typing / applied query / inactive).
142
+ private footerHints(): Array<{ keys: string; label: string }> {
143
+ const hints: Array<{ keys: string; label: string }> = [
144
+ { keys: 'j/k', label: 'scroll' },
145
+ { keys: 'g/G', label: 'jump' },
146
+ ];
147
+ if (this.filterActive) {
148
+ hints.push({ keys: 'Esc', label: 'clear filter' });
149
+ } else if (this.filterQuery) {
150
+ hints.push({ keys: '/', label: 'edit filter' }, { keys: 'Esc', label: 'clear filter' });
151
+ } else {
152
+ hints.push({ keys: '/', label: 'filter' });
153
+ }
154
+ return hints;
155
+ }
156
+
132
157
  override render(width: number, height: number): Line[] {
133
158
  return this.trackedRender(() => {
134
159
  const intro = 'Operational system traffic routed out of the main conversation to reduce noise and keep runtime status reviewable.';
@@ -151,7 +176,7 @@ export class SystemMessagesPanel extends ScrollableListPanel<SystemMessageEntry>
151
176
  ),
152
177
  }],
153
178
  footerLines: [
154
- buildPanelLine(width, [[' j/k or Up/Down scroll g/G jump low-priority system traffic lands here by default', C.dim]]),
179
+ buildPanelLine(width, [[' Low-priority system traffic lands here by default. Routing is configurable via /settings.', C.dim]]),
155
180
  ],
156
181
  palette: C,
157
182
  });
@@ -162,12 +187,16 @@ export class SystemMessagesPanel extends ScrollableListPanel<SystemMessageEntry>
162
187
  const lowCount = this._messages.length - highCount;
163
188
  this.selectedIndex = Math.min(this.selectedIndex, this._messages.length - 1);
164
189
  const ui = this.configManager.getRaw().ui;
190
+ const latest = this._messages[this._messages.length - 1];
165
191
  const postureLines = [
166
- buildKeyValueLine(width, [
167
- { label: 'messages', value: String(this._messages.length), valueColor: C.value },
168
- { label: 'high', value: String(highCount), valueColor: highCount > 0 ? C.high : C.dim },
169
- { label: 'low', value: String(lowCount), valueColor: lowCount > 0 ? C.low : C.dim },
170
- ], C),
192
+ // Severity + recency first: high-priority count leads, newest message age follows.
193
+ buildPanelLine(width, [
194
+ [' ', C.label],
195
+ ...buildStatusBadge(highCount > 0 ? 'failed' : 'completed', 'high', { count: highCount }),
196
+ [' ', C.dim],
197
+ ...buildStatusBadge('review', 'low', { count: lowCount }),
198
+ ...(latest ? ([[' newest ', C.label], [`${fmtTime(latest.ts)}`, C.value]] as Array<[string, string]>) : []),
199
+ ]),
171
200
  buildKeyValueLine(width, [
172
201
  { label: 'system route', value: ui.systemMessages, valueColor: C.info },
173
202
  { label: 'ops route', value: ui.operationalMessages, valueColor: C.info },
@@ -176,28 +205,33 @@ export class SystemMessagesPanel extends ScrollableListPanel<SystemMessageEntry>
176
205
  buildGuidanceLine(width, '/settings', 'adjust where operational and WRFC messages render across panels and conversation', C),
177
206
  ];
178
207
 
179
- const selected = this._messages[this.selectedIndex]!;
180
- const messageRows: Line[] = this._messages.map((entry, index) =>
181
- this.renderItem(entry, index, index === this.selectedIndex, width),
182
- );
208
+ const visible = this.getVisibleItems();
209
+ this.selectedIndex = Math.max(0, Math.min(this.selectedIndex, visible.length - 1));
210
+ const selected = visible[this.selectedIndex];
211
+ const messageRows: Line[] = visible.length > 0
212
+ ? visible.map((entry, index) => this.renderItem(entry, index, index === this.selectedIndex, width))
213
+ : [buildPanelLine(width, [[` No messages match "${this.filterQuery.trim()}" (Esc to clear)`, C.dim]])];
183
214
 
215
+ const filterSection: PanelWorkspaceSection = { lines: [this.buildFilterLine(width)] };
184
216
  const postureSection: PanelWorkspaceSection = { lines: buildSummaryBlock(width, 'System posture', postureLines, C) };
185
- const detailSection: PanelWorkspaceSection = {
186
- title: 'Selected Message',
187
- lines: [
188
- buildPanelLine(width, [
189
- [' Time ', C.label],
190
- [fmtTime(selected.ts), C.value],
191
- [' Priority ', C.label],
192
- [selected.priority, selected.priority === 'high' ? C.high : C.low],
193
- ]),
194
- ...buildBodyText(width, selected.text, C, C.value),
195
- ],
196
- };
217
+ const detailSection: PanelWorkspaceSection = selected
218
+ ? {
219
+ title: 'Selected Message',
220
+ lines: [
221
+ buildPanelLine(width, [
222
+ [' Time ', C.label],
223
+ [fmtTime(selected.ts), C.value],
224
+ [' Priority ', C.label],
225
+ [selected.priority, selected.priority === 'high' ? C.high : C.low],
226
+ ]),
227
+ ...buildBodyText(width, selected.text, C, C.value),
228
+ ],
229
+ }
230
+ : { title: 'Selected Message', lines: [] };
197
231
  const messagesSection = resolvePrimaryScrollableSection(width, height, {
198
232
  intro,
199
233
  palette: C,
200
- beforeSections: [postureSection],
234
+ beforeSections: [filterSection, postureSection],
201
235
  section: {
202
236
  title: 'Timeline',
203
237
  scrollableLines: messageRows,
@@ -210,6 +244,7 @@ export class SystemMessagesPanel extends ScrollableListPanel<SystemMessageEntry>
210
244
  });
211
245
  this.scrollStart = messagesSection.scrollOffset;
212
246
  const sections: PanelWorkspaceSection[] = [
247
+ filterSection,
213
248
  postureSection,
214
249
  messagesSection.section,
215
250
  detailSection,
@@ -219,9 +254,7 @@ export class SystemMessagesPanel extends ScrollableListPanel<SystemMessageEntry>
219
254
  title: 'System Messages',
220
255
  intro,
221
256
  sections,
222
- footerLines: [
223
- buildPanelLine(width, [[' j/k or Up/Down scroll PgUp/PgDn page g/G jump', C.dim]]),
224
- ],
257
+ footerLines: [buildKeyboardHints(width, this.footerHints(), C)],
225
258
  palette: C,
226
259
  });
227
260
  return lines;
@@ -1,5 +1,6 @@
1
1
  import type { Line } from '../types/grid.ts';
2
2
  import { createEmptyLine } from '../types/grid.ts';
3
+ import { truncateDisplay } from '../utils/terminal-width.ts';
3
4
  import { ScrollableListPanel } from './scrollable-list-panel.ts';
4
5
  import type { RuntimeTask, TaskLifecycleState } from '@/runtime/index.ts';
5
6
  import type { ManagedWorktreeMeta } from '@/runtime/index.ts';
@@ -8,6 +9,7 @@ import {
8
9
  buildDetailBlock,
9
10
  buildEmptyState,
10
11
  buildGuidanceLine,
12
+ buildKeyboardHints,
11
13
  buildPanelListRow,
12
14
  buildPanelLine,
13
15
  buildSummaryBlock,
@@ -152,6 +154,8 @@ export class TasksPanel extends ScrollableListPanel<RuntimeTask> {
152
154
  ) {
153
155
  super('tasks', 'Tasks', 'J', 'monitoring');
154
156
  this.showSelectionGutter = true; // I5: non-color selection affordance
157
+ this.filterEnabled = true;
158
+ this.filterLabel = 'Filter tasks';
155
159
  this.readModel = readModel;
156
160
  this.worktrees = worktrees;
157
161
  this.unsubscribers = [
@@ -178,6 +182,13 @@ export class TasksPanel extends ScrollableListPanel<RuntimeTask> {
178
182
  ];
179
183
  }
180
184
 
185
+ protected override filterMatches(task: RuntimeTask, q: string): boolean {
186
+ return task.title.toLowerCase().includes(q)
187
+ || task.status.toLowerCase().includes(q)
188
+ || task.id.toLowerCase().includes(q)
189
+ || String(task.kind).toLowerCase().includes(q);
190
+ }
191
+
181
192
  protected getItems(): readonly RuntimeTask[] {
182
193
  if (!this.readModel) return [];
183
194
  return sortTasks([...this.readModel.getSnapshot().tasks]);
@@ -188,7 +199,7 @@ export class TasksPanel extends ScrollableListPanel<RuntimeTask> {
188
199
  { text: task.status.padEnd(10), fg: statusColor(task.status) },
189
200
  { text: ` ${kindLabel(task.kind).padEnd(12)}`, fg: C.value },
190
201
  { text: ` ${task.id.slice(0, 8)} `, fg: C.dim },
191
- { text: task.title.slice(0, Math.max(0, width - 37)), fg: C.value },
202
+ { text: truncateDisplay(task.title, Math.max(0, width - 37)), fg: C.value },
192
203
  ], C, { selected });
193
204
  }
194
205
 
@@ -199,7 +210,7 @@ export class TasksPanel extends ScrollableListPanel<RuntimeTask> {
199
210
  return true;
200
211
  }
201
212
  if (key === 'end') {
202
- const tasks = this.getItems();
213
+ const tasks = this.getVisibleItems();
203
214
  this.selectedIndex = Math.max(0, tasks.length - 1);
204
215
  this.markDirty();
205
216
  return true;
@@ -210,7 +221,23 @@ export class TasksPanel extends ScrollableListPanel<RuntimeTask> {
210
221
  public render(width: number, height: number): Line[] {
211
222
  this.clampSelection();
212
223
  const intro = 'Live task lifecycle, ownership, retries, and result/error details across runtime execution domains.';
213
- const footerLines = [buildPanelLine(width, [[' Up/Down move Home/End jump', C.dim]])];
224
+ const visibleCount = this.getVisibleItems().length;
225
+ // Context-aware footer: position + only the keys that apply in the current
226
+ // (filtering vs browsing) state.
227
+ const footerLines = [
228
+ this.filterActive
229
+ ? buildKeyboardHints(width, [
230
+ { keys: 'type', label: 'filter tasks' },
231
+ { keys: 'Enter', label: 'apply' },
232
+ { keys: 'Esc', label: 'clear' },
233
+ ], C)
234
+ : buildKeyboardHints(width, [
235
+ { keys: visibleCount > 0 ? `${this.selectedIndex + 1}/${visibleCount}` : '0/0', label: 'task' },
236
+ { keys: '↑/↓', label: 'move' },
237
+ { keys: 'Home/End', label: 'jump' },
238
+ { keys: '/', label: 'filter' },
239
+ ], C),
240
+ ];
214
241
 
215
242
  if (!this.readModel) {
216
243
  const workspace = buildPanelWorkspace(width, height, {
@@ -267,7 +294,7 @@ export class TasksPanel extends ScrollableListPanel<RuntimeTask> {
267
294
  [' kind ', C.label],
268
295
  [selected.kind, C.value],
269
296
  [' owner ', C.label],
270
- [selected.owner.slice(0, Math.max(0, width - 46)), C.dim],
297
+ [truncateDisplay(selected.owner, Math.max(0, width - 46)), C.dim],
271
298
  ]));
272
299
  }
273
300
  postureLines.push(
@@ -354,7 +381,7 @@ export class TasksPanel extends ScrollableListPanel<RuntimeTask> {
354
381
  ]]));
355
382
  for (const record of attachedWorktrees.records.slice(0, 2)) {
356
383
  detailRows.push(buildPanelLine(width, [[
357
- ` ${record.state.padEnd(15)} ${record.path}`.slice(0, Math.max(0, width - 2)),
384
+ truncateDisplay(` ${record.state.padEnd(15)} ${record.path}`, Math.max(0, width - 2)),
358
385
  record.state === 'active' ? C.running : record.state === 'paused' ? C.blocked : C.dim,
359
386
  ]]));
360
387
  }
@@ -368,14 +395,14 @@ export class TasksPanel extends ScrollableListPanel<RuntimeTask> {
368
395
  if (selected.error) {
369
396
  detailRows.push(buildPanelLine(width, [
370
397
  [' Error: ', C.label],
371
- [selected.error.slice(0, Math.max(0, width - 10)), C.failed],
398
+ [truncateDisplay(selected.error, Math.max(0, width - 10)), C.failed],
372
399
  ]));
373
400
  }
374
401
  if (selected.result !== undefined) {
375
402
  const resultText = safeJson(selected.result);
376
403
  detailRows.push(buildPanelLine(width, [
377
404
  [' Result: ', C.label],
378
- [resultText.slice(0, Math.max(0, width - 11)), C.dim],
405
+ [truncateDisplay(resultText, Math.max(0, width - 11)), C.dim],
379
406
  ]));
380
407
  }
381
408
  }