@pellux/goodvibes-tui 0.18.20 → 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 (34) hide show
  1. package/CHANGELOG.md +120 -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.ts +44 -6
  8. package/src/input/handler-shortcuts.ts +138 -125
  9. package/src/input/handler.ts +121 -119
  10. package/src/input/keybindings.ts +30 -0
  11. package/src/panels/approval-panel.ts +54 -82
  12. package/src/panels/automation-control-panel.ts +119 -161
  13. package/src/panels/communication-panel.ts +68 -107
  14. package/src/panels/control-plane-panel.ts +116 -172
  15. package/src/panels/hooks-panel.ts +101 -138
  16. package/src/panels/incident-review-panel.ts +55 -107
  17. package/src/panels/local-auth-panel.ts +76 -93
  18. package/src/panels/mcp-panel.ts +108 -155
  19. package/src/panels/ops-control-panel.ts +50 -85
  20. package/src/panels/panel-manager.ts +22 -2
  21. package/src/panels/plugins-panel.ts +36 -60
  22. package/src/panels/routes-panel.ts +89 -141
  23. package/src/panels/scrollable-list-panel.ts +45 -14
  24. package/src/panels/security-panel.ts +101 -137
  25. package/src/panels/services-panel.ts +58 -102
  26. package/src/panels/settings-sync-panel.ts +76 -122
  27. package/src/panels/subscription-panel.ts +63 -86
  28. package/src/panels/tasks-panel.ts +129 -179
  29. package/src/panels/watchers-panel.ts +88 -137
  30. package/src/renderer/buffer.ts +11 -0
  31. package/src/renderer/diff.ts +8 -0
  32. package/src/renderer/help-overlay.ts +37 -28
  33. package/src/renderer/markdown.ts +3 -145
  34. 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,33 +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
- (() => {
85
- const approvalCount = policySnapshot.recentPermissionAudit.filter((entry) => entry.approved === true).length;
86
- const denialCount = policySnapshot.recentPermissionAudit.filter((entry) => entry.approved === false).length;
87
- const pendingCount = policySnapshot.recentPermissionAudit.filter((entry) => entry.approved === undefined).length;
88
- return buildPanelLine(width, [
89
- [' \u2713 ', C.good],
90
- [`approvals (${approvalCount}) `, C.good],
91
- ['\u2715 ', C.bad],
92
- [`denials (${denialCount}) `, C.bad],
93
- ['\u25cb ', C.info],
94
- [`pending (${pendingCount})`, C.info],
95
- ]);
96
- })(),
97
- buildGuidanceLine(width, '/approval review shell', 'inspect the highest-risk approval lane and refine scoped review posture', C),
98
- ];
99
- 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
+
100
84
  const selected = APPROVAL_ROWS[this.selectedIndex] ?? null;
101
85
  const detailLines: Line[] = [];
102
86
  if (selected) {
87
+ detailLines.push(buildPanelLine(width, [[' Selected Lane', C.label]]));
103
88
  detailLines.push(buildKeyValueLine(width, [
104
89
  { label: 'lane', value: selected[0], valueColor: C.info },
105
90
  { label: 'next review', value: selected[2], valueColor: C.dim },
@@ -107,6 +92,7 @@ export class ApprovalPanel extends BasePanel {
107
92
  detailLines.push(buildPanelLine(width, [[` ${selected[1]}`, C.value]]));
108
93
  detailLines.push(buildGuidanceLine(width, selected[2].replace('review via ', ''), `open the ${selected[0]} review path`, C));
109
94
  }
95
+
110
96
  const recentAuditLines: Line[] = [];
111
97
  for (const entry of policySnapshot.recentPermissionAudit.slice(0, 5)) {
112
98
  const decision = entry.approved === undefined ? 'pending' : entry.approved ? 'approved' : 'denied';
@@ -123,6 +109,7 @@ export class ApprovalPanel extends BasePanel {
123
109
  if (recentAuditLines.length === 0) {
124
110
  recentAuditLines.push(buildPanelLine(width, [[` No recent approval pressure. Live requests and decisions will appear here.`, C.dim]]));
125
111
  }
112
+
126
113
  const ruleSuggestionLines: Line[] = [];
127
114
  for (const suggestion of buildPermissionRuleSuggestions(policySnapshot.recentPermissionAudit).slice(0, 3)) {
128
115
  ruleSuggestionLines.push(buildPanelLine(width, [[` ${suggestion.summary}`, C.info]]));
@@ -131,47 +118,32 @@ export class ApprovalPanel extends BasePanel {
131
118
  if (ruleSuggestionLines.length === 0) {
132
119
  ruleSuggestionLines.push(buildPanelLine(width, [[` No repeated denials currently suggest a durable rule.`, C.dim]]));
133
120
  }
134
- const postureSection: PanelWorkspaceSection = { title: 'Approval posture', lines: postureLines };
135
- const selectedSection: PanelWorkspaceSection = { title: 'Selected Lane', lines: detailLines };
136
- const pressureSection: PanelWorkspaceSection = { title: 'Recent Pressure', lines: recentAuditLines };
137
- const rulesSection: PanelWorkspaceSection = { title: 'Rule Suggestions', lines: ruleSuggestionLines };
138
- const resolvedLanesSection = resolvePrimaryScrollableSection(width, height, {
139
- intro: 'Action-specific review lanes for approvals, denials, escalations, and preflight guidance.',
140
- footerLines,
141
- palette: C,
142
- beforeSections: [postureSection, selectedSection, pressureSection, rulesSection],
143
- section: {
144
- title: 'Review Lanes',
145
- scrollableLines: APPROVAL_ROWS.map((row, absolute) => {
146
- const bg = absolute === this.selectedIndex ? C.selectBg : undefined;
147
- return buildPanelLine(width, [
148
- [' ', C.label],
149
- [row[0].padEnd(10), C.info, bg],
150
- [row[1].slice(0, Math.max(0, width - 18)), C.value, bg],
151
- ]);
152
- }),
153
- selectedIndex: this.selectedIndex,
154
- scrollOffset: this.scrollOffset,
155
- guardRows: 1,
156
- minRows: 4,
157
- appendWindowSummary: { dimColor: C.dim },
158
- },
159
- });
160
- this.scrollOffset = resolvedLanesSection.scrollOffset;
161
- 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, {
162
144
  title: 'Approval Control Room',
163
- intro: 'Action-specific review lanes for approvals, denials, escalations, and preflight guidance.',
164
- sections: [
165
- postureSection,
166
- selectedSection,
167
- pressureSection,
168
- rulesSection,
169
- resolvedLanesSection.section,
170
- ],
171
- footerLines,
172
- palette: C,
145
+ header: headerLines,
146
+ footer: [buildPanelLine(width, [[` Up/Down move Home/End jump selected lane opens the next command path`, C.dim]])],
173
147
  });
174
- while (lines.length < height) lines.push(createEmptyLine(width));
175
- return lines.slice(0, height);
176
148
  }
177
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
  }