@pellux/goodvibes-tui 0.26.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 (148) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +12 -7
  3. package/docs/foundation-artifacts/operator-contract.json +4 -1
  4. package/package.json +2 -2
  5. package/src/cli/bundle-command.ts +2 -1
  6. package/src/cli/service-posture.ts +2 -1
  7. package/src/core/context-usage.ts +53 -0
  8. package/src/core/conversation-rendering.ts +2 -2
  9. package/src/core/conversation.ts +53 -8
  10. package/src/core/session-recovery.ts +26 -18
  11. package/src/core/system-message-router.ts +42 -26
  12. package/src/core/turn-event-wiring.ts +13 -16
  13. package/src/daemon/handlers/calendar/caldav-client.ts +2 -1
  14. package/src/daemon/handlers/inbox/index.ts +2 -1
  15. package/src/daemon/handlers/inbox/poller.ts +2 -1
  16. package/src/daemon/handlers/inbox/providers/discord.ts +2 -1
  17. package/src/daemon/handlers/inbox/providers/email.ts +2 -1
  18. package/src/daemon/handlers/inbox/providers/route-util.ts +2 -1
  19. package/src/daemon/handlers/inbox/providers/slack.ts +2 -1
  20. package/src/daemon/handlers/triage/integration.ts +2 -1
  21. package/src/daemon/handlers/triage/pipeline.ts +2 -1
  22. package/src/daemon/handlers/triage/tagger/discord.ts +2 -1
  23. package/src/daemon/handlers/triage/tagger/imap.ts +4 -3
  24. package/src/export/gist-uploader.ts +3 -1
  25. package/src/input/command-registry.ts +1 -0
  26. package/src/input/commands/cloudflare-runtime.ts +2 -1
  27. package/src/input/commands/eval.ts +4 -3
  28. package/src/input/commands/guidance-runtime.ts +2 -4
  29. package/src/input/commands/health-runtime.ts +13 -7
  30. package/src/input/commands/knowledge.ts +2 -1
  31. package/src/input/commands/runtime-services.ts +40 -10
  32. package/src/input/handler-command-route.ts +1 -1
  33. package/src/input/handler-feed-routes.ts +61 -4
  34. package/src/input/handler-feed.ts +13 -0
  35. package/src/input/handler-interactions.ts +2 -1
  36. package/src/input/handler-modal-routes.ts +2 -1
  37. package/src/input/handler-onboarding-cloudflare.ts +2 -1
  38. package/src/input/handler-onboarding.ts +8 -8
  39. package/src/input/handler-picker-routes.ts +18 -13
  40. package/src/input/handler-ui-state.ts +5 -1
  41. package/src/input/keybindings.ts +5 -0
  42. package/src/input/model-picker.ts +5 -0
  43. package/src/input/panel-integration-actions.ts +10 -0
  44. package/src/input/tts-settings-actions.ts +2 -1
  45. package/src/main.ts +52 -60
  46. package/src/panels/agent-inspector-panel.ts +90 -15
  47. package/src/panels/agent-inspector-shared.ts +3 -3
  48. package/src/panels/agent-logs-panel.ts +149 -72
  49. package/src/panels/approval-panel.ts +146 -95
  50. package/src/panels/automation-control-panel.ts +24 -1
  51. package/src/panels/base-panel.ts +28 -3
  52. package/src/panels/builtin/agent.ts +3 -1
  53. package/src/panels/builtin/development.ts +2 -1
  54. package/src/panels/builtin/session.ts +1 -1
  55. package/src/panels/cockpit-panel.ts +24 -5
  56. package/src/panels/cockpit-read-model.ts +2 -1
  57. package/src/panels/communication-panel.ts +108 -43
  58. package/src/panels/context-visualizer-panel.ts +49 -15
  59. package/src/panels/control-plane-panel.ts +27 -1
  60. package/src/panels/cost-tracker-panel.ts +87 -65
  61. package/src/panels/debug-panel.ts +61 -37
  62. package/src/panels/diff-panel.ts +59 -30
  63. package/src/panels/docs-panel.ts +30 -24
  64. package/src/panels/eval-panel.ts +130 -81
  65. package/src/panels/expandable-list-panel.ts +189 -0
  66. package/src/panels/file-explorer-panel.ts +42 -42
  67. package/src/panels/file-preview-panel.ts +11 -3
  68. package/src/panels/forensics-panel.ts +27 -13
  69. package/src/panels/git-panel.ts +65 -84
  70. package/src/panels/hooks-panel.ts +36 -2
  71. package/src/panels/incident-review-panel.ts +31 -10
  72. package/src/panels/intelligence-panel.ts +152 -71
  73. package/src/panels/knowledge-graph-panel.ts +89 -27
  74. package/src/panels/local-auth-panel.ts +17 -11
  75. package/src/panels/marketplace-panel.ts +33 -13
  76. package/src/panels/memory-panel.ts +7 -5
  77. package/src/panels/ops-control-panel.ts +69 -7
  78. package/src/panels/ops-strategy-panel.ts +61 -43
  79. package/src/panels/orchestration-panel.ts +34 -4
  80. package/src/panels/panel-list-panel.ts +33 -8
  81. package/src/panels/panel-manager.ts +23 -4
  82. package/src/panels/plan-dashboard-panel.ts +183 -66
  83. package/src/panels/plugins-panel.ts +48 -10
  84. package/src/panels/policy-panel.ts +60 -23
  85. package/src/panels/polish-core.ts +157 -0
  86. package/src/panels/polish-tables.ts +258 -0
  87. package/src/panels/polish.ts +44 -145
  88. package/src/panels/project-planning-panel.ts +27 -4
  89. package/src/panels/provider-accounts-panel.ts +55 -18
  90. package/src/panels/provider-health-panel.ts +118 -87
  91. package/src/panels/provider-stats-panel.ts +47 -22
  92. package/src/panels/qr-panel.ts +23 -11
  93. package/src/panels/remote-panel.ts +12 -5
  94. package/src/panels/routes-panel.ts +27 -5
  95. package/src/panels/sandbox-panel.ts +62 -27
  96. package/src/panels/schedule-panel.ts +44 -24
  97. package/src/panels/scrollable-list-panel.ts +124 -10
  98. package/src/panels/security-panel.ts +72 -20
  99. package/src/panels/services-panel.ts +68 -22
  100. package/src/panels/session-browser-panel.ts +42 -26
  101. package/src/panels/session-maintenance.ts +2 -2
  102. package/src/panels/settings-sync-panel.ts +30 -15
  103. package/src/panels/skills-panel.ts +2 -1
  104. package/src/panels/subscription-panel.ts +21 -3
  105. package/src/panels/symbol-outline-panel.ts +40 -47
  106. package/src/panels/system-messages-panel.ts +60 -27
  107. package/src/panels/tasks-panel.ts +36 -14
  108. package/src/panels/thinking-panel.ts +32 -36
  109. package/src/panels/token-budget-panel.ts +80 -53
  110. package/src/panels/tool-inspector-panel.ts +37 -39
  111. package/src/panels/types.ts +25 -0
  112. package/src/panels/watchers-panel.ts +25 -5
  113. package/src/panels/work-plan-panel.ts +127 -35
  114. package/src/panels/worktree-panel.ts +65 -20
  115. package/src/panels/wrfc-panel-format.ts +133 -0
  116. package/src/panels/wrfc-panel.ts +53 -147
  117. package/src/renderer/agent-detail-modal.ts +2 -2
  118. package/src/renderer/compaction-preview.ts +2 -1
  119. package/src/renderer/compositor.ts +46 -43
  120. package/src/renderer/modal-utils.ts +0 -10
  121. package/src/renderer/model-picker-overlay.ts +9 -4
  122. package/src/renderer/model-workspace.ts +2 -3
  123. package/src/renderer/panel-composite.ts +7 -20
  124. package/src/renderer/panel-workspace-bar.ts +14 -8
  125. package/src/renderer/process-modal.ts +2 -2
  126. package/src/renderer/progress.ts +3 -2
  127. package/src/renderer/tab-strip.ts +148 -34
  128. package/src/renderer/ui-factory.ts +4 -6
  129. package/src/runtime/bootstrap-command-context.ts +3 -0
  130. package/src/runtime/bootstrap-command-parts.ts +3 -1
  131. package/src/runtime/bootstrap-core.ts +31 -31
  132. package/src/runtime/bootstrap-shell.ts +1 -0
  133. package/src/runtime/onboarding/apply.ts +4 -3
  134. package/src/runtime/onboarding/snapshot.ts +4 -3
  135. package/src/runtime/process-lifecycle.ts +195 -0
  136. package/src/runtime/services.ts +4 -1
  137. package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
  138. package/src/runtime/ui/model-picker/types.ts +4 -0
  139. package/src/runtime/wrfc-persistence.ts +20 -5
  140. package/src/shell/ui-openers.ts +3 -2
  141. package/src/utils/format-duration.ts +55 -0
  142. package/src/utils/format-number.ts +71 -0
  143. package/src/verification/live-verifier.ts +4 -3
  144. package/src/version.ts +1 -1
  145. package/src/core/context-auto-compact.ts +0 -110
  146. package/src/panels/panel-picker.ts +0 -106
  147. package/src/renderer/panel-picker-overlay.ts +0 -202
  148. 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,
