@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
@@ -16,7 +16,7 @@ import {
16
16
  DEFAULT_PANEL_PALETTE,
17
17
  type PanelWorkspaceSection,
18
18
  } from './polish.ts';
19
- import { truncateDisplay } from '../utils/terminal-width.ts';
19
+ import { truncateDisplay, wrapText } from '../utils/terminal-width.ts';
20
20
 
21
21
  const C = extendPalette(DEFAULT_PANEL_PALETTE, {
22
22
  // Domain-specific reasoning tones (purples) and the active-stream cyan have no
@@ -34,23 +34,6 @@ interface ReasoningBlock {
34
34
  collapsed: boolean;
35
35
  }
36
36
 
37
- function wrapLines(text: string, width: number): string[] {
38
- const result: string[] = [];
39
- const raw = text.split('\n');
40
- for (const line of raw) {
41
- if (line.length <= width) {
42
- result.push(line);
43
- } else {
44
- let start = 0;
45
- while (start < line.length) {
46
- result.push(line.slice(start, start + width));
47
- start += width;
48
- }
49
- }
50
- }
51
- return result;
52
- }
53
-
54
37
  type FlatRow = { kind: 'header'; blockIndex: number; text: string } | { kind: 'content'; text: string };
55
38
 
56
39
  export class ThinkingPanel extends BasePanel {
@@ -121,8 +104,8 @@ export class ThinkingPanel extends BasePanel {
121
104
  lines: buildEmptyState(
122
105
  width,
123
106
  ' No reasoning content yet',
124
- 'When the model emits thinking or reasoning deltas, they accumulate here in expandable blocks.',
125
- [],
107
+ 'When the model emits thinking or reasoning deltas during a turn, they accumulate here in expandable per-turn blocks.',
108
+ [{ command: '/chat <prompt>', summary: 'start a turn with a reasoning-capable model to stream thinking here' }],
126
109
  DEFAULT_PANEL_PALETTE,
127
110
  ),
128
111
  },
@@ -152,12 +135,30 @@ export class ThinkingPanel extends BasePanel {
152
135
  ],
153
136
  };
154
137
 
155
- const selectedRow = flat[this.cursorIndex];
138
+ // Resolve the reasoning block that owns the cursor row (walk back to its
139
+ // header) so the detail block describes the actual turn, not just row kind.
140
+ let selectedBlockIndex = -1;
141
+ for (let i = Math.min(this.cursorIndex, flat.length - 1); i >= 0; i--) {
142
+ const row = flat[i];
143
+ if (row?.kind === 'header') { selectedBlockIndex = row.blockIndex; break; }
144
+ }
145
+ const selectedBlock = selectedBlockIndex >= 0 ? this.blocks[selectedBlockIndex] : undefined;
156
146
  const selectedSection: PanelWorkspaceSection = {
157
147
  title: 'Selected',
158
- lines: [
159
- buildPanelLine(width, [[' Row Type ', DEFAULT_PANEL_PALETTE.label], [selectedRow?.kind ?? 'none', DEFAULT_PANEL_PALETTE.value]]),
160
- ],
148
+ lines: selectedBlock
149
+ ? [
150
+ buildPanelLine(width, [
151
+ [' Turn ', DEFAULT_PANEL_PALETTE.label],
152
+ [String(selectedBlock.turnId), DEFAULT_PANEL_PALETTE.value],
153
+ [' State ', DEFAULT_PANEL_PALETTE.label],
154
+ [selectedBlock.active ? 'streaming' : 'complete', selectedBlock.active ? DEFAULT_PANEL_PALETTE.warn : DEFAULT_PANEL_PALETTE.good],
155
+ [' View ', DEFAULT_PANEL_PALETTE.label],
156
+ [selectedBlock.collapsed ? 'collapsed' : 'expanded', DEFAULT_PANEL_PALETTE.info],
157
+ [' Chars ', DEFAULT_PANEL_PALETTE.label],
158
+ [String(selectedBlock.content.length), DEFAULT_PANEL_PALETTE.dim],
159
+ ]),
160
+ ]
161
+ : [buildPanelLine(width, [[' No block selected', DEFAULT_PANEL_PALETTE.dim]])],
161
162
  };
162
163
 
163
164
  const reasoningSection = resolveScrollablePanelSection(width, height, {
@@ -212,7 +213,7 @@ export class ThinkingPanel extends BasePanel {
212
213
  const turnLabel = `Turn ${block.turnId}${block.active ? ' (streaming)' : ''}`;
213
214
  rows.push({ kind: 'header', blockIndex: i, text: turnLabel });
214
215
  if (!block.collapsed) {
215
- const wrapped = wrapLines(block.content || '(empty)', Math.max(1, width - 2));
216
+ const wrapped = wrapText(block.content || '(empty)', Math.max(1, width - 2));
216
217
  for (const line of wrapped) {
217
218
  rows.push({ kind: 'content', text: line });
218
219
  }
@@ -8,7 +8,10 @@ import type { SessionMemoryQuery } from '../runtime/ui-service-queries.ts';
8
8
  import {
9
9
  buildEmptyState,
10
10
  buildGuidanceLine,
11
+ buildKeyboardHints,
12
+ buildMeterLine,
11
13
  buildStyledPanelLine,
14
+ buildTable,
12
15
  buildPanelWorkspace,
13
16
  resolveScrollablePanelSection,
14
17
  DEFAULT_PANEL_PALETTE,
@@ -237,6 +240,12 @@ export class TokenBudgetPanel extends BasePanel {
237
240
  const priorSections = [...sections];
238
241
  const turnsSection = resolveScrollablePanelSection(width, height, {
239
242
  intro: 'Live context pressure, session token composition, cache usage, and recent turn deltas.',
243
+ footerLines: [
244
+ buildKeyboardHints(width, [
245
+ { keys: '/compact', label: 'compress context' },
246
+ { keys: '/clear', label: 'reset session' },
247
+ ], DEFAULT_PANEL_PALETTE),
248
+ ],
240
249
  palette: DEFAULT_PANEL_PALETTE,
241
250
  beforeSections: priorSections,
242
251
  section: {
@@ -273,6 +282,12 @@ export class TokenBudgetPanel extends BasePanel {
273
282
  title: ' Token Budget',
274
283
  intro: 'Live context pressure, session token composition, cache usage, and recent turn deltas.',
275
284
  sections,
285
+ footerLines: [
286
+ buildKeyboardHints(width, [
287
+ { keys: '/compact', label: 'compress context' },
288
+ { keys: '/clear', label: 'reset session' },
289
+ ], DEFAULT_PANEL_PALETTE),
290
+ ],
276
291
  palette: DEFAULT_PANEL_PALETTE,
277
292
  });
278
293
  });
@@ -398,12 +413,12 @@ export class TokenBudgetPanel extends BasePanel {
398
413
  const suffix = ` ${fmtTok(this.lastInputTokens)}/${fmtTok(this.contextWindow)} (${pctInt}%)${warnSuffix}`;
399
414
  const BAR_W = Math.max(8, width - label.length - suffix.length - 2);
400
415
  const filled = Math.round(pct * BAR_W);
401
- lines.push(buildStyledPanelLine(width, [
402
- { text: label, fg: C.label },
403
- { text: '#'.repeat(filled), fg: barColor, dim: pct < WARN_YELLOW },
404
- { text: '.'.repeat(Math.max(0, BAR_W - filled)), fg: C.barBg, dim: pct < WARN_YELLOW },
405
- { text: suffix, fg: barColor, dim: pct < WARN_YELLOW },
406
- ]));
416
+ // Context fill is the headline metric — render it with the shared meter
417
+ // primitive so the bar glyphs and width handling match every other panel.
418
+ lines.push(buildMeterLine(width, filled, BAR_W,
419
+ { filled: barColor, empty: C.barBg, label: C.label },
420
+ { prefix: label, suffix },
421
+ ));
407
422
  return lines;
408
423
  }
409
424
 
@@ -436,41 +451,37 @@ export class TokenBudgetPanel extends BasePanel {
436
451
 
437
452
  /** Last N turns table: turn#, in, out, CR, CW, total. */
438
453
  private renderTurnHistory(width: number, maxRows: number): Line[] {
439
- const lines: Line[] = [];
440
-
441
- // Column headers
442
- const colLine = createEmptyLine(width);
443
- const headers = [' #', ' Input', ' Output', ' CR', ' CW', ' Total'];
444
- const cols = [3, 9, 9, 6, 6, 9];
445
- let hx = 0;
446
- for (let i = 0; i < headers.length; i++) {
447
- const h = headers[i]!;
448
- for (const ch of h.slice(0, cols[i]!)) {
449
- if (hx >= width) break;
450
- colLine[hx++] = createStyledCell(ch, { fg: C.turnHeader, dim: true });
451
- }
452
- }
453
- lines.push(colLine);
454
-
455
454
  const available = Math.max(0, maxRows - 1); // minus col header
456
455
  const toShow = this.turnHistory.slice(-Math.max(0, available));
457
456
 
458
- toShow.forEach((t, i) => {
459
- if (lines.length >= maxRows) return;
460
- const turnNum = this.turnHistory.length - toShow.length + i + 1;
461
- const total = t.input + t.output + t.cacheRead + t.cacheWrite;
462
- const cells: Array<[string, string]> = [
463
- [String(turnNum).padStart(3), C.dim],
464
- [fmtTok(t.input).padStart(9), C.input],
465
- [fmtTok(t.output).padStart(9), C.output],
466
- [fmtTok(t.cacheRead).padStart(6), C.cacheRead],
467
- [fmtTok(t.cacheWrite).padStart(6), C.cacheWrite],
468
- [fmtTok(total).padStart(9), C.value],
469
- ];
470
- lines.push(buildStyledPanelLine(width, cells.map(([val, color]) => ({ text: val, fg: color }))));
471
- });
472
-
473
- return lines;
457
+ // Width-aware aligned table via the shared primitive (replaces manual
458
+ // per-cell column math, which miscounts on wide glyphs).
459
+ return buildTable(
460
+ width,
461
+ [
462
+ { label: '#', width: 4, align: 'right' },
463
+ { label: 'Input', width: 9, align: 'right' },
464
+ { label: 'Output', width: 9, align: 'right' },
465
+ { label: 'CR', width: 7, align: 'right' },
466
+ { label: 'CW', width: 7, align: 'right' },
467
+ { label: 'Total', align: 'right' },
468
+ ],
469
+ toShow.map((t, i) => {
470
+ const turnNum = this.turnHistory.length - toShow.length + i + 1;
471
+ const total = t.input + t.output + t.cacheRead + t.cacheWrite;
472
+ return {
473
+ cells: [
474
+ { text: String(turnNum), fg: C.dim },
475
+ { text: fmtTok(t.input), fg: C.input },
476
+ { text: fmtTok(t.output), fg: C.output },
477
+ { text: fmtTok(t.cacheRead), fg: C.cacheRead },
478
+ { text: fmtTok(t.cacheWrite), fg: C.cacheWrite },
479
+ { text: fmtTok(total), fg: C.value },
480
+ ],
481
+ };
482
+ }),
483
+ { ...DEFAULT_PANEL_PALETTE, label: C.turnHeader },
484
+ );
474
485
  }
475
486
 
476
487
  /** Paint a plain text string into a new Line. */
@@ -17,6 +17,7 @@ import {
17
17
  type PanelWorkspaceSection,
18
18
  } from './polish.ts';
19
19
  import { truncateDisplay } from '../utils/terminal-width.ts';
20
+ import { formatDuration } from '../utils/format-duration.ts';
20
21
 
21
22
  // Panel-specific accents only; shared tones come from DEFAULT_PANEL_PALETTE so
22
23
  // theme changes propagate. selectedBg->selectBg, errorFg->bad are shared keys.
@@ -56,14 +57,12 @@ function shortTime(ms: number): string {
56
57
  }
57
58
 
58
59
  function formatMs(ms: number): string {
59
- if (ms < 1000) return `${ms}ms`;
60
- if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
61
- return `${Math.floor(ms / 60000)}m${Math.floor((ms % 60000) / 1000)}s`;
60
+ return formatDuration(ms);
62
61
  }
63
62
 
64
63
  function truncateJson(val: unknown, maxLen = 120): string {
65
64
  const s = JSON.stringify(val) ?? 'null';
66
- return s.length > maxLen ? s.slice(0, maxLen - 1) + '\u2026' : s;
65
+ return truncateDisplay(s, maxLen);
67
66
  }
68
67
 
69
68
  function summarizeResult(result: unknown): string | undefined {
@@ -73,7 +72,7 @@ function summarizeResult(result: unknown): string | undefined {
73
72
  const record = result as Record<string, unknown>;
74
73
  if (typeof record.preview === 'string' && record.preview.trim()) {
75
74
  const compact = record.preview.replace(/\s+/g, ' ').trim();
76
- return compact.length > 72 ? `${compact.slice(0, 69)}\u2026` : compact;
75
+ return truncateDisplay(compact, 72);
77
76
  }
78
77
  if (typeof record.kind === 'string' && typeof record.byteSize === 'number') {
79
78
  return `${record.kind} (${record.byteSize}B)`;
@@ -142,22 +141,23 @@ export class ToolInspectorPanel extends BasePanel {
142
141
  if (height <= 0 || width <= 0) return [];
143
142
 
144
143
  const running = this.records.filter(r => r.endMs === undefined).length;
144
+ const distinctTools = new Set(this.records.map((r) => r.tool)).size;
145
145
  const filterLabel = this.filterMode === 'all' ? '' : ` [${this.filterMode}]`;
146
146
  const title = ` Tools [${this.records.length} calls${running > 0 ? `, ${running} running` : ''}]${filterLabel}`;
147
- const footerLines = [
148
- buildPanelLine(width, [
149
- [' Up/Down', DEFAULT_PANEL_PALETTE.info],
150
- [' scroll', DEFAULT_PANEL_PALETTE.dim],
151
- [' Enter', DEFAULT_PANEL_PALETTE.info],
152
- [' expand', DEFAULT_PANEL_PALETTE.dim],
153
- [' f', DEFAULT_PANEL_PALETTE.info],
154
- [' filter', DEFAULT_PANEL_PALETTE.dim],
155
- [' c', DEFAULT_PANEL_PALETTE.info],
156
- [' clear', DEFAULT_PANEL_PALETTE.dim],
157
- [' g', DEFAULT_PANEL_PALETTE.info],
158
- [' end', DEFAULT_PANEL_PALETTE.dim],
159
- ]),
147
+ // Context-aware footer: only advertise keys that do something in the current
148
+ // state — filter needs >1 tool, clear/end need at least one call.
149
+ const footerSegments: Array<[string, string, string?]> = [
150
+ [' Up/Down', DEFAULT_PANEL_PALETTE.info], [' scroll', DEFAULT_PANEL_PALETTE.dim],
151
+ [' Enter', DEFAULT_PANEL_PALETTE.info], [' expand', DEFAULT_PANEL_PALETTE.dim],
160
152
  ];
153
+ if (distinctTools > 1) {
154
+ footerSegments.push([' f', DEFAULT_PANEL_PALETTE.info], [' filter', DEFAULT_PANEL_PALETTE.dim]);
155
+ }
156
+ if (this.records.length > 0) {
157
+ footerSegments.push([' c', DEFAULT_PANEL_PALETTE.info], [' clear', DEFAULT_PANEL_PALETTE.dim]);
158
+ footerSegments.push([' g', DEFAULT_PANEL_PALETTE.info], [this.autoScroll ? ' end (live)' : ' jump to end', DEFAULT_PANEL_PALETTE.dim]);
159
+ }
160
+ const footerLines = [buildPanelLine(width, footerSegments)];
161
161
 
162
162
  const flat = this._getFlat();
163
163
 
@@ -174,9 +174,15 @@ export class ToolInspectorPanel extends BasePanel {
174
174
  title: 'Calls',
175
175
  lines: buildEmptyState(
176
176
  width,
177
- ' No tool calls yet',
178
- 'Tool executions appear here as the agent works. Expand a call to inspect its arguments and result payload.',
179
- [],
177
+ this.records.length > 0 && this.filterMode !== 'all'
178
+ ? ` No "${this.filterMode}" calls`
179
+ : ' No tool calls yet',
180
+ this.records.length > 0 && this.filterMode !== 'all'
181
+ ? 'The active tool filter hides every recorded call.'
182
+ : 'Tool executions appear here as the agent works. Expand a call to inspect its arguments and result payload.',
183
+ this.records.length > 0 && this.filterMode !== 'all'
184
+ ? [{ command: 'f', summary: 'cycle the tool filter back to all calls' }]
185
+ : [{ command: '/spawn <task>', summary: 'run an agent to populate the tool-call timeline' }],
180
186
  DEFAULT_PANEL_PALETTE,
181
187
  ),
182
188
  },
@@ -1,5 +1,20 @@
1
1
  import type { Line } from '../types/grid.ts';
2
+ import type { StatusState } from '../renderer/status-glyphs.ts';
2
3
  import type { ComponentResourceContract, ComponentHealthState } from '../runtime/perf/panel-contracts.ts';
4
+ // Routed through the `@/` alias (not `./panel-manager.ts`) so this foundational
5
+ // types module stays a leaf in the relative-import graph the architecture
6
+ // cycle-checker walks. Type-only, erased at runtime — no real dependency edge.
7
+ import type { PanelManager } from '@/panels/panel-manager.ts';
8
+
9
+ /**
10
+ * Context passed to a panel's `handlePanelIntegrationAction` hook so it can
11
+ * drive cross-panel behavior (e.g. file-explorer opening the preview panel)
12
+ * without the input layer needing `instanceof` knowledge of each panel type.
13
+ */
14
+ export interface PanelIntegrationContext {
15
+ readonly panelManager: PanelManager;
16
+ readonly executeCommand?: (name: string, args: string[]) => Promise<unknown>;
17
+ }
3
18
 
4
19
  /**
5
20
  * Named logical key identifiers emitted by the input tokenizer.
@@ -67,6 +82,16 @@ export interface Panel {
67
82
  // Scroll input (optional)
68
83
  // Positive delta scrolls down; negative delta scrolls up.
69
84
  handleScroll?(deltaRows: number): boolean;
85
+
86
+ /**
87
+ * Cross-panel integration hook (optional). Called before the panel's own
88
+ * `handleInput` when a navigation/confirm key is pressed, so a panel can
89
+ * drive another panel (e.g. open a file in the preview panel). Return `true`
90
+ * to consume the key. The legacy `handlePanelIntegrationAction` router in
91
+ * `src/input/panel-integration-actions.ts` consults this first, then falls
92
+ * back to its built-in `instanceof` routing.
93
+ */
94
+ handlePanelIntegrationAction?(key: string, ctx: PanelIntegrationContext): boolean;
70
95
  }
71
96
 
72
97
  export interface PanelRegistration extends Pick<Panel, 'id' | 'name' | 'icon' | 'category'> {
@@ -4,9 +4,11 @@ import { ScrollableListPanel } from './scrollable-list-panel.ts';
4
4
  import type { UiReadModel, UiWatchersSnapshot } from '../runtime/ui-read-models.ts';
5
5
  import { truncateDisplay } from '../utils/terminal-width.ts';
6
6
  import {
7
+ buildDetailBlock,
7
8
  buildEmptyState,
8
9
  buildGuidanceLine,
9
10
  buildKeyValueLine,
11
+ buildKeyboardHints,
10
12
  buildPanelLine,
11
13
  buildPanelWorkspace,
12
14
  DEFAULT_PANEL_PALETTE,
@@ -59,10 +61,18 @@ export class WatchersPanel extends ScrollableListPanel<WatcherEntry> {
59
61
  public constructor(readModel?: UiReadModel<UiWatchersSnapshot>) {
60
62
  super('watchers', 'Watchers', 'W', 'monitoring');
61
63
  this.showSelectionGutter = true; // I5: non-color selection affordance
64
+ this.filterEnabled = true;
65
+ this.filterLabel = 'Filter watchers';
62
66
  this.readModel = readModel;
63
67
  this.unsub = readModel ? readModel.subscribe(() => this.markDirty()) : null;
64
68
  }
65
69
 
70
+ protected override filterMatches(watcher: WatcherEntry, q: string): boolean {
71
+ return watcher.label.toLowerCase().includes(q)
72
+ || watcher.state.toLowerCase().includes(q)
73
+ || String(watcher.sourceStatus ?? '').toLowerCase().includes(q);
74
+ }
75
+
66
76
  public override onDestroy(): void {
67
77
  this.unsub?.();
68
78
  }
@@ -144,7 +154,7 @@ export class WatchersPanel extends ScrollableListPanel<WatcherEntry> {
144
154
  this.clampSelection();
145
155
  const selected = watchers[this.selectedIndex]!;
146
156
 
147
- const footerLines: Line[] = [
157
+ const detailRows: Line[] = [
148
158
  buildPanelLine(width, [
149
159
  [' Watcher: ', C.label],
150
160
  [selected.label, C.value],
@@ -171,23 +181,33 @@ export class WatchersPanel extends ScrollableListPanel<WatcherEntry> {
171
181
  ]),
172
182
  ];
173
183
  if (selected.degradedReason) {
174
- footerLines.push(buildPanelLine(width, [
184
+ detailRows.push(buildPanelLine(width, [
175
185
  [' Reason: ', C.label],
176
186
  [truncateDisplay(selected.degradedReason, Math.max(0, width - 11)), C.warn],
177
187
  ]));
178
188
  }
179
189
  if (selected.lastError) {
180
- footerLines.push(buildPanelLine(width, [
190
+ detailRows.push(buildPanelLine(width, [
181
191
  [' Error: ', C.label],
182
192
  [truncateDisplay(selected.lastError, Math.max(0, width - 10)), C.error],
183
193
  ]));
184
194
  }
185
- footerLines.push(buildPanelLine(width, [[' Up/Down move through watchers', C.dim]]));
195
+
196
+ const hints = this.filterActive
197
+ ? [{ keys: 'type', label: 'filter' }, { keys: 'Enter', label: 'apply' }, { keys: 'Esc', label: 'clear' }]
198
+ : [
199
+ { keys: 'Up/Down', label: 'move' },
200
+ { keys: '/schedule list', label: 'consumers' },
201
+ { keys: '/', label: 'filter' },
202
+ ];
186
203
 
187
204
  return this.renderList(width, height, {
188
205
  title: 'Watchers',
189
206
  header: headerLines,
190
- footer: footerLines,
207
+ footer: [
208
+ ...buildDetailBlock(width, `Watcher · ${selected.label}`, detailRows, C),
209
+ buildKeyboardHints(width, hints, C),
210
+ ],
191
211
  });
192
212
  }
193
213
  }
@@ -1,9 +1,33 @@
1
1
  import type { Line } from '../types/grid.ts';
2
- import { UIFactory } from '../renderer/ui-factory.ts';
2
+ import { truncateDisplay } from '../utils/terminal-width.ts';
3
3
  import { ScrollableListPanel } from './scrollable-list-panel.ts';
4
+ import {
5
+ buildDetailBlock,
6
+ buildGuidanceLine,
7
+ buildKeyboardHints,
8
+ buildKeyValueLine,
9
+ buildMeterLine,
10
+ buildPanelLine,
11
+ buildPanelListRow,
12
+ buildSummaryBlock,
13
+ DEFAULT_PANEL_PALETTE,
14
+ extendPalette,
15
+ type PanelPalette,
16
+ } from './polish.ts';
4
17
  import type { WorkPlanItem, WorkPlanItemStatus, WorkPlanStore } from '../work-plans/work-plan-store.ts';
5
18
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
6
19
 
20
+ const C = extendPalette(DEFAULT_PANEL_PALETTE, {
21
+ pending: '#94a3b8',
22
+ inProgress: '#38bdf8',
23
+ blocked: '#f59e0b',
24
+ done: '#22c55e',
25
+ failed: '#ef4444',
26
+ cancelled: '#64748b',
27
+ accent: '#a5b4fc',
28
+ selectBg: '#1e293b',
29
+ });
30
+
7
31
  const STATUS_LABEL: Record<WorkPlanItemStatus, string> = {
8
32
  pending: '[ ]',
9
33
  in_progress: '[>]',
@@ -14,17 +38,15 @@ const STATUS_LABEL: Record<WorkPlanItemStatus, string> = {
14
38
  };
15
39
 
16
40
  const STATUS_COLOR: Record<WorkPlanItemStatus, string> = {
17
- pending: '#94a3b8',
18
- in_progress: '#38bdf8',
19
- blocked: '#f59e0b',
20
- done: '#22c55e',
21
- failed: '#ef4444',
22
- cancelled: '#64748b',
41
+ pending: C.pending,
42
+ in_progress: C.inProgress,
43
+ blocked: C.blocked,
44
+ done: C.done,
45
+ failed: C.failed,
46
+ cancelled: C.cancelled,
23
47
  };
24
48
 
25
- function line(text: string, width: number, style: Parameters<typeof UIFactory.stringToLine>[2] = {}): Line {
26
- return UIFactory.stringToLine(text.padEnd(width).slice(0, width), width, style);
27
- }
49
+ const STATUS_ORDER: WorkPlanItemStatus[] = ['pending', 'in_progress', 'blocked', 'done', 'failed', 'cancelled'];
28
50
 
29
51
  function compactDate(value: number): string {
30
52
  return new Date(value).toISOString().replace('T', ' ').slice(0, 16);
@@ -48,13 +70,16 @@ export class WorkPlanPanel extends ScrollableListPanel<WorkPlanItem> {
48
70
  this.refresh();
49
71
  }
50
72
 
73
+ protected override getPalette(): PanelPalette {
74
+ return C;
75
+ }
76
+
51
77
  render(width: number, height: number): Line[] {
52
78
  this.refresh();
53
79
  return this.renderList(width, height, {
54
80
  title: 'Work Plan',
55
81
  header: this.renderHeader(width),
56
82
  footer: this.renderFooter(width),
57
- emptyMessage: 'No work plan items yet',
58
83
  });
59
84
  }
60
85
 
@@ -108,10 +133,14 @@ export class WorkPlanPanel extends ScrollableListPanel<WorkPlanItem> {
108
133
  return this.items;
109
134
  }
110
135
 
136
+ protected override getEmptyStateMessage(): string {
137
+ return ' No work plan items yet';
138
+ }
139
+
111
140
  protected getEmptyStateActions(): Array<{ command: string; summary: string }> {
112
141
  return [
113
- { command: '/work-plan add <title>', summary: 'add a persistent item' },
114
- { command: '/work-plan list', summary: 'print the current plan' },
142
+ { command: '/work-plan add <title>', summary: 'add a persistent item that survives across sessions' },
143
+ { command: '/work-plan list', summary: 'print the current plan to the shell' },
115
144
  ];
116
145
  }
117
146
 
@@ -119,12 +148,14 @@ export class WorkPlanPanel extends ScrollableListPanel<WorkPlanItem> {
119
148
  const status = STATUS_LABEL[item.status];
120
149
  const owner = item.owner ? ` @${item.owner}` : '';
121
150
  const source = item.source ? ` (${item.source})` : '';
122
- const text = `${status} ${item.title}${owner}${source}`;
123
- return line(text, width, {
124
- fg: selected ? '#e2e8f0' : STATUS_COLOR[item.status],
125
- bg: selected ? '#1e293b' : undefined,
126
- bold: selected || item.status === 'in_progress',
127
- });
151
+ const reserve = status.length + owner.length + source.length + 4;
152
+ const title = truncateDisplay(item.title, Math.max(6, width - reserve));
153
+ return buildPanelListRow(width, [
154
+ { text: `${status} `, fg: STATUS_COLOR[item.status], bold: item.status === 'in_progress' },
155
+ { text: title, fg: selected ? C.value : C.label, bold: selected || item.status === 'in_progress' },
156
+ { text: owner, fg: C.accent },
157
+ { text: source, fg: C.dim },
158
+ ], C, { selected, selectedBg: C.selectBg });
128
159
  }
129
160
 
130
161
  private setSelectedStatus(status: WorkPlanItemStatus): boolean {
@@ -147,30 +178,90 @@ export class WorkPlanPanel extends ScrollableListPanel<WorkPlanItem> {
147
178
  private renderHeader(width: number): Line[] {
148
179
  const plan = this.store.getActivePlan();
149
180
  const counts = new Map<WorkPlanItemStatus, number>();
150
- for (const status of Object.keys(STATUS_LABEL) as WorkPlanItemStatus[]) counts.set(status, 0);
181
+ for (const status of STATUS_ORDER) counts.set(status, 0);
151
182
  for (const item of plan.items) counts.set(item.status, (counts.get(item.status) ?? 0) + 1);
152
- const active = this.items[this.selectedIndex];
153
- const header = [
154
- line(`Persistent Work Plan`, width, { fg: '#22d3ee', bold: true }),
155
- line(`Project: ${plan.projectRoot}`, width, { fg: '#cbd5e1' }),
156
- line(
157
- `Items: ${plan.items.length} pending ${counts.get('pending') ?? 0} active ${counts.get('in_progress') ?? 0} blocked ${counts.get('blocked') ?? 0} done ${counts.get('done') ?? 0}`,
158
- width,
159
- { fg: '#94a3b8' },
160
- ),
161
- line(`Saved: ${this.store.filePath}`, width, { fg: '#64748b' }),
183
+
184
+ const total = plan.items.length;
185
+ const done = (counts.get('done') ?? 0) + (counts.get('cancelled') ?? 0);
186
+ const pct = total > 0 ? Math.round((done / total) * 100) : 0;
187
+ const meterWidth = Math.max(10, Math.min(26, width - 34));
188
+
189
+ const postureLines: Line[] = [
190
+ buildPanelLine(width, [
191
+ [' Project ', C.label],
192
+ [truncateDisplay(plan.projectRoot, Math.max(8, width - 11)), C.value],
193
+ ]),
194
+ buildMeterLine(width, Math.round((pct / 100) * meterWidth), meterWidth, {
195
+ filled: pct === 100 ? C.done : C.inProgress,
196
+ empty: C.empty,
197
+ label: C.label,
198
+ }, { prefix: ' Progress ', suffix: ` ${done}/${total} (${pct}%) ` }),
199
+ buildKeyValueLine(width, [
200
+ { label: 'pending', value: String(counts.get('pending') ?? 0), valueColor: (counts.get('pending') ?? 0) > 0 ? C.pending : C.dim },
201
+ { label: 'active', value: String(counts.get('in_progress') ?? 0), valueColor: (counts.get('in_progress') ?? 0) > 0 ? C.inProgress : C.dim },
202
+ { label: 'blocked', value: String(counts.get('blocked') ?? 0), valueColor: (counts.get('blocked') ?? 0) > 0 ? C.blocked : C.dim },
203
+ { label: 'done', value: String(counts.get('done') ?? 0), valueColor: (counts.get('done') ?? 0) > 0 ? C.done : C.dim },
204
+ ], C),
205
+ buildGuidanceLine(width, '/work-plan add <title>', 'append a persistent item to the active plan', C),
162
206
  ];
207
+
208
+ const header: Line[] = buildSummaryBlock(width, 'Persistent Work Plan', postureLines, C);
209
+
210
+ const active = this.items[this.selectedIndex];
163
211
  if (active) {
164
- header.push(line('', width));
165
- header.push(line(`Selected: ${active.id} ${statusName(active.status)} updated ${compactDate(active.updatedAt)}`, width, { fg: '#a5b4fc' }));
166
- if (active.notes) header.push(line(`Notes: ${active.notes}`, width, { fg: '#cbd5e1' }));
212
+ const detailRows: Line[] = [
213
+ buildPanelLine(width, [
214
+ [' ', C.label],
215
+ [STATUS_LABEL[active.status], STATUS_COLOR[active.status]],
216
+ [' ', C.label],
217
+ [truncateDisplay(active.title, Math.max(8, width - 6)), C.value],
218
+ ]),
219
+ buildKeyValueLine(width, [
220
+ { label: 'status', value: statusName(active.status), valueColor: STATUS_COLOR[active.status] },
221
+ ...(active.owner ? [{ label: 'owner', value: active.owner, valueColor: C.accent }] : []),
222
+ { label: 'updated', value: compactDate(active.updatedAt), valueColor: C.dim },
223
+ ], C),
224
+ ];
225
+ if (active.source) {
226
+ detailRows.push(buildPanelLine(width, [[' source ', C.label], [active.source, C.info]]));
227
+ }
228
+ if (active.notes) {
229
+ detailRows.push(buildPanelLine(width, [
230
+ [' notes ', C.label],
231
+ [truncateDisplay(active.notes, Math.max(8, width - 8)), C.value],
232
+ ]));
233
+ }
234
+ header.push(...buildDetailBlock(width, 'Selected item', detailRows, C));
167
235
  }
168
236
  return header;
169
237
  }
170
238
 
171
239
  private renderFooter(width: number): Line[] {
240
+ const hasItem = this.items.length > 0;
241
+ if (!hasItem) {
242
+ return [
243
+ buildKeyboardHints(width, [
244
+ { keys: '↑/↓', label: 'navigate' },
245
+ ], C),
246
+ ];
247
+ }
172
248
  return [
173
- line('Enter/Space cycle 1 pending 2 active 3 blocked 4 done 5 failed 6 cancelled d delete c clear done r refresh', width, { fg: '#94a3b8' }),
249
+ buildKeyboardHints(width, [
250
+ { keys: this.items.length > 0 ? `${this.selectedIndex + 1}/${this.items.length}` : '0/0', label: 'item' },
251
+ { keys: 'Enter', label: 'cycle status' },
252
+ { keys: '1-6', label: 'set status' },
253
+ { keys: 'd', label: 'delete' },
254
+ { keys: 'c', label: 'clear done' },
255
+ { keys: 'r', label: 'refresh' },
256
+ ], C),
257
+ buildPanelLine(width, [
258
+ [' 1', C.info], [' pending ', C.dim],
259
+ ['2', C.info], [' active ', C.dim],
260
+ ['3', C.info], [' blocked ', C.dim],
261
+ ['4', C.info], [' done ', C.dim],
262
+ ['5', C.info], [' failed ', C.dim],
263
+ ['6', C.info], [' cancelled', C.dim],
264
+ ]),
174
265
  ];
175
266
  }
176
267
  }