@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
@@ -9,14 +9,15 @@ import { createEmptyLine } from '../types/grid.ts';
9
9
  import { BasePanel } from './base-panel.ts';
10
10
  import {
11
11
  buildEmptyState,
12
+ buildKeyboardHints,
12
13
  buildPanelLine,
13
14
  buildSearchInputLine,
14
- buildSelectablePanelLine,
15
+ buildTreeRow,
15
16
  buildPanelWorkspace,
16
17
  resolveScrollablePanelSection,
17
18
  DEFAULT_PANEL_PALETTE,
19
+ extendPalette,
18
20
  } from './polish.ts';
19
- import { getDisplayWidth } from '../utils/terminal-width.ts';
20
21
  import {
21
22
  getPanelSearchFocusTransition,
22
23
  isPanelSearchBackspace,
@@ -84,15 +85,12 @@ interface TreeNode {
84
85
  // ---------------------------------------------------------------------------
85
86
  // Colour palette
86
87
  // ---------------------------------------------------------------------------
87
- const CLR_DIR = '#00ffff'; // cyan — directories
88
- const CLR_FILE = '#e0e0e0'; // near-whitefiles
89
- const CLR_SIZE = '244'; // dim grey sizes
90
- const CLR_CURSOR = '#1a2a3a'; // cursor background
91
- const CLR_CURSOR_FG = '#ffffff';
92
- const CLR_SEARCH_BG = '#2a1a3a';
93
- const CLR_SEARCH_FG = '#ff79c6';
94
- const CLR_TOGGLE = '244'; // ▶/▼ toggle arrows
95
- const CLR_ICON = '244'; // file type icon
88
+ const C = extendPalette(DEFAULT_PANEL_PALETTE, {
89
+ dirColor: '#00ffff', // cyandirectories
90
+ fileColor: '#e0e0e0', // near-whitefiles
91
+ sizeColor: '244', // dim grey — sizes
92
+ cursorBg: '#1a2a3a', // cursor background
93
+ });
96
94
 
97
95
  // ---------------------------------------------------------------------------
98
96
  // Helpers
@@ -215,21 +213,29 @@ export class FileExplorerPanel extends BasePanel {
215
213
  ? `Filter: ${this.searchQuery} (/ or up at top to edit)`
216
214
  : `Root: ${relative(this.workingDirectory, this.rootPath) || '.'} (/ or up at top to search)`;
217
215
 
216
+ // Context-aware hints: only surface keys that act in the current mode.
217
+ const hintsLine = this.searchMode
218
+ ? buildKeyboardHints(width, [
219
+ { keys: 'type', label: 'filter' },
220
+ { keys: '↑/↓', label: 'move' },
221
+ { keys: 'Enter', label: 'keep results' },
222
+ { keys: 'Esc', label: 'clear' },
223
+ ], C)
224
+ : buildKeyboardHints(width, [
225
+ { keys: '↑/↓', label: 'navigate' },
226
+ { keys: 'Enter/→', label: 'expand' },
227
+ { keys: '←', label: 'collapse' },
228
+ { keys: '/', label: 'search' },
229
+ { keys: 'r', label: 'refresh' },
230
+ ], C);
231
+
218
232
  if (this.flat.length === 0) {
219
233
  return buildPanelWorkspace(width, height, {
220
234
  title: ' Explorer',
221
235
  intro: 'Browse the project tree, expand directories, and search for paths.',
222
236
  sections: [
223
237
  {
224
- lines: buildEmptyState(
225
- width,
226
- ' No files found',
227
- this.searchQuery
228
- ? 'No files or directories match the current search.'
229
- : 'This root did not produce any visible files after the explorer filters were applied.',
230
- [],
231
- DEFAULT_PANEL_PALETTE,
232
- ),
238
+ lines: buildEmptyState(width, ' No files found', this.searchQuery ? 'No files or directories match the current search.' : 'This root did not produce any visible files after the explorer filters were applied.', this.searchQuery ? [{ command: 'Esc', summary: 'clear the filter to show the full tree again' }] : [{ command: 'r', summary: 'rescan this root from disk' }], C),
233
239
  },
234
240
  ],
235
241
  footerLines: [
@@ -237,7 +243,7 @@ export class FileExplorerPanel extends BasePanel {
237
243
  active: this.searchMode,
238
244
  valueColor: this.searchMode ? DEFAULT_PANEL_PALETTE.info : DEFAULT_PANEL_PALETTE.dim,
239
245
  }),
240
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Enter/Right', DEFAULT_PANEL_PALETTE.info], [' expand', DEFAULT_PANEL_PALETTE.dim], [' Left', DEFAULT_PANEL_PALETTE.info], [' collapse', DEFAULT_PANEL_PALETTE.dim], [' /', DEFAULT_PANEL_PALETTE.info], [' search', DEFAULT_PANEL_PALETTE.dim], [' r', DEFAULT_PANEL_PALETTE.info], [' refresh', DEFAULT_PANEL_PALETTE.dim]]),
246
+ hintsLine,
241
247
  ],
242
248
  palette: DEFAULT_PANEL_PALETTE,
243
249
  });
@@ -279,7 +285,7 @@ export class FileExplorerPanel extends BasePanel {
279
285
  active: this.searchMode,
280
286
  valueColor: this.searchMode ? DEFAULT_PANEL_PALETTE.info : DEFAULT_PANEL_PALETTE.dim,
281
287
  }),
282
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Enter/Right', DEFAULT_PANEL_PALETTE.info], [' expand', DEFAULT_PANEL_PALETTE.dim], [' Left', DEFAULT_PANEL_PALETTE.info], [' collapse', DEFAULT_PANEL_PALETTE.dim], [' /', DEFAULT_PANEL_PALETTE.info], [' search', DEFAULT_PANEL_PALETTE.dim], [' r', DEFAULT_PANEL_PALETTE.info], [' refresh', DEFAULT_PANEL_PALETTE.dim]]),
288
+ hintsLine,
283
289
  ],
284
290
  palette: DEFAULT_PANEL_PALETTE,
285
291
  beforeSections: [summarySection],
@@ -287,24 +293,17 @@ export class FileExplorerPanel extends BasePanel {
287
293
  title: 'Tree',
288
294
  scrollableLines: this.flat.map((node, absoluteIdx) => {
289
295
  const isCursor = absoluteIdx === this.cursor;
290
- const baseBg = isCursor ? CLR_CURSOR : '';
291
- const baseFg = isCursor ? CLR_CURSOR_FG : (node.isDir ? CLR_DIR : CLR_FILE);
292
- const indent = ' '.repeat(node.depth);
293
- const segments = [
294
- { text: indent, fg: baseFg },
295
- node.isDir
296
- ? { text: node.expanded ? '▾ ' : '▸ ', fg: CLR_TOGGLE, bold: isCursor }
297
- : { text: `${fileIcon(node.name)} `, fg: CLR_ICON, dim: !isCursor },
298
- { text: node.name, fg: baseFg, bold: node.isDir || isCursor },
299
- ];
300
- if (!node.isDir && node.size > 0) {
301
- const sizeStr = ` ${formatSize(node.size)}`;
302
- const contentWidth = getDisplayWidth(indent) + 2 + getDisplayWidth(node.name);
303
- const gap = Math.max(1, width - contentWidth - getDisplayWidth(sizeStr));
304
- segments.push({ text: ' '.repeat(gap), fg: baseFg });
305
- segments.push({ text: sizeStr, fg: CLR_SIZE, dim: true });
306
- }
307
- return buildSelectablePanelLine(width, segments, { selected: isCursor, selectedBg: baseBg, fillFg: baseFg });
296
+ return buildTreeRow(width, {
297
+ depth: node.depth,
298
+ label: node.name,
299
+ icon: node.isDir ? undefined : fileIcon(node.name),
300
+ expandable: node.isDir,
301
+ expanded: node.expanded,
302
+ labelColor: node.isDir ? C.dirColor : C.fileColor,
303
+ metadata: (!node.isDir && node.size > 0)
304
+ ? [{ text: formatSize(node.size), fg: C.sizeColor }]
305
+ : undefined,
306
+ }, C, { selected: isCursor, selectedBg: C.cursorBg });
308
307
  }),
309
308
  selectedIndex: this.cursor,
310
309
  scrollOffset: this.scrollTop,
@@ -326,7 +325,7 @@ export class FileExplorerPanel extends BasePanel {
326
325
  active: this.searchMode,
327
326
  valueColor: this.searchMode ? DEFAULT_PANEL_PALETTE.info : DEFAULT_PANEL_PALETTE.dim,
328
327
  }),
329
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Enter/Right', DEFAULT_PANEL_PALETTE.info], [' expand', DEFAULT_PANEL_PALETTE.dim], [' Left', DEFAULT_PANEL_PALETTE.info], [' collapse', DEFAULT_PANEL_PALETTE.dim], [' /', DEFAULT_PANEL_PALETTE.info], [' search', DEFAULT_PANEL_PALETTE.dim], [' r', DEFAULT_PANEL_PALETTE.info], [' refresh', DEFAULT_PANEL_PALETTE.dim]]),
328
+ hintsLine,
330
329
  ],