@@ -24,6 +25,7 @@ import {
24
25
  isPanelSearchCommit,
25
26
  isPanelSearchPrintable,
26
27
  } from './search-focus.ts';
28
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
27
29
 
28
30
  // ---------------------------------------------------------------------------
29
31
  // Constants
@@ -83,15 +85,12 @@ interface TreeNode {
83
85
  // ---------------------------------------------------------------------------
84
86
  // Colour palette
85
87
  // ---------------------------------------------------------------------------
86
- const CLR_DIR = '#00ffff'; // cyan — directories
87
- const CLR_FILE = '#e0e0e0'; // near-whitefiles
88
- const CLR_SIZE = '244'; // dim grey sizes
89
- const CLR_CURSOR = '#1a2a3a'; // cursor background
90
- const CLR_CURSOR_FG = '#ffffff';
91
- const CLR_SEARCH_BG = '#2a1a3a';
92
- const CLR_SEARCH_FG = '#ff79c6';
93
- const CLR_TOGGLE = '244'; // ▶/▼ toggle arrows
94
- 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
+ });
95
94
 
96
95
  // ---------------------------------------------------------------------------
97
96
  // Helpers
@@ -214,21 +213,29 @@ export class FileExplorerPanel extends BasePanel {
214
213
  ? `Filter: ${this.searchQuery} (/ or up at top to edit)`
215
214
  : `Root: ${relative(this.workingDirectory, this.rootPath) || '.'} (/ or up at top to search)`;
216
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
+
217
232
  if (this.flat.length === 0) {
218
233
  return buildPanelWorkspace(width, height, {
219
234
  title: ' Explorer',
220
235
  intro: 'Browse the project tree, expand directories, and search for paths.',
221
236
  sections: [
222
237
  {
223
- lines: buildEmptyState(
224
- width,
225
- ' No files found',
226
- this.searchQuery
227
- ? 'No files or directories match the current search.'
228
- : 'This root did not produce any visible files after the explorer filters were applied.',
229
- [],
230
- DEFAULT_PANEL_PALETTE,
231
- ),
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),
232
239
  },
233
240
  ],
234
241
  footerLines: [
@@ -236,7 +243,7 @@ export class FileExplorerPanel extends BasePanel {
236
243
  active: this.searchMode,
237
244
  valueColor: this.searchMode ? DEFAULT_PANEL_PALETTE.info : DEFAULT_PANEL_PALETTE.dim,
238
245
  }),
239
- 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,
240
247
  ],
241
248
  palette: DEFAULT_PANEL_PALETTE,
242
249
  });
