@pellux/goodvibes-tui 0.18.19 → 0.18.23

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 (42) hide show
  1. package/CHANGELOG.md +170 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/core/conversation-rendering.ts +20 -6
  5. package/src/input/commands/session.ts +0 -1
  6. package/src/input/feed-context-factory.ts +236 -0
  7. package/src/input/handler-feed-routes.ts +10 -0
  8. package/src/input/handler-feed.ts +44 -6
  9. package/src/input/handler-shortcuts.ts +138 -125
  10. package/src/input/handler.ts +121 -119
  11. package/src/input/keybindings.ts +30 -0
  12. package/src/panels/approval-panel.ts +54 -74
  13. package/src/panels/automation-control-panel.ts +119 -161
  14. package/src/panels/base-panel.ts +71 -0
  15. package/src/panels/communication-panel.ts +68 -107
  16. package/src/panels/confirm-state.ts +61 -0
  17. package/src/panels/control-plane-panel.ts +116 -172
  18. package/src/panels/git-panel.ts +9 -0
  19. package/src/panels/hooks-panel.ts +101 -138
  20. package/src/panels/incident-review-panel.ts +55 -107
  21. package/src/panels/knowledge-panel.ts +63 -14
  22. package/src/panels/local-auth-panel.ts +76 -93
  23. package/src/panels/marketplace-panel.ts +19 -12
  24. package/src/panels/mcp-panel.ts +108 -155
  25. package/src/panels/ops-control-panel.ts +50 -85
  26. package/src/panels/panel-manager.ts +22 -2
  27. package/src/panels/plugins-panel.ts +36 -60
  28. package/src/panels/routes-panel.ts +89 -141
  29. package/src/panels/scrollable-list-panel.ts +71 -16
  30. package/src/panels/security-panel.ts +101 -137
  31. package/src/panels/services-panel.ts +58 -102
  32. package/src/panels/settings-sync-panel.ts +76 -122
  33. package/src/panels/skills-panel.ts +44 -0
  34. package/src/panels/subscription-panel.ts +69 -80
  35. package/src/panels/tasks-panel.ts +129 -179
  36. package/src/panels/watchers-panel.ts +88 -137
  37. package/src/renderer/buffer.ts +11 -0
  38. package/src/renderer/diff.ts +8 -0
  39. package/src/renderer/help-overlay.ts +37 -28
  40. package/src/renderer/markdown.ts +3 -145
  41. package/src/renderer/status-token.ts +71 -0
  42. package/src/version.ts +1 -1
@@ -1,14 +1,10 @@
1
1
  import type { Line } from '../types/grid.ts';
2
- import { createEmptyLine } from '../types/grid.ts';
3
- import { BasePanel } from './base-panel.ts';
2
+ import { ScrollableListPanel } from './scrollable-list-panel.ts';
4
3
  import {
5
4
  buildGuidanceLine,
6
5
  buildKeyValueLine,
7
6
  buildPanelLine,
8
- buildPanelWorkspace,
9
7
  DEFAULT_PANEL_PALETTE,
10
- resolvePrimaryScrollableSection,
11
- type PanelWorkspaceSection,
12
8
  } from './polish.ts';
13
9
  import type { PolicyRuntimeState } from '@pellux/goodvibes-sdk/platform/runtime/permissions/policy-runtime';
14
10
  import { buildPermissionRuleSuggestions } from '@pellux/goodvibes-sdk/platform/runtime/permissions/rule-suggestions';
@@ -30,9 +26,9 @@ const APPROVAL_ROWS = [
30
26
  ['sandbox', 'why prompted: WSL/VM isolation changes alter host risk posture', 'review via /sandbox preset and /sandbox review'],
31
27
  ] as const;
32
28
 