331
330
  palette: DEFAULT_PANEL_PALETTE,
332
331
  });
@@ -8,6 +8,7 @@ import { SyntaxHighlighter, type SyntaxToken } from '../renderer/syntax-highligh
8
8
  import { getDisplayWidth } from '../utils/terminal-width.ts';
9
9
  import {
10
10
  buildEmptyState,
11
+ buildKeyboardHints,
11
12
  buildPanelLine,
12
13
  buildPanelWorkspace,
13
14
  resolveScrollablePanelSection,
@@ -273,7 +274,11 @@ export class FilePreviewPanel extends BasePanel {
273
274
  ],
274
275
  } as const;
275
276
  const footerLines = [
276
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' scroll', DEFAULT_PANEL_PALETTE.dim], [' PgUp/PgDn', DEFAULT_PANEL_PALETTE.info], [' page', DEFAULT_PANEL_PALETTE.dim], [' Home/End', DEFAULT_PANEL_PALETTE.info], [' bounds', DEFAULT_PANEL_PALETTE.dim]]),
277
+ buildKeyboardHints(width, [
278
+ { keys: '↑/↓', label: 'scroll' },
279
+ { keys: 'PgUp/PgDn', label: 'page' },
280
+ { keys: 'Home/End', label: 'top/bottom' },
281
+ ], DEFAULT_PANEL_PALETTE),
277
282
  ];