@@ -278,7 +285,7 @@ export class FileExplorerPanel extends BasePanel {
278
285
  active: this.searchMode,
279
286
  valueColor: this.searchMode ? DEFAULT_PANEL_PALETTE.info : DEFAULT_PANEL_PALETTE.dim,
280
287
  }),
281
- 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,
282
289
  ],
283
290
  palette: DEFAULT_PANEL_PALETTE,
284
291
  beforeSections: [summarySection],
@@ -286,24 +293,17 @@ export class FileExplorerPanel extends BasePanel {
286
293
  title: 'Tree',
287
294
  scrollableLines: this.flat.map((node, absoluteIdx) => {
288
295
  const isCursor = absoluteIdx === this.cursor;
289
- const baseBg = isCursor ? CLR_CURSOR : '';
290
- const baseFg = isCursor ? CLR_CURSOR_FG : (node.isDir ? CLR_DIR : CLR_FILE);
291
- const indent = ' '.repeat(node.depth);
292
- const segments = [
293
- { text: indent, fg: baseFg },
294
- node.isDir
295
- ? { text: node.expanded ? '▾ ' : '▸ ', fg: CLR_TOGGLE, bold: isCursor }
296
- : { text: `${fileIcon(node.name)} `, fg: CLR_ICON, dim: !isCursor },
297
- { text: node.name, fg: baseFg, bold: node.isDir || isCursor },
298
- ];
299
- if (!node.isDir && node.size > 0) {
300
- const sizeStr = ` ${formatSize(node.size)}`;
301
- const contentWidth = getDisplayWidth(indent) + 2 + getDisplayWidth(node.name);
302
- const gap = Math.max(1, width - contentWidth - getDisplayWidth(sizeStr));
303
- segments.push({ text: ' '.repeat(gap), fg: baseFg });
304
- segments.push({ text: sizeStr, fg: CLR_SIZE, dim: true });
305
- }
306
- 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 });
307
307
  }),