33
- export class ApprovalPanel extends BasePanel {
34
- private selectedIndex = 0;
35
- private scrollOffset = 0;
29
+ type ApprovalRow = (typeof APPROVAL_ROWS)[number];
30
+
31
+ export class ApprovalPanel extends ScrollableListPanel<ApprovalRow> {
36
32
  private readonly policyRuntimeState: Pick<PolicyRuntimeState, 'getSnapshot'>;
37
33
 
38
34
  public constructor(policyRuntimeState: Pick<PolicyRuntimeState, 'getSnapshot'>) {
@@ -40,17 +36,23 @@ export class ApprovalPanel extends BasePanel {
40
36
  this.policyRuntimeState = policyRuntimeState;
41
37
  }
42
38
 
39
+ protected override getPalette() { return C; }
40
+ protected override getEmptyStateMessage() { return ' No approval lanes defined.'; }
41
+
42
+ protected getItems(): readonly ApprovalRow[] {
43
+ return APPROVAL_ROWS;
44
+ }
45
+
46
+ protected renderItem(row: ApprovalRow, index: number, selected: boolean, width: number): Line {
47
+ const bg = selected ? C.selectBg : undefined;
48
+ return buildPanelLine(width, [
49
+ [' ', C.label],
50
+ [row[0].padEnd(10), C.info, bg],
51
+ [row[1].slice(0, Math.max(0, width - 18)), C.value, bg],
52
+ ]);
53
+ }
54
+
43
55
  public handleInput(key: string): boolean {
44
- if (key === 'up' || key === 'k') {
45
- this.selectedIndex = Math.max(0, this.selectedIndex - 1);
46
- this.markDirty();
47
- return true;
48
- }
49
- if (key === 'down' || key === 'j') {
50
- this.selectedIndex = Math.min(APPROVAL_ROWS.length - 1, this.selectedIndex + 1);
51
- this.markDirty();
52
- return true;
53
- }
54
56
  if (key === 'home') {
55
57
  this.selectedIndex = 0;
56
58
  this.markDirty();
@@ -64,7 +66,7 @@ export class ApprovalPanel extends BasePanel {
64
66
  if (key === 'enter' || key === 'return') {
65
67
  return true;
66
68
  }
67
- return false;
69
+ return super.handleInput(key);
68
70
  }
69
71
 
70
72
  public getSelectedCommand(): string | null {
@@ -73,25 +75,16 @@ export class ApprovalPanel extends BasePanel {
73
75
  }
74
76
 
75
77
  public render(width: number, height: number): Line[] {
76
- this.needsRender = false;
78
+ this.clampSelection();
77
79
  const policySnapshot = this.policyRuntimeState.getSnapshot();
78
- const postureLines = [
79
- buildKeyValueLine(width, [
80
- { label: 'why prompted', value: 'risk summary', valueColor: C.value },
81
- { label: 'what-if', value: '/policy simulate + preflight', valueColor: C.info },
82
- { label: 'operator', value: '/security + /cockpit', valueColor: C.good },
83
- ], C),
84
- buildKeyValueLine(width, [
85
- { label: 'recent approvals', value: String(policySnapshot.recentPermissionAudit.filter((entry) => entry.approved === true).length), valueColor: C.good },
86
- { label: 'recent denials', value: String(policySnapshot.recentPermissionAudit.filter((entry) => entry.approved === false).length), valueColor: C.bad },
87
- { label: 'pending', value: String(policySnapshot.recentPermissionAudit.filter((entry) => entry.approved === undefined).length), valueColor: C.info },
88
- ], C),
89
- buildGuidanceLine(width, '/approval review shell', 'inspect the highest-risk approval lane and refine scoped review posture', C),
90
- ];
91
- const footerLines = [buildPanelLine(width, [[` Up/Down move Home/End jump selected lane opens the next command path`, C.dim]])];
80
+ const approvalCount = policySnapshot.recentPermissionAudit.filter((e) => e.approved === true).length;
81
+ const denialCount = policySnapshot.recentPermissionAudit.filter((e) => e.approved === false).length;
82
+ const pendingCount = policySnapshot.recentPermissionAudit.filter((e) => e.approved === undefined).length;
83
+
92
84
  const selected = APPROVAL_ROWS[this.selectedIndex] ?? null;
93
85
  const detailLines: Line[] = [];
94
86
  if (selected) {
87
+ detailLines.push(buildPanelLine(width, [[' Selected Lane', C.label]]));
95
88
  detailLines.push(buildKeyValueLine(width, [
96
89
  { label: 'lane', value: selected[0], valueColor: C.info },
97
90
  { label: 'next review', value: selected[2], valueColor: C.dim },
@@ -99,6 +92,7 @@ export class ApprovalPanel extends BasePanel {
99
92
  detailLines.push(buildPanelLine(width, [[` ${selected[1]}`, C.value]]));
100
93
  detailLines.push(buildGuidanceLine(width, selected[2].replace('review via ', ''), `open the ${selected[0]} review path`, C));
101
94
  }
95
+
102
96
  const recentAuditLines: Line[] = [];
103
97
  for (const entry of policySnapshot.recentPermissionAudit.slice(0, 5)) {
104
98
  const decision = entry.approved === undefined ? 'pending' : entry.approved ? 'approved' : 'denied';
@@ -115,6 +109,7 @@ export class ApprovalPanel extends BasePanel {
115
109
  if (recentAuditLines.length === 0) {
116
110
  recentAuditLines.push(buildPanelLine(width, [[` No recent approval pressure. Live requests and decisions will appear here.`, C.dim]]));
117
111
  }
112
+
118
113
  const ruleSuggestionLines: Line[] = [];
119
114
  for (const suggestion of buildPermissionRuleSuggestions(policySnapshot.recentPermissionAudit).slice(0, 3)) {
120
115
  ruleSuggestionLines.push(buildPanelLine(width, [[` ${suggestion.summary}`, C.info]]));
@@ -123,47 +118,32 @@ export class ApprovalPanel extends BasePanel {
123
118
  if (ruleSuggestionLines.length === 0) {
124
119
  ruleSuggestionLines.push(buildPanelLine(width, [[` No repeated denials currently suggest a durable rule.`, C.dim]]));
125
120
  }
126
- const postureSection: PanelWorkspaceSection = { title: 'Approval posture', lines: postureLines };
127
- const selectedSection: PanelWorkspaceSection = { title: 'Selected Lane', lines: detailLines };
128
- const pressureSection: PanelWorkspaceSection = { title: 'Recent Pressure', lines: recentAuditLines };
129
- const rulesSection: PanelWorkspaceSection = { title: 'Rule Suggestions', lines: ruleSuggestionLines };
130
- const resolvedLanesSection = resolvePrimaryScrollableSection(width, height, {
131
- intro: 'Action-specific review lanes for approvals, denials, escalations, and preflight guidance.',
132
- footerLines,
133
- palette: C,
134
- beforeSections: [postureSection, selectedSection, pressureSection, rulesSection],
135
- section: {
136
- title: 'Review Lanes',
137
- scrollableLines: APPROVAL_ROWS.map((row, absolute) => {
138
- const bg = absolute === this.selectedIndex ? C.selectBg : undefined;
139
- return buildPanelLine(width, [
140
- [' ', C.label],
141
- [row[0].padEnd(10), C.info, bg],
142
- [row[1].slice(0, Math.max(0, width - 18)), C.value, bg],
143
- ]);
144
- }),
145
- selectedIndex: this.selectedIndex,
146
- scrollOffset: this.scrollOffset,
147
- guardRows: 1,
148
- minRows: 4,
149
- appendWindowSummary: { dimColor: C.dim },
150
- },
151
- });
152
- this.scrollOffset = resolvedLanesSection.scrollOffset;
153
- const lines = buildPanelWorkspace(width, height, {
121
+
122
+ const headerLines: Line[] = [
123
+ buildPanelLine(width, [[' Approval posture', C.label]]),
124
+ buildKeyValueLine(width, [
125
+ { label: 'why prompted', value: 'risk summary', valueColor: C.value },
126
+ { label: 'what-if', value: '/policy simulate + preflight', valueColor: C.info },
127
+ { label: 'operator', value: '/security + /cockpit', valueColor: C.good },
128
+ ], C),
129
+ buildPanelLine(width, [
130
+ [' \u2713 ', C.good],
131
+ [`approvals (${approvalCount}) `, C.good],
132
+ ['\u2715 ', C.bad],
133
+ [`denials (${denialCount}) `, C.bad],
134
+ ['\u25cb ', C.info],
135
+ [`pending (${pendingCount})`, C.info],
136
+ ]),
137
+ buildGuidanceLine(width, '/approval review shell', 'inspect the highest-risk approval lane and refine scoped review posture', C),
138
+ ...detailLines,
139
+ ...recentAuditLines,
140
+ ...ruleSuggestionLines,
141
+ ];
142
+
143
+ return this.renderList(width, height, {
154
144
  title: 'Approval Control Room',
155
- intro: 'Action-specific review lanes for approvals, denials, escalations, and preflight guidance.',
156
- sections: [
157
- postureSection,
158
- selectedSection,
159
- pressureSection,
160
- rulesSection,
161
- resolvedLanesSection.section,
162
- ],
163
- footerLines,
164
- palette: C,
145
+ header: headerLines,
146
+ footer: [buildPanelLine(width, [[` Up/Down move Home/End jump selected lane opens the next command path`, C.dim]])],
165
147
  });
166
- while (lines.length < height) lines.push(createEmptyLine(width));
167
- return lines.slice(0, height);
168
148
  }
169
149
  }
@@ -1,6 +1,6 @@
1
1
  import type { Line } from '../types/grid.ts';
2
2
  import { createEmptyLine } from '../types/grid.ts';
3
- import { BasePanel } from './base-panel.ts';
3
+ import { ScrollableListPanel } from './scrollable-list-panel.ts';
4
4
  import type { UiAutomationSnapshot, UiReadModel } from '../runtime/ui-read-models.ts';
5
5
  import { truncateDisplay } from '../utils/terminal-width.ts';
6
6
  import {
@@ -10,8 +10,7 @@ import {
10
10
  buildPanelLine,
11
11
  buildPanelWorkspace,
12
12
  DEFAULT_PANEL_PALETTE,
13
- resolvePrimaryScrollableSection,
14
- type PanelWorkspaceSection,
13
+ type PanelPalette,
15
14
  } from './polish.ts';
16
15
 
17
16
  const C = {
@@ -37,11 +36,12 @@ function runStatusColor(status: string): string {
37
36
  return C.info;
38
37
  }
39
38
 
40
- export class AutomationControlPanel extends BasePanel {
39
+ type AutomationRun = UiAutomationSnapshot['runs'][number];
40
+ type AutomationJob = UiAutomationSnapshot['jobs'][number];
41
+
42
+ export class AutomationControlPanel extends ScrollableListPanel<AutomationRun> {
41
43
  private readonly readModel?: UiReadModel<UiAutomationSnapshot>;
42
44
  private readonly unsub: (() => void) | null;
43
- private selectedIndex = 0;
44
- private scrollOffset = 0;
45
45
 
46
46
  public constructor(readModel?: UiReadModel<UiAutomationSnapshot>) {
47
47
  super('automation', 'Automation', 'M', 'monitoring');
@@ -53,29 +53,45 @@ export class AutomationControlPanel extends BasePanel {
53
53
  this.unsub?.();
54
54
  }
55
55
 
56
- public handleInput(key: string): boolean {
57
- const runs = this.runs();
58
- if (runs.length === 0) return false;
59
- if (key === 'up' || key === 'k') {
60
- this.selectedIndex = Math.max(0, this.selectedIndex - 1);
61
- this.markDirty();
62
- return true;
63
- }
64
- if (key === 'down' || key === 'j') {
65
- this.selectedIndex = Math.min(runs.length - 1, this.selectedIndex + 1);
66
- this.markDirty();
67
- return true;
68
- }
69
- return false;
56
+ protected override getPalette(): PanelPalette {
57
+ return C;
58
+ }
59
+
60
+ private getJobs(): readonly AutomationJob[] {
61
+ if (!this.readModel) return [];
62
+ return this.readModel.getSnapshot().jobs;
70
63
  }
71
64
 
72
- private runs() {
65
+ protected getItems(): readonly AutomationRun[] {
73
66
  if (!this.readModel) return [];
74
- return [...this.readModel.getSnapshot().runs];
67
+ return this.readModel.getSnapshot().runs;
68
+ }
69
+
70
+ protected renderItem(run: AutomationRun, _index: number, selected: boolean, width: number): Line {
71
+ const bg = selected ? C.selectBg : undefined;
72
+ const jobs = this.getJobs();
73
+ const name = jobs.find((job) => job.id === run.jobId)?.name ?? run.jobId;
74
+ return buildPanelLine(width, [
75
+ [' ', C.label, bg],
76
+ [run.status.padEnd(11), runStatusColor(run.status), bg],
77
+ [` ${truncateDisplay(name, 22).padEnd(22)}`, C.value, bg],
78
+ [` ${truncateDisplay(run.target.kind, 12).padEnd(12)}`, C.info, bg],
79
+ [` ${truncateDisplay(formatTime(run.queuedAt), Math.max(0, width - 49))}`, C.dim, bg],
80
+ ]);
81
+ }
82
+
83
+ protected override getEmptyStateMessage(): string {
84
+ return ' No automation activity recorded.';
85
+ }
86
+
87
+ protected override getEmptyStateActions(): Array<{ command: string; summary: string }> {
88
+ return [
89
+ { command: '/schedule add cron 0 * * * * repo sweep', summary: 'create a recurring automation job' },
90
+ { command: '/schedule list', summary: 'inspect jobs and run history from the shell' },
91
+ ];
75
92
  }
76
93
 
77
94
  public render(width: number, height: number): Line[] {
78
- this.needsRender = false;
79
95
  const intro = 'Automation jobs, active runs, deliveries, and failure posture across the shared control plane.';
80
96
 
81
97
  if (!this.readModel) {
@@ -99,155 +115,97 @@ export class AutomationControlPanel extends BasePanel {
99
115
 
100
116
  const snapshot = this.readModel.getSnapshot();
101
117
  const jobs = [...snapshot.jobs];
102
- const runs = this.runs();
103
-
104
- const summarySection: PanelWorkspaceSection = {
105
- title: 'Posture',
106
- lines: [
107
- buildKeyValueLine(width, [
108
- { label: 'jobs', value: String(snapshot.totalJobs), valueColor: snapshot.totalJobs > 0 ? C.info : C.dim },
109
- { label: 'runs', value: String(snapshot.totalRuns), valueColor: snapshot.totalRuns > 0 ? C.value : C.dim },
110
- { label: 'active', value: String(snapshot.activeRunIds.length), valueColor: snapshot.activeRunIds.length > 0 ? C.warn : C.dim },
111
- { label: 'failed', value: String(snapshot.totalFailed), valueColor: snapshot.totalFailed > 0 ? C.error : C.dim },
112
- ], C),
113
- buildKeyValueLine(width, [
114
- { label: 'deliveries ok', value: String(snapshot.deliveryTotals.succeeded), valueColor: snapshot.deliveryTotals.succeeded > 0 ? C.ok : C.dim },
115
- { label: 'delivery fail', value: String(snapshot.deliveryTotals.failed), valueColor: snapshot.deliveryTotals.failed > 0 ? C.error : C.dim },
116
- { label: 'dead letters', value: String(snapshot.deliveryTotals.deadLettered), valueColor: snapshot.deliveryTotals.deadLettered > 0 ? C.warn : C.dim },
117
- { label: 'sources', value: String(snapshot.sourceCount), valueColor: snapshot.sourceCount > 0 ? C.info : C.dim },
118
- ], C),
119
- buildGuidanceLine(width, '/schedule list', 'manage jobs and use the web or surface controls for retries, delivery, and cross-surface sessions', C),
120
- ],
121
- };
118
+ const runs = this.getItems();
119
+
120
+ const headerLines: Line[] = [
121
+ buildKeyValueLine(width, [
122
+ { label: 'jobs', value: String(snapshot.totalJobs), valueColor: snapshot.totalJobs > 0 ? C.info : C.dim },
123
+ { label: 'runs', value: String(snapshot.totalRuns), valueColor: snapshot.totalRuns > 0 ? C.value : C.dim },
124
+ { label: 'active', value: String(snapshot.activeRunIds.length), valueColor: snapshot.activeRunIds.length > 0 ? C.warn : C.dim },
125
+ { label: 'failed', value: String(snapshot.totalFailed), valueColor: snapshot.totalFailed > 0 ? C.error : C.dim },
126
+ ], C),
127
+ buildKeyValueLine(width, [
128
+ { label: 'deliveries ok', value: String(snapshot.deliveryTotals.succeeded), valueColor: snapshot.deliveryTotals.succeeded > 0 ? C.ok : C.dim },
129
+ { label: 'delivery fail', value: String(snapshot.deliveryTotals.failed), valueColor: snapshot.deliveryTotals.failed > 0 ? C.error : C.dim },
130
+ { label: 'dead letters', value: String(snapshot.deliveryTotals.deadLettered), valueColor: snapshot.deliveryTotals.deadLettered > 0 ? C.warn : C.dim },
131
+ { label: 'sources', value: String(snapshot.sourceCount), valueColor: snapshot.sourceCount > 0 ? C.info : C.dim },
132
+ ], C),
133
+ buildGuidanceLine(width, '/schedule list', 'manage jobs and use the web or surface controls for retries, delivery, and cross-surface sessions', C),
134
+ ];
122
135
 
123
136
  if (jobs.length === 0 && runs.length === 0) {
124
- const workspace = buildPanelWorkspace(width, height, {
137
+ return this.renderList(width, height, {
125
138
  title: 'Automation Control',
126
- intro,
127
- sections: [
128
- summarySection,
129
- {
130
- lines: buildEmptyState(
131
- width,
132
- ' No automation activity recorded.',
133
- 'Create a job, run one manually, or let a watcher/surface trigger automation to populate this control room.',
134
- [
135
- { command: '/schedule add cron 0 * * * * repo sweep', summary: 'create a recurring automation job' },
136
- { command: '/schedule list', summary: 'inspect jobs and run history from the shell' },
137
- ],
138
- C,
139
- ),
140
- },
141
- ],
142
- palette: C,
139
+ header: headerLines,
140
+ emptyMessage: ' No automation activity recorded.',
143
141
  });
144
- while (workspace.length < height) workspace.push(createEmptyLine(width));
145
- return workspace;
146
142
  }
147
143
 
148
- this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, runs.length - 1));
144
+ this.clampSelection();
149
145
  const selectedRun = runs[this.selectedIndex];
150
146
  const jobName = selectedRun ? (jobs.find((job) => job.id === selectedRun.jobId)?.name ?? selectedRun.jobId) : 'n/a';
151
147
 
152
- const jobSection: PanelWorkspaceSection = {
153
- title: 'Jobs',
154
- lines: jobs.slice(0, 6).map((job) => buildPanelLine(width, [
155
- [' ', C.label],
156
- [job.enabled ? 'ENABLED ' : 'PAUSED ', job.enabled ? C.ok : C.warn],
157
- [truncateDisplay(job.name, 24).padEnd(24), C.value],
158
- [` next ${truncateDisplay(formatTime(job.nextRunAt), Math.max(0, width - 43))}`, C.dim],
159
- ])),
160
- };
161
-
162
- const detailSection: PanelWorkspaceSection = selectedRun
163
- ? {
164
- title: 'Selected Run',
165
- lines: [
166
- buildPanelLine(width, [
167
- [' Run: ', C.label],
168
- [selectedRun.id, C.value],
169
- [' Status: ', C.label],
170
- [selectedRun.status, runStatusColor(selectedRun.status)],
171
- ]),
172
- buildPanelLine(width, [
173
- [' Job: ', C.label],
174
- [jobName, C.value],
175
- [' Agent: ', C.label],
176
- [selectedRun.agentId ?? 'n/a', C.info],
177
- ]),
178
- buildPanelLine(width, [
179
- [' Queue: ', C.label],
180
- [formatTime(selectedRun.queuedAt), C.dim],
181
- [' End: ', C.label],
182
- [formatTime(selectedRun.endedAt), C.dim],
183
- ]),
184
- buildPanelLine(width, [
185
- [' Trigger: ', C.label],
186
- [selectedRun.triggeredBy.kind, C.info],
187
- [' Target: ', C.label],
188
- [selectedRun.target.kind, C.value],
189
- ]),
190
- buildPanelLine(width, [
191
- [' Deliveries: ', C.label],
192
- [String(selectedRun.deliveryIds.length), selectedRun.deliveryIds.length > 0 ? C.info : C.dim],
193
- [' Route: ', C.label],
194
- [selectedRun.routeId ?? 'n/a', C.dim],
195
- ]),
196
- ...(selectedRun.error ? [
197
- buildPanelLine(width, [
198
- [' Error: ', C.label],
199
- [truncateDisplay(selectedRun.error, Math.max(0, width - 10)), C.error],
200
- ]),
201
- ] : []),
202
- ],
203
- }
204
- : {
205
- title: 'Selected Run',
206
- lines: [buildPanelLine(width, [[' No run selected.', C.dim]])],
207
- };
208
-
209
- const resolvedRuns = resolvePrimaryScrollableSection(width, height, {
210
- intro,
211
- footerLines: [buildPanelLine(width, [[' Up/Down move through runs', C.dim]])],
212
- palette: C,
213
- beforeSections: [summarySection],
214
- section: {
215
- title: 'Recent Runs',
216
- scrollableLines: runs.map((run, absolute) => {
217
- const bg = absolute === this.selectedIndex ? C.selectBg : undefined;
218
- const name = jobs.find((job) => job.id === run.jobId)?.name ?? run.jobId;
219
- return buildPanelLine(width, [
220
- [' ', C.label, bg],
221
- [run.status.padEnd(11), runStatusColor(run.status), bg],
222
- [` ${truncateDisplay(name, 22).padEnd(22)}`, C.value, bg],
223
- [` ${truncateDisplay(run.target.kind, 12).padEnd(12)}`, C.info, bg],
224
- [` ${truncateDisplay(formatTime(run.queuedAt), Math.max(0, width - 49))}`, C.dim, bg],
225
- ]);
226
- }),
227
- selectedIndex: this.selectedIndex,
228
- scrollOffset: this.scrollOffset,
229
- guardRows: 1,
230
- minRows: 5,
231
- appendWindowSummary: { dimColor: C.dim },
232
- },
233
- afterSections: [detailSection, jobSection],
234
- });
235
- this.scrollOffset = resolvedRuns.scrollOffset;
148
+ const footerLines: Line[] = [];
149
+ if (selectedRun) {
150
+ footerLines.push(
151
+ buildPanelLine(width, [
152
+ [' Run: ', C.label],
153
+ [selectedRun.id, C.value],
154
+ [' Status: ', C.label],
155
+ [selectedRun.status, runStatusColor(selectedRun.status)],
156
+ ]),
157
+ buildPanelLine(width, [
158
+ [' Job: ', C.label],
159
+ [jobName, C.value],
160
+ [' Agent: ', C.label],
161
+ [selectedRun.agentId ?? 'n/a', C.info],
162
+ ]),
163
+ buildPanelLine(width, [
164
+ [' Queue: ', C.label],
165
+ [formatTime(selectedRun.queuedAt), C.dim],
166
+ [' End: ', C.label],
167
+ [formatTime(selectedRun.endedAt), C.dim],
168
+ ]),
169
+ buildPanelLine(width, [
170
+ [' Trigger: ', C.label],
171
+ [selectedRun.triggeredBy.kind, C.info],
172
+ [' Target: ', C.label],
173
+ [selectedRun.target.kind, C.value],
174
+ ]),
175
+ buildPanelLine(width, [
176
+ [' Deliveries: ', C.label],
177
+ [String(selectedRun.deliveryIds.length), selectedRun.deliveryIds.length > 0 ? C.info : C.dim],
178
+ [' Route: ', C.label],
179
+ [selectedRun.routeId ?? 'n/a', C.dim],
180
+ ]),
181
+ );
182
+ if (selectedRun.error) {
183
+ footerLines.push(buildPanelLine(width, [
184
+ [' Error: ', C.label],
185
+ [truncateDisplay(selectedRun.error, Math.max(0, width - 10)), C.error],
186
+ ]));
187
+ }
188
+ } else {
189
+ footerLines.push(buildPanelLine(width, [[' No run selected.', C.dim]]));
190
+ }
236
191
 
237
- const sections: PanelWorkspaceSection[] = [
238
- summarySection,
239
- resolvedRuns.section,
240
- detailSection,
241
- jobSection,
242
- ];
243
- const lines = buildPanelWorkspace(width, height, {
192
+ // Jobs quick view
193
+ if (jobs.length > 0) {
194
+ footerLines.push(
195
+ ...jobs.slice(0, 6).map((job) => buildPanelLine(width, [
196
+ [' ', C.label],
197
+ [job.enabled ? 'ENABLED ' : 'PAUSED ', job.enabled ? C.ok : C.warn],
198
+ [truncateDisplay(job.name, 24).padEnd(24), C.value],
199
+ [` next ${truncateDisplay(formatTime(job.nextRunAt), Math.max(0, width - 43))}`, C.dim],
200
+ ])),
201
+ );
202
+ }
203
+ footerLines.push(buildPanelLine(width, [[' Up/Down move through runs', C.dim]]));
204
+
205
+ return this.renderList(width, height, {
244
206
  title: 'Automation Control',
245
- intro,
246
- sections,
247
- footerLines: [buildPanelLine(width, [[' Up/Down move through runs', C.dim]])],
248
- palette: C,
207
+ header: headerLines,
208
+ footer: footerLines,
249
209
  });
250
- while (lines.length < height) lines.push(createEmptyLine(width));
251
- return lines.slice(0, height);
252
210
  }
253
211
  }
@@ -2,6 +2,8 @@ import type { Line } from '../types/grid.ts';
2
2
  import type { Panel, PanelCategory } from './types.ts';
3
3
  import type { ComponentResourceContract, ComponentHealthState } from '../runtime/perf/panel-contracts.ts';
4
4
  import type { ComponentHealthMonitor } from '../runtime/perf/panel-health-monitor.ts';
5
+ import { UIFactory } from '../renderer/ui-factory.ts';
6
+ import { SPINNER_FRAMES } from '../renderer/progress.ts';
5
7
 
6
8
  export abstract class BasePanel implements Panel {
7
9
  public needsRender = true;
@@ -9,6 +11,75 @@ export abstract class BasePanel implements Panel {
9
11
  public isPinned = false;
10
12
  protected readonly componentHealthMonitor?: ComponentHealthMonitor;
11
13
 
14
+ // -------------------------------------------------------------------------
15
+ // I2: Error surface slot
16
+ // -------------------------------------------------------------------------
17
+
18
+ /** Last error message to surface in the panel footer. Auto-cleared on next input. */
19
+ protected lastError: string | null = null;
20
+
21
+ /** Set a transient error message. Triggers a re-render. */
22
+ protected setError(msg: string): void {
23
+ this.lastError = msg;
24
+ this.needsRender = true;
25
+ }
26
+
27
+ /** Clear the current error. */
28
+ protected clearError(): void {
29
+ this.lastError = null;
30
+ }
31
+
32
+ /**
33
+ * Build a single error Line for display above the hints footer.
34
+ * Returns null when there is no active error.
35
+ *
36
+ * Color: bold red foreground (palette-consistent: #ef4444).
37
+ */
38
+ protected renderErrorLine(width: number): Line | null {
39
+ if (!this.lastError) return null;
40
+ return UIFactory.stringToLine(
41
+ ` ✕ ${this.lastError}`.padEnd(width).slice(0, width),
42
+ width,
43
+ { fg: '#ef4444', bold: true },
44
+ );
45
+ }
46
+
47
+ // -------------------------------------------------------------------------
48
+ // I3: Loading spinner slot
49
+ // -------------------------------------------------------------------------
50
+
51
+ /** Tracks the loading label for the spinner (undefined = no spinner active). */
52
+ protected loadingState: 'idle' | 'loading' | 'error' = 'idle';
53
+ private _loadingLabel = '';
54
+
55
+ /** Begin loading. Triggers a re-render. */
56
+ protected startLoading(label = 'Loading...'): void {
57
+ this.loadingState = 'loading';
58
+ this._loadingLabel = label;
59
+ this.needsRender = true;
60
+ }
61
+
62
+ /** End loading (returns to idle). Triggers a re-render. */
63
+ protected stopLoading(): void {
64
+ this.loadingState = 'idle';
65
+ this._loadingLabel = '';
66
+ this.needsRender = true;
67
+ }
68
+
69
+ /**
70
+ * Build a spinner Line for the loading state.
71
+ * Returns null when loadingState is not 'loading'.
72
+ *
73
+ * @param width Panel width in columns.
74
+ * @param frame Current animation frame index (caller increments each render).
75
+ */
76
+ protected renderLoadingLine(width: number, frame = 0): Line | null {
77
+ if (this.loadingState !== 'loading') return null;
78
+ const spinner = SPINNER_FRAMES[frame % SPINNER_FRAMES.length] ?? SPINNER_FRAMES[0]!;
79
+ const text = ` ${spinner} ${this._loadingLabel}`;
80
+ return UIFactory.stringToLine(text.padEnd(width).slice(0, width), width, { fg: '135', bold: true });
81
+ }
82
+
12
83
  /**
13
84
  * Optional resource contract for this panel.
14
85
  * Override in subclasses to declare a custom contract; leave undefined