278
283
  const fullCode = this.fileLines.join('\n');
279
284
  const hlLines = this.fenceTag
@@ -319,8 +324,10 @@ export class FilePreviewPanel extends BasePanel {
319
324
  buildPanelLine(width, [
320
325
  [' Lines ', DEFAULT_PANEL_PALETTE.label],
321
326
  [String(this.fileLines.length), DEFAULT_PANEL_PALETTE.value],
322
- [' Scroll ', DEFAULT_PANEL_PALETTE.label],
327
+ [' Viewing ', DEFAULT_PANEL_PALETTE.label],
323
328
  [`${window.start + 1}-${window.end}`, DEFAULT_PANEL_PALETTE.info],
329
+ [' Lang ', DEFAULT_PANEL_PALETTE.label],
330
+ [this.fenceTag || 'text', this.fenceTag ? DEFAULT_PANEL_PALETTE.value : DEFAULT_PANEL_PALETTE.dim],
324
331
  ]),
325
332
  ],
326
333
  },
@@ -7,6 +7,7 @@
7
7
  * Open via /forensics or the panel picker.
8
8
  */
9
9
  import type { Line } from '../types/grid.ts';
10
+ import { fitDisplay, truncateDisplay } from '../utils/terminal-width.ts';
10
11
  import type { ForensicsRegistry } from '@/runtime/index.ts';
11
12
  import type { FailureReport, CausalChainEntry, PhaseTimingEntry } from '@/runtime/index.ts';
12
13
  import { ForensicsDataPanel } from '@/runtime/index.ts';
@@ -20,6 +21,7 @@ import {
20
21
  DEFAULT_PANEL_PALETTE,
21
22
  type PanelWorkspaceSection,
22
23
  } from './polish.ts';
24
+ import { formatShortDuration } from '../utils/format-duration.ts';
23
25
 
24
26
  // ── Colour palette ────────────────────────────────────────────────────────────