308
308
  selectedIndex: this.cursor,
309
309
  scrollOffset: this.scrollTop,
@@ -325,7 +325,7 @@ export class FileExplorerPanel extends BasePanel {
325
325
  active: this.searchMode,
326
326
  valueColor: this.searchMode ? DEFAULT_PANEL_PALETTE.info : DEFAULT_PANEL_PALETTE.dim,
327
327
  }),
328
- 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,
329
329
  ],
330
330
  palette: DEFAULT_PANEL_PALETTE,
331
331
  });
@@ -342,7 +342,7 @@ export class FileExplorerPanel extends BasePanel {
342
342
  this.cacheValid = true;
343
343
  });
344
344
  } catch (err) {
345
- this.setError(err instanceof Error ? err.message : String(err));
345
+ this.setError(summarizeError(err));
346
346
  }
347
347
  this.markDirty();
348
348
  })();
@@ -8,11 +8,13 @@ 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,
14
15
  DEFAULT_PANEL_PALETTE,
15
16
  } from './polish.ts';
17
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
16
18
 
17
19
  // ─── Constants ────────────────────────────────────────────────────────────────
18
20
 
@@ -144,7 +146,7 @@ export class FilePreviewPanel extends BasePanel {
144
146
  this.clampScroll(0);
145
147
  });
146
148
  } catch (err) {
147
- this.setError(err instanceof Error ? err.message : String(err));
149
+ this.setError(summarizeError(err));
148
150
  }
149
151
  this.markDirty();
150
152
  }
@@ -272,7 +274,11 @@ export class FilePreviewPanel extends BasePanel {
272
274
  ],
273
275
  } as const;
274
276
  const footerLines = [
275
- 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),
276
282
  ];
277
283
  const fullCode = this.fileLines.join('\n');
278
284
  const hlLines = this.fenceTag
@@ -318,8 +324,10 @@ export class FilePreviewPanel extends BasePanel {
318
324
  buildPanelLine(width, [
319
325
  [' Lines ', DEFAULT_PANEL_PALETTE.label],
320
326
  [String(this.fileLines.length), DEFAULT_PANEL_PALETTE.value],
321
- [' Scroll ', DEFAULT_PANEL_PALETTE.label],
327
+ [' Viewing ', DEFAULT_PANEL_PALETTE.label],
322
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],
323
331
  ]),
324
332
  ],
325
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,15 +1,19 @@
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,
16
+ extendPalette,
13
17
  } from './polish.ts';
14
18
 
15
19
  // ---------------------------------------------------------------------------
@@ -56,25 +60,17 @@ const MIN_VISIBLE_DIFF_LINES = 5;
56
60
  // Colors
57
61
  // ---------------------------------------------------------------------------
58
62
 
59
- const C = {
63
+ const C = extendPalette(DEFAULT_PANEL_PALETTE, {
60
64
  branch: '#00d7ff',
61
- clean: '#5fd700',
62
- dirty: '#ffaf00',
63
- ahead: '#5fd700',
64
- behind: '#ff5f5f',
65
65
  sectionHeader: '244',
66
- staged: '#5fd700',
67
- unstaged: '#ff5f5f',
68
66
  commit: '250',
69
67
  commitHash: '238',
70
68
  commitAuthor: '244',
71
69
  selected: '#1c1c1c',
72
70
  selectedFg: '#ffffff',
73
- diffAdd: '#5fd700',
74
- diffRemove: '#ff5f5f',
75
71
  diffMeta: '#5f87ff',
76
72
  diffNeutral: '250',
77
- } as const;
73
+ });
78
74
 
79
75
  // ---------------------------------------------------------------------------
80
76
  // GitPanel
@@ -107,11 +103,16 @@ export class GitPanel extends BasePanel {
107
103
  private loading = true;
108
104
  private error: string | null = null;
109
105
 
110
- constructor(workingDirectory: string) {
106
+ constructor(workingDirectory: string, private readonly requestRender: () => void = () => {}) {
111
107
  super('git', 'Git', 'G', 'development');
112
108
  this.workingDirectory = workingDirectory;
113
109
  }
114
110
 
111
+ private _markDirtyAndRender(): void {
112
+ this.markDirty();
113
+ this.requestRender();
114
+ }
115
+
115
116
  // ---------------------------------------------------------------------------
116
117
  // Lifecycle
117
118
  // ---------------------------------------------------------------------------
@@ -179,7 +180,7 @@ export class GitPanel extends BasePanel {
179
180
  this.error = null;
180
181
  this.rebuildItems();
181
182
  // Do not clear expandedDiff during auto-refresh — only clear on explicit user action
182
- this.markDirty();
183
+ this._markDirtyAndRender();
183
184
  } catch (err) {
184
185
  const msg = summarizeError(err);
185
186
  // If the failure is because this directory isn't a git repo, auto-initialise
@@ -203,7 +204,7 @@ export class GitPanel extends BasePanel {
203
204
  }
204
205
  this.loading = false;
205
206
  logger.debug('GitPanel: refresh failed', { error: this.error });
206
- this.markDirty();
207
+ this._markDirtyAndRender();
207
208
  }
208
209
  }
209
210
 
@@ -352,7 +353,7 @@ export class GitPanel extends BasePanel {
352
353
  return this.renderMessage(width, height, 'Loading git status...', C.branch);
353
354
  }
354
355
  if (this.error) {
355
- return this.renderMessage(width, height, `Git error: ${this.error}`, C.unstaged);
356
+ return this.renderMessage(width, height, `Git error: ${this.error}`, C.bad);
356
357
  }
357
358
  // I3: spinner during openDiff() async fetch
358
359
  if (this.loadingState === 'loading') {
@@ -390,77 +391,49 @@ export class GitPanel extends BasePanel {
390
391
  }
391
392
 
392
393
  private renderBranchLine(width: number): Line {
393
- const line = createEmptyLine(width);
394
- let x = 0;
395
-
396
- const branchIcon = ' git: ';
397
- x = this.paintText(line, branchIcon, x, width, C.sectionHeader);
398
- x = this.paintText(line, this.data.branch, x, width, C.branch, { bold: true });
399
-
400
- if (this.data.ahead > 0) {
401
- x = this.paintText(line, ` +${this.data.ahead}`, x, width, C.ahead);
402
- }
403
- if (this.data.behind > 0) {
404
- x = this.paintText(line, ` -${this.data.behind}`, x, width, C.behind);
405
- }
406
-
407
394
  const isDirty = this.data.stagedFiles.length > 0 || this.data.unstagedFiles.length > 0;
408
- const statusText = isDirty ? ' * dirty' : ' y clean';
409
- const statusFg = isDirty ? C.dirty : C.clean;
410
- this.paintText(line, statusText, x, width, statusFg);
411
-
412
- 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);
413
412
  }
414
413
 
415
414
  private renderSectionHeader(label: string, width: number): Line {
416
- return buildStyledPanelLine(width, [{ text: `-- ${label} `, fg: C.sectionHeader, dim: true }]);
415
+ return buildStyledPanelLine(width, [{ text: ` ${label}`, fg: C.sectionHeader, bold: true }]);
417
416
  }
418
417
 
419
418
  private renderFileRow(entry: GitFileEntry, selected: boolean, width: number): Line {
420
- const line = createEmptyLine(width);
421
- const fg = entry.staged ? C.staged : C.unstaged;
422
- const prefix = ' ';
423
- const label = `${prefix}${entry.path}`;
424
-
425
- if (selected) {
426
- // Fill background highlight
427
- for (let i = 0; i < width; i++) {
428
- line[i] = createStyledCell(' ', { bg: C.selected, fg: C.selectedFg });
429
- }
430
- let x = 0;
431
- for (const ch of label) {
432
- if (x >= width) break;
433
- line[x++] = createStyledCell(ch, { fg, bg: C.selected, bold: true });
434
- }
435
- } else {
436
- this.paintText(line, label, 0, width, fg);
437
- }
438
- return line;
419
+ const fg = entry.staged ? C.good : C.bad;
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: '▸' });
439
427
  }
440
428
 
441
429
  private renderCommitRow(entry: CommitEntry, selected: boolean, width: number): Line {
442
- const line = createEmptyLine(width);
443
- const hashPart = ` ${entry.hash} `;
444
- const msgPart = entry.message.length > 60 ? `${entry.message.slice(0, 57)}...` : entry.message;
445
-
446
- if (selected) {
447
- for (let i = 0; i < width; i++) {
448
- line[i] = createStyledCell(' ', { bg: C.selected, fg: C.selectedFg });
449
- }
450
- let x = 0;
451
- for (const ch of hashPart) {
452
- if (x >= width) break;
453
- line[x++] = createStyledCell(ch, { fg: C.commitHash, bg: C.selected });
454
- }
455
- for (const ch of msgPart) {
456
- if (x >= width) break;
457
- line[x++] = createStyledCell(ch, { fg: C.selectedFg, bg: C.selected });
458
- }
459
- } else {
460
- let x = this.paintText(line, hashPart, 0, width, C.commitHash);
461
- this.paintText(line, msgPart, x, width, C.commit);
462
- }
463
- 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: '▸' });
464
437
  }
465
438
 
466
439
  private renderList(width: number, height: number): Line[] {
@@ -535,7 +508,7 @@ export class GitPanel extends BasePanel {
535
508
  const workspaceSection = resolveScrollablePanelSection(width, height, {
536
509
  intro: 'Review branch status, staged and unstaged files, and recent commits. Open a file row to inspect its diff.',
537
510
  footerLines: [
538
- 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),
539
512
  ],
540
513
  palette: DEFAULT_PANEL_PALETTE,
541
514
  beforeSections: [summarySection],
@@ -555,11 +528,11 @@ export class GitPanel extends BasePanel {
555
528
  intro: 'Review branch status, staged and unstaged files, and recent commits. Open a file row to inspect its diff.',
556
529
  sections: [
557
530
  summarySection,
558
- 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) },
559
532
  selectedSection,
560
533
  ],
561
534
  footerLines: [
562
- 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),
563
536
  ],
564
537
  palette: DEFAULT_PANEL_PALETTE,
565
538
  });
@@ -569,7 +542,7 @@ export class GitPanel extends BasePanel {
569
542
  intro: 'Review branch status, staged and unstaged files, and recent commits. Open a file row to inspect its diff.',
570
543
  sections: [
571
544
  {
572
- 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),
573
546
  },
574
547
  ],
575
548
  palette: DEFAULT_PANEL_PALETTE,
@@ -595,16 +568,24 @@ export class GitPanel extends BasePanel {
595
568
 
596
569
  private renderDiff(width: number, height: number): Line[] {
597
570
  const item = this.items[this.selectedIndex];
598
- const title =
599
- item?.kind === 'file' ? `Diff: ${item.entry.path}` : 'Diff';
600
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';
601
582
  const renderedLines = diffLines.map((rawLine) => {
602
583
  const dLine = createEmptyLine(width);
603
584
  let fg: string;
604
585
  if (rawLine.startsWith('+') && !rawLine.startsWith('+++')) {
605
- fg = C.diffAdd;
586
+ fg = C.good;
606
587
  } else if (rawLine.startsWith('-') && !rawLine.startsWith('---')) {
607
- fg = C.diffRemove;
588
+ fg = C.bad;
608
589
  } else if (rawLine.startsWith('@@') || rawLine.startsWith('diff') || rawLine.startsWith('index')) {
609
590
  fg = C.diffMeta;
610
591
  } else {
@@ -614,7 +595,7 @@ export class GitPanel extends BasePanel {
614
595
  return dLine;
615
596
  });
616
597
  const footerLines = [
617
- 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),
618
599
  ];
619
600
  const diffSection = resolveScrollablePanelSection(width, height, {
620
601
  palette: DEFAULT_PANEL_PALETTE,