25
27
  const C = {
@@ -59,9 +61,7 @@ function fmtTime(ts: number): string {
59
61
  }
60
62
 
61
63
  function fmtDuration(ms: number | undefined): string {
62
- if (ms === undefined) return '?ms';
63
- if (ms < 1000) return `${ms}ms`;
64
- return `${(ms / 1000).toFixed(1)}s`;
64
+ return formatShortDuration(ms);
65
65
  }
66
66
 
67
67
  function classificationColor(cls: FailureReport['classification']): string {
@@ -195,7 +195,21 @@ export class ForensicsPanel extends BasePanel {
195
195
  // ── List view ──────────────────────────────────────────────────────────────
196
196
 
197
197
  private _renderList(lines: Line[], reports: FailureReport[], width: number, height: number, intro: string): void {
198
+ // Posture summary: how many reports and how many are hard errors, so the
199
+ // most important signal (recent failures) is visible before scrolling.
200
+ const errorCount = reports.filter((r) => classificationColor(r.classification) === C.classError).length;
201
+ const newest = reports[0];
202
+ const summaryLine = buildPanelLine(width, [
203
+ [' reports ', C.label],
204
+ [String(reports.length), C.value],
205
+ [' errors ', C.label],
206
+ [String(errorCount), errorCount > 0 ? C.classError : C.classOk],
207
+ [' newest ', C.label],
208
+ [newest ? fmtTime(newest.generatedAt) : 'n/a', C.timestamp],
209
+ ]);
210
+
198
211
  const reportRows: Line[] = [
212
+ summaryLine,
199
213
  buildPanelLine(width, [[' ID TIME CLASS SUMMARY', C.label]]),
200
214
  ];
201
215
 
@@ -204,12 +218,12 @@ export class ForensicsPanel extends BasePanel {
204
218
  const isSelected = i === this._selectedIndex;
205
219
  const bg = isSelected ? C.selectBg : undefined;
206
220
 
207
- const idStr = report.id.slice(0, 8).padEnd(8, ' ');
221
+ const idStr = fitDisplay(report.id, 8);
208
222
  const timeStr = fmtTime(report.generatedAt);
209
- const cls = report.classification.slice(0, 20).padEnd(20, ' ');
223
+ const cls = fitDisplay(report.classification, 20);
210
224
  const clsColor = classificationColor(report.classification);
211
225
  const summaryMax = Math.max(0, width - 42);
212
- const summaryStr = report.summary.slice(0, summaryMax);
226
+ const summaryStr = truncateDisplay(report.summary, summaryMax);
213
227
 
214
228
  const segs: Array<[string, string, string?]> = [
215
229
  [isSelected ? '▸' : ' ', C.jumpLink, bg],
@@ -226,12 +240,12 @@ export class ForensicsPanel extends BasePanel {
226
240
  section: {
227
241
  title: 'Reports',
228
242
  scrollableLines: reportRows,
229
- selectedIndex: this._selectedIndex + 1,
243
+ selectedIndex: this._selectedIndex + 2,
230
244
  scrollOffset: this._scrollOffset,
231
245
  minRows: 4,
232
246
  appendWindowSummary: {
233
247
  dimColor: C.label,
234
- formatter: () => buildPanelLine(width, [[` [${this._selectedIndex + 1}/${reports.length}] Up/Down navigate Enter expand`, C.label]]),
248
+ formatter: () => buildPanelLine(width, [[` [${this._selectedIndex + 1}/${reports.length}] ↑/↓ navigate Enter expand report`, C.label]]),
235
249
  },
236
250
  },
237
251
  });
@@ -262,13 +276,13 @@ export class ForensicsPanel extends BasePanel {
262
276
  ]));
263
277
  detailLines.push(buildPanelLine(width, [
264
278
  [' Summary: ', C.label],
265
- [report.summary.slice(0, width - 11), C.summaryText],
279
+ [truncateDisplay(report.summary, Math.max(0, width - 11)), C.summaryText],
266
280
  ]));
267
281
 
268
282
  if (report.errorMessage) {
269
283
  detailLines.push(buildPanelLine(width, [
270
284
  [' Error: ', C.label],
271
- [report.errorMessage.slice(0, width - 11), C.classError],
285
+ [truncateDisplay(report.errorMessage, Math.max(0, width - 11)), C.classError],
272
286
  ]));
273
287
  }
274
288
  if (report.stopReason) {
@@ -339,8 +353,8 @@ export class ForensicsPanel extends BasePanel {
339
353
  const statusChar = pt.success ? '✓' : '✕';
340
354
  const statusColor = pt.success ? C.phaseOk : C.phaseFail;
341
355
  const dur = fmtDuration(pt.durationMs);
342
- const phaseLabel = pt.phase.slice(0, 14).padEnd(14, ' ');
343
- const errPart = pt.error ? ` ${pt.error.slice(0, Math.max(0, width - 32))}` : '';
356
+ const phaseLabel = fitDisplay(pt.phase, 14);
357
+ const errPart = pt.error ? ` ${truncateDisplay(pt.error, Math.max(0, width - 32))}` : '';
344
358
  lines.push(buildPanelLine(width, [
345
359
  [' ', C.dim],
346
360
  [statusChar + ' ', statusColor],
@@ -358,7 +372,7 @@ export class ForensicsPanel extends BasePanel {
358
372
  lines.push(buildPanelLine(width, [
359
373
  [prefix, color],
360
374
  [`${timeStr} `, C.timestamp],
361
- [entry.description.slice(0, descMax), color],
375
+ [truncateDisplay(entry.description, descMax), color],
362
376
  ]));
363
377
  }
364
378
  }
@@ -1,13 +1,16 @@
1
1
  import { BasePanel } from './base-panel.ts';
2
2
  import { createEmptyLine, createStyledCell, type Line } from '../types/grid.ts';
3
+ import { truncateDisplay, getDisplayWidth } from '../utils/terminal-width.ts';
3
4
  import { GitService } from '@pellux/goodvibes-sdk/platform/git';
4
5
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
5
6
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
6
7
  import {
7
8
  buildEmptyState,
9
+ buildKeyboardHints,
8
10
  buildPanelLine,
9
11
  buildPanelWorkspace,
10
12
  resolveScrollablePanelSection,
13
+ buildSelectablePanelLine,
11
14
  buildStyledPanelLine,
12
15
  DEFAULT_PANEL_PALETTE,
13
16
  extendPalette,
@@ -100,11 +103,16 @@ export class GitPanel extends BasePanel {
100
103
  private loading = true;
101
104
  private error: string | null = null;
102
105
 
103
- constructor(workingDirectory: string) {
106
+ constructor(workingDirectory: string, private readonly requestRender: () => void = () => {}) {
104
107
  super('git', 'Git', 'G', 'development');
105
108
  this.workingDirectory = workingDirectory;
106
109
  }
107
110
 
111
+ private _markDirtyAndRender(): void {
112
+ this.markDirty();
113
+ this.requestRender();
114
+ }
115
+
108
116
  // ---------------------------------------------------------------------------
109
117
  // Lifecycle
110
118
  // ---------------------------------------------------------------------------
@@ -172,7 +180,7 @@ export class GitPanel extends BasePanel {
172
180
  this.error = null;
173
181
  this.rebuildItems();
174
182
  // Do not clear expandedDiff during auto-refresh — only clear on explicit user action
175
- this.markDirty();
183
+ this._markDirtyAndRender();
176
184
  } catch (err) {
177
185
  const msg = summarizeError(err);
178
186
  // If the failure is because this directory isn't a git repo, auto-initialise
@@ -196,7 +204,7 @@ export class GitPanel extends BasePanel {
196
204
  }
197
205
  this.loading = false;
198
206
  logger.debug('GitPanel: refresh failed', { error: this.error });
199
- this.markDirty();
207
+ this._markDirtyAndRender();
200
208
  }
201
209
  }
202
210
 
@@ -383,77 +391,49 @@ export class GitPanel extends BasePanel {
383
391
  }
384
392
 
385
393
  private renderBranchLine(width: number): Line {
386
- const line = createEmptyLine(width);
387
- let x = 0;
388
-
389
- const branchIcon = ' git: ';
390
- x = this.paintText(line, branchIcon, x, width, C.sectionHeader);
391
- x = this.paintText(line, this.data.branch, x, width, C.branch, { bold: true });
392
-
393
- if (this.data.ahead > 0) {
394
- x = this.paintText(line, ` +${this.data.ahead}`, x, width, C.good);
395
- }
396
- if (this.data.behind > 0) {
397
- x = this.paintText(line, ` -${this.data.behind}`, x, width, C.bad);
398
- }
399
-
400
394
  const isDirty = this.data.stagedFiles.length > 0 || this.data.unstagedFiles.length > 0;
401
- const statusText = isDirty ? ' * dirty' : ' y clean';
402
- const statusFg = isDirty ? C.warn : C.good;
403
- this.paintText(line, statusText, x, width, statusFg);
404
-
405
- return line;
395
+ const segments: Array<{ text: string; fg: string; bold?: boolean }> = [
396
+ { text: ' ', fg: C.sectionHeader },
397
+ { text: this.data.branch, fg: C.branch, bold: true },
398
+ ];
399
+ if (this.data.ahead > 0) segments.push({ text: ` ↑${this.data.ahead}`, fg: C.good });
400
+ if (this.data.behind > 0) segments.push({ text: ` ↓${this.data.behind}`, fg: C.bad });
401
+ if (this.data.ahead === 0 && this.data.behind === 0) {
402
+ segments.push({ text: ' ≡ up to date', fg: C.dim });
403
+ }
404
+ segments.push({
405
+ text: isDirty ? ' ● dirty' : ' ✓ clean',
406
+ fg: isDirty ? C.warn : C.good,
407
+ bold: true,
408
+ });
409
+ segments.push({ text: ` ${this.data.stagedFiles.length} staged`, fg: this.data.stagedFiles.length > 0 ? C.good : C.dim });
410
+ segments.push({ text: ` · ${this.data.unstagedFiles.length} unstaged`, fg: this.data.unstagedFiles.length > 0 ? C.warn : C.dim });
411
+ return buildStyledPanelLine(width, segments);
406
412
  }
407
413
 
408
414
  private renderSectionHeader(label: string, width: number): Line {
409
- return buildStyledPanelLine(width, [{ text: `-- ${label} `, fg: C.sectionHeader, dim: true }]);
415
+ return buildStyledPanelLine(width, [{ text: ` ${label}`, fg: C.sectionHeader, bold: true }]);
410
416
  }
411
417
 
412
418
  private renderFileRow(entry: GitFileEntry, selected: boolean, width: number): Line {
413
- const line = createEmptyLine(width);
414
419
  const fg = entry.staged ? C.good : C.bad;
415
- const prefix = ' ';
416
- const label = `${prefix}${entry.path}`;
417
-
418
- if (selected) {
419
- // Fill background highlight
420
- for (let i = 0; i < width; i++) {
421
- line[i] = createStyledCell(' ', { bg: C.selected, fg: C.selectedFg });
422
- }
423
- let x = 0;
424
- for (const ch of label) {
425
- if (x >= width) break;
426
- line[x++] = createStyledCell(ch, { fg, bg: C.selected, bold: true });
427
- }
428
- } else {
429
- this.paintText(line, label, 0, width, fg);
430
- }
431
- return line;
420
+ // Single-char status marker: + staged (ready to commit), M modified/unstaged.
421
+ const statusGlyph = entry.staged ? '+' : 'M';
422
+ const path = truncateDisplay(entry.path, Math.max(0, width - 6));
423
+ return buildSelectablePanelLine(width, [
424
+ { text: ` ${statusGlyph} `, fg, bg: selected ? C.selected : undefined, bold: true },
425
+ { text: path, fg: selected ? C.selectedFg : fg, bg: selected ? C.selected : undefined, bold: selected },
426
+ ], { selected, selectedBg: C.selected, fillFg: selected ? C.selectedFg : '', leadingMarker: '▸' });
432
427
  }
433
428
 
434
429
  private renderCommitRow(entry: CommitEntry, selected: boolean, width: number): Line {
435
- const line = createEmptyLine(width);
436
- const hashPart = ` ${entry.hash} `;
437
- const msgPart = entry.message.length > 60 ? `${entry.message.slice(0, 57)}...` : entry.message;
438
-
439
- if (selected) {
440
- for (let i = 0; i < width; i++) {
441
- line[i] = createStyledCell(' ', { bg: C.selected, fg: C.selectedFg });
442
- }
443
- let x = 0;
444
- for (const ch of hashPart) {
445
- if (x >= width) break;
446
- line[x++] = createStyledCell(ch, { fg: C.commitHash, bg: C.selected });
447
- }
448
- for (const ch of msgPart) {
449
- if (x >= width) break;
450
- line[x++] = createStyledCell(ch, { fg: C.selectedFg, bg: C.selected });
451
- }
452
- } else {
453
- let x = this.paintText(line, hashPart, 0, width, C.commitHash);
454
- this.paintText(line, msgPart, x, width, C.commit);
455
- }
456
- return line;
430
+ const hashPart = ` ${entry.hash} `;
431
+ const msgBudget = Math.max(0, width - getDisplayWidth(hashPart) - 3);
432
+ const msgPart = truncateDisplay(entry.message, msgBudget);
433
+ return buildSelectablePanelLine(width, [
434
+ { text: hashPart, fg: selected ? C.selectedFg : C.commitHash, bg: selected ? C.selected : undefined },
435
+ { text: msgPart, fg: selected ? C.selectedFg : C.commit, bg: selected ? C.selected : undefined, bold: selected },
436
+ ], { selected, selectedBg: C.selected, fillFg: selected ? C.selectedFg : '', leadingMarker: '▸' });
457
437
  }
458
438
 
459
439
  private renderList(width: number, height: number): Line[] {
@@ -528,7 +508,7 @@ export class GitPanel extends BasePanel {
528
508
  const workspaceSection = resolveScrollablePanelSection(width, height, {
529
509
  intro: 'Review branch status, staged and unstaged files, and recent commits. Open a file row to inspect its diff.',
530
510
  footerLines: [
531
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Enter', DEFAULT_PANEL_PALETTE.info], [' diff', DEFAULT_PANEL_PALETTE.dim], [' r', DEFAULT_PANEL_PALETTE.info], [' refresh', DEFAULT_PANEL_PALETTE.dim]]),
511
+ buildKeyboardHints(width, [{ keys: '↑/↓', label: 'navigate' }, { keys: 'Enter', label: 'open diff' }, { keys: 'r', label: 'refresh' }], DEFAULT_PANEL_PALETTE),
532
512
  ],
533
513
  palette: DEFAULT_PANEL_PALETTE,
534
514
  beforeSections: [summarySection],
@@ -548,11 +528,11 @@ export class GitPanel extends BasePanel {
548
528
  intro: 'Review branch status, staged and unstaged files, and recent commits. Open a file row to inspect its diff.',
549
529
  sections: [
550
530
  summarySection,
551
- workspaceSection.section.lines.length > 0 ? workspaceSection.section : { title: 'Workspace', lines: buildEmptyState(width, ' No git rows', 'This repository has no files or commits to display yet.', [], DEFAULT_PANEL_PALETTE) },
531
+ workspaceSection.section.lines.length > 0 ? workspaceSection.section : { title: 'Workspace', lines: buildEmptyState(width, ' No git rows', 'This repository has no staged or unstaged changes and no commits to display yet.', [{ command: '/git status', summary: 'refresh working-tree status, or stage changes to populate this view' }], DEFAULT_PANEL_PALETTE) },
552
532
  selectedSection,
553
533
  ],
554
534
  footerLines: [
555
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Enter', DEFAULT_PANEL_PALETTE.info], [' diff', DEFAULT_PANEL_PALETTE.dim], [' r', DEFAULT_PANEL_PALETTE.info], [' refresh', DEFAULT_PANEL_PALETTE.dim]]),
535
+ buildKeyboardHints(width, [{ keys: '↑/↓', label: 'navigate' }, { keys: 'Enter', label: 'open diff' }, { keys: 'r', label: 'refresh' }], DEFAULT_PANEL_PALETTE),
556
536
  ],
557
537
  palette: DEFAULT_PANEL_PALETTE,
558
538
  });
@@ -562,7 +542,7 @@ export class GitPanel extends BasePanel {
562
542
  intro: 'Review branch status, staged and unstaged files, and recent commits. Open a file row to inspect its diff.',
563
543
  sections: [
564
544
  {
565
- lines: buildEmptyState(width, ' No git rows', 'This repository has no files or commits to display yet.', [], DEFAULT_PANEL_PALETTE),
545
+ lines: buildEmptyState(width, ' No git rows', 'This repository has no staged or unstaged changes and no commits to display yet.', [{ command: '/git status', summary: 'refresh working-tree status, or stage changes to populate this view' }], DEFAULT_PANEL_PALETTE),
566
546
  },
567
547
  ],
568
548
  palette: DEFAULT_PANEL_PALETTE,
@@ -588,9 +568,17 @@ export class GitPanel extends BasePanel {
588
568
 
589
569
  private renderDiff(width: number, height: number): Line[] {
590
570
  const item = this.items[this.selectedIndex];
591
- const title =
592
- item?.kind === 'file' ? `Diff: ${item.entry.path}` : 'Diff';
593
571
  const diffLines = this.expandedDiff ?? [];
572
+ let added = 0;
573
+ let removed = 0;
574
+ for (const l of diffLines) {
575
+ if (l.startsWith('+') && !l.startsWith('+++')) added++;
576
+ else if (l.startsWith('-') && !l.startsWith('---')) removed++;
577
+ }
578
+ const title =
579
+ item?.kind === 'file'
580
+ ? `Diff: ${item.entry.path} +${added} -${removed}`
581
+ : 'Diff';
594
582
  const renderedLines = diffLines.map((rawLine) => {
595
583
  const dLine = createEmptyLine(width);
596
584
  let fg: string;
@@ -607,7 +595,7 @@ export class GitPanel extends BasePanel {
607
595
  return dLine;
608
596
  });
609
597
  const footerLines = [
610
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' scroll', DEFAULT_PANEL_PALETTE.dim], [' Esc/q', DEFAULT_PANEL_PALETTE.info], [' close', DEFAULT_PANEL_PALETTE.dim]]),
598
+ buildKeyboardHints(width, [{ keys: '↑/↓', label: 'scroll' }, { keys: 'Esc/q', label: 'back to files' }], DEFAULT_PANEL_PALETTE),
611
599
  ];
612
600
  const diffSection = resolveScrollablePanelSection(width, height, {
613
601
  palette: DEFAULT_PANEL_PALETTE,
@@ -9,6 +9,8 @@ import type { HookChain, HookDefinition } from '@pellux/goodvibes-sdk/platform/h
9
9
  import type { HookWorkbench } from '@pellux/goodvibes-sdk/platform/hooks';
10
10
  import { truncateDisplay } from '../utils/terminal-width.ts';
11
11
  import {
12
+ buildKeyValueLine,
13
+ buildKeyboardHints,
12
14
  buildPanelLine,
13
15
  buildStatusPill,
14
16
  DEFAULT_PANEL_PALETTE,
@@ -68,9 +70,17 @@ export class HooksPanel extends ScrollableListPanel<HookEntry> {
68
70
  ) {
69
71
  super('hooks', 'Hooks', 'H', 'monitoring');
70
72
  this.showSelectionGutter = true; // I5: non-color selection affordance
73
+ this.filterEnabled = true;
74
+ this.filterLabel = 'Filter hooks';
71
75
  this.dataSource = dataSource;
72
76
  }
73
77
 
78
+ protected override filterMatches(entry: HookEntry, q: string): boolean {
79
+ return (entry.hook.name ?? '').toLowerCase().includes(q)
80
+ || entry.pattern.toLowerCase().includes(q)
81
+ || entry.hook.type.toLowerCase().includes(q);
82
+ }
83
+
74
84
  protected override getPalette() { return C; }
75
85
  protected override getEmptyStateMessage() { return ' No hooks are currently registered.'; }
76
86
  protected override getEmptyStateActions() {
@@ -96,7 +106,7 @@ export class HooksPanel extends ScrollableListPanel<HookEntry> {
96
106
  }
97
107
 
98
108
  public handleInput(key: string): boolean {
99
- if (key === 'r') {
109
+ if (!this.filterActive && key === 'r') {
100
110
  this.markDirty();
101
111
  return true;
102
112
  }
@@ -224,15 +234,39 @@ export class HooksPanel extends ScrollableListPanel<HookEntry> {
224
234
  });
225
235
  }
226
236
 
237
+ // Summary header — surface registration + activity counts first so the most
238
+ // important "what's firing" signal is visible without scrolling to the footer.
239
+ const denials = recentActivity.filter((r) => r.ok && r.decision === 'deny').length;
240
+ const errors = recentActivity.filter((r) => !r.ok).length;
241
+ const headerLines: Line[] = [
242
+ buildKeyValueLine(width, [
243
+ { label: 'hooks', value: String(hooks.length), valueColor: C.info },
244
+ { label: 'chains', value: String(chains.length), valueColor: C.value },
245
+ { label: 'contracts', value: String(contracts.length), valueColor: C.value },
246
+ { label: 'recent denials', value: String(denials), valueColor: denials > 0 ? C.warn : C.dim },
247
+ { label: 'errors', value: String(errors), valueColor: errors > 0 ? C.error : C.dim },
248
+ ], C),
249
+ ];
250
+
251
+ const hints = this.filterActive
252
+ ? [{ keys: 'type', label: 'filter' }, { keys: 'Enter', label: 'apply' }, { keys: 'Esc', label: 'clear' }]
253
+ : [
254
+ { keys: 'Up/Down', label: 'move' },
255
+ { keys: 'r', label: 'refresh' },
256
+ { keys: '/hooks', label: 'full listing' },
257
+ { keys: '/', label: 'filter' },
258
+ ];
259
+
227
260
  return this.renderList(width, height, {
228
261
  title: 'Hooks Control Room',
262
+ header: headerLines,
229
263
  footer: [
230
264
  ...detailLines,
231
265
  buildPanelLine(width, [[' Recent Activity', C.label]]),
232
266
  ...activityLines,
233
267
  buildPanelLine(width, [[' Authoring', C.label]]),
234
268
  ...authoringLines,
235
- buildPanelLine(width, [[' Up/Down move r refresh /hooks for full contract listing', C.dim]]),
269
+ buildKeyboardHints(width, hints, C),
236
270
  ],
237
271
  });
238
272
  }