@pellux/goodvibes-tui 0.18.20 → 0.19.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 (83) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +1 -1
  4. package/package.json +7 -3
  5. package/src/core/conversation-rendering.ts +22 -6
  6. package/src/core/orchestrator.ts +1 -1
  7. package/src/input/commands/diff-runtime.ts +6 -5
  8. package/src/input/commands/guidance-runtime.ts +1 -1
  9. package/src/input/commands/health-runtime.ts +2 -2
  10. package/src/input/commands/local-setup-review.ts +1 -1
  11. package/src/input/commands/session-content.ts +1 -1
  12. package/src/input/commands/session.ts +0 -1
  13. package/src/input/commands/shell-core.ts +3 -2
  14. package/src/input/commands/skills-runtime.ts +2 -2
  15. package/src/input/commands/subscription-runtime.ts +4 -4
  16. package/src/input/feed-context-factory.ts +236 -0
  17. package/src/input/handler-feed.ts +44 -6
  18. package/src/input/handler-shortcuts.ts +138 -125
  19. package/src/input/handler.ts +119 -119
  20. package/src/input/keybindings.ts +30 -0
  21. package/src/input/panel-integration-actions.ts +2 -1
  22. package/src/input/settings-modal-types.ts +60 -0
  23. package/src/input/settings-modal.ts +83 -65
  24. package/src/panels/agent-inspector-panel.ts +10 -9
  25. package/src/panels/agent-logs-panel.ts +26 -6
  26. package/src/panels/approval-panel.ts +55 -82
  27. package/src/panels/automation-control-panel.ts +120 -161
  28. package/src/panels/base-panel.ts +108 -3
  29. package/src/panels/communication-panel.ts +69 -107
  30. package/src/panels/context-visualizer-panel.ts +2 -0
  31. package/src/panels/control-plane-panel.ts +117 -172
  32. package/src/panels/diff-panel.ts +2 -0
  33. package/src/panels/file-explorer-panel.ts +51 -31
  34. package/src/panels/file-preview-panel.ts +57 -35
  35. package/src/panels/git-panel.ts +12 -13
  36. package/src/panels/hooks-panel.ts +103 -138
  37. package/src/panels/incident-review-panel.ts +59 -109
  38. package/src/panels/knowledge-panel.ts +75 -107
  39. package/src/panels/local-auth-panel.ts +77 -93
  40. package/src/panels/marketplace-panel.ts +51 -69
  41. package/src/panels/mcp-panel.ts +110 -155
  42. package/src/panels/memory-panel.ts +90 -158
  43. package/src/panels/ops-control-panel.ts +51 -85
  44. package/src/panels/orchestration-panel.ts +70 -51
  45. package/src/panels/panel-list-panel.ts +5 -4
  46. package/src/panels/panel-manager.ts +25 -2
  47. package/src/panels/plan-dashboard-panel.ts +2 -0
  48. package/src/panels/plugins-panel.ts +37 -60
  49. package/src/panels/polish.ts +51 -2
  50. package/src/panels/provider-accounts-panel.ts +1 -0
  51. package/src/panels/provider-health-panel.ts +6 -8
  52. package/src/panels/routes-panel.ts +91 -141
  53. package/src/panels/schedule-panel.ts +7 -6
  54. package/src/panels/scrollable-list-panel.ts +64 -16
  55. package/src/panels/security-panel.ts +118 -152
  56. package/src/panels/services-panel.ts +63 -105
  57. package/src/panels/session-browser-panel.ts +19 -18
  58. package/src/panels/settings-sync-panel.ts +79 -123
  59. package/src/panels/skills-panel.ts +114 -230
  60. package/src/panels/subscription-panel.ts +64 -86
  61. package/src/panels/system-messages-panel.ts +147 -141
  62. package/src/panels/tasks-panel.ts +130 -179
  63. package/src/panels/token-budget-panel.ts +2 -0
  64. package/src/panels/watchers-panel.ts +89 -137
  65. package/src/panels/worktree-panel.ts +1 -0
  66. package/src/panels/wrfc-panel.ts +2 -0
  67. package/src/renderer/agent-detail-modal.ts +2 -2
  68. package/src/renderer/ansi-sanitize.ts +76 -0
  69. package/src/renderer/buffer.ts +23 -1
  70. package/src/renderer/diff.ts +8 -0
  71. package/src/renderer/help-overlay.ts +48 -28
  72. package/src/renderer/markdown.ts +3 -145
  73. package/src/renderer/settings-modal-helpers.ts +27 -0
  74. package/src/renderer/settings-modal.ts +18 -1
  75. package/src/renderer/status-glyphs.ts +21 -0
  76. package/src/renderer/status-token.ts +4 -8
  77. package/src/renderer/tool-call.ts +4 -3
  78. package/src/runtime/bootstrap-core.ts +1 -1
  79. package/src/runtime/bootstrap-hook-bridge.ts +1 -1
  80. package/src/runtime/bootstrap.ts +7 -8
  81. package/src/runtime/diagnostics/panels/policy.ts +2 -1
  82. package/src/shell/ui-openers.ts +1 -1
  83. package/src/version.ts +1 -1
@@ -1,4 +1,4 @@
1
- import { readFileSync, existsSync, watch, type FSWatcher } from 'fs';
1
+ import { promises as fsPromises, watch, type FSWatcher } from 'fs';
2
2
  import type { Line } from '../types/grid.ts';
3
3
  import { createEmptyLine, createStyledCell } from '../types/grid.ts';
4
4
  import { ScrollableListPanel } from './scrollable-list-panel.ts';
@@ -61,6 +61,7 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
61
61
 
62
62
  constructor(agentEvents: UiEventFeed<AgentEvent>, private readonly deps: AgentLogsPanelDeps) {
63
63
  super('agent-logs', 'Agents', 'A', 'agent');
64
+ this.showSelectionGutter = true; // I5: non-color selection affordance
64
65
  this.agentEvents = agentEvents;
65
66
  this._refreshAgents();
66
67
  this._startPolling();
@@ -256,14 +257,22 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
256
257
  }
257
258
 
258
259
  private _pollCurrentAgent(): void {
260
+ void this._pollCurrentAgentAsync();
261
+ }
262
+
263
+ private async _pollCurrentAgentAsync(): Promise<void> {
259
264
  const agent = this._selectedAgent();
260
265
  if (!agent) return;
261
266
 
262
267
  const sessionFile = this._sessionFilePath(agent.id);
263
- if (!existsSync(sessionFile)) return;
268
+ try {
269
+ await fsPromises.access(sessionFile);
270
+ } catch {
271
+ return;
272
+ }
264
273
 
265
274
  try {
266
- const content = readFileSync(sessionFile, 'utf-8');
275
+ const content = await fsPromises.readFile(sessionFile, 'utf-8');
267
276
  if (content.length === this.lastFileSize) return;
268
277
  this.lastFileSize = content.length;
269
278
 
@@ -284,7 +293,9 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
284
293
  private _watchAgent(agentId: string): void {
285
294
  this._stopWatcher();
286
295
  const sessionFile = this._sessionFilePath(agentId);
287
- if (!existsSync(sessionFile)) return;
296
+ // Start watching immediately; the watcher setup itself is synchronous,
297
+ // the file-existence check is skipped to avoid blocking — if the file
298
+ // does not yet exist watch() will throw and we catch it below.
288
299
  try {
289
300
  this.fsWatcher = watch(sessionFile, () => {
290
301
  if (!this.paused) {
@@ -392,24 +403,33 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
392
403
  }
393
404
 
394
405
  private _reloadAgent(agent: AgentRecord): void {
406
+ void this._reloadAgentAsync(agent);
407
+ }
408
+
409
+ private async _reloadAgentAsync(agent: AgentRecord): Promise<void> {
395
410
  const sessionFile = this._sessionFilePath(agent.id);
396
- if (!existsSync(sessionFile)) {
411
+ try {
412
+ await fsPromises.access(sessionFile);
413
+ } catch {
397
414
  this.allEntries = [];
398
415
  this.filteredEntries = [];
399
416
  this.lastFileSize = 0;
417
+ this.markDirty();
400
418
  return;
401
419
  }
402
420
  try {
403
- const content = readFileSync(sessionFile, 'utf-8');
421
+ const content = await fsPromises.readFile(sessionFile, 'utf-8');
404
422
  this.lastFileSize = content.length;
405
423
  this.allEntries = parseAgentJsonl(content);
406
424
  this._applyFilter();
407
425
  if (this.autoFollow) {
408
426
  this.selectedIndex = Math.max(0, this.filteredEntries.length - 1);
409
427
  }
428
+ this.markDirty();
410
429
  } catch {
411
430
  this.allEntries = [];
412
431
  this.filteredEntries = [];
432
+ this.markDirty();
413
433
  }
414
434
  }
415
435
 
@@ -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,27 +26,34 @@ 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'>) {
39
35
  super('approval', 'Approval', 'A', 'monitoring');
36
+ this.showSelectionGutter = true; // I5: non-color selection affordance
40
37
  this.policyRuntimeState = policyRuntimeState;
41
38
  }
42
39
 
40
+ protected override getPalette() { return C; }
41
+ protected override getEmptyStateMessage() { return ' No approval lanes defined.'; }
42
+
43
+ protected getItems(): readonly ApprovalRow[] {
44
+ return APPROVAL_ROWS;
45
+ }
46
+
47
+ protected renderItem(row: ApprovalRow, index: number, selected: boolean, width: number): Line {
48
+ const bg = selected ? C.selectBg : undefined;
49
+ return buildPanelLine(width, [
50
+ [' ', C.label],
51
+ [row[0].padEnd(10), C.info, bg],
52
+ [row[1].slice(0, Math.max(0, width - 18)), C.value, bg],
53
+ ]);
54
+ }
55
+
43
56
  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
57
  if (key === 'home') {
55
58
  this.selectedIndex = 0;
56
59
  this.markDirty();
@@ -64,7 +67,7 @@ export class ApprovalPanel extends BasePanel {
64
67
  if (key === 'enter' || key === 'return') {
65
68
  return true;
66
69
  }
67
- return false;
70
+ return super.handleInput(key);
68
71
  }
69
72
 
70
73
  public getSelectedCommand(): string | null {
@@ -73,33 +76,16 @@ export class ApprovalPanel extends BasePanel {
73
76
  }
74
77
 
75
78
  public render(width: number, height: number): Line[] {
76
- this.needsRender = false;
79
+ this.clampSelection();
77
80
  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]])];
81
+ const approvalCount = policySnapshot.recentPermissionAudit.filter((e) => e.approved === true).length;
82
+ const denialCount = policySnapshot.recentPermissionAudit.filter((e) => e.approved === false).length;
83
+ const pendingCount = policySnapshot.recentPermissionAudit.filter((e) => e.approved === undefined).length;
84
+
100
85
  const selected = APPROVAL_ROWS[this.selectedIndex] ?? null;
101
86
  const detailLines: Line[] = [];
102
87
  if (selected) {
88
+ detailLines.push(buildPanelLine(width, [[' Selected Lane', C.label]]));
103
89
  detailLines.push(buildKeyValueLine(width, [
104
90
  { label: 'lane', value: selected[0], valueColor: C.info },
105
91
  { label: 'next review', value: selected[2], valueColor: C.dim },
@@ -107,6 +93,7 @@ export class ApprovalPanel extends BasePanel {
107
93
  detailLines.push(buildPanelLine(width, [[` ${selected[1]}`, C.value]]));
108
94
  detailLines.push(buildGuidanceLine(width, selected[2].replace('review via ', ''), `open the ${selected[0]} review path`, C));
109
95
  }
96
+
110
97
  const recentAuditLines: Line[] = [];
111
98
  for (const entry of policySnapshot.recentPermissionAudit.slice(0, 5)) {
112
99
  const decision = entry.approved === undefined ? 'pending' : entry.approved ? 'approved' : 'denied';
@@ -123,6 +110,7 @@ export class ApprovalPanel extends BasePanel {
123
110
  if (recentAuditLines.length === 0) {
124
111
  recentAuditLines.push(buildPanelLine(width, [[` No recent approval pressure. Live requests and decisions will appear here.`, C.dim]]));
125
112
  }
113
+
126
114
  const ruleSuggestionLines: Line[] = [];
127
115
  for (const suggestion of buildPermissionRuleSuggestions(policySnapshot.recentPermissionAudit).slice(0, 3)) {
128
116
  ruleSuggestionLines.push(buildPanelLine(width, [[` ${suggestion.summary}`, C.info]]));
@@ -131,47 +119,32 @@ export class ApprovalPanel extends BasePanel {
131
119
  if (ruleSuggestionLines.length === 0) {
132
120
  ruleSuggestionLines.push(buildPanelLine(width, [[` No repeated denials currently suggest a durable rule.`, C.dim]]));
133
121
  }
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, {
122
+
123
+ const headerLines: Line[] = [
124
+ buildPanelLine(width, [[' Approval posture', C.label]]),
125
+ buildKeyValueLine(width, [
126
+ { label: 'why prompted', value: 'risk summary', valueColor: C.value },
127
+ { label: 'what-if', value: '/policy simulate + preflight', valueColor: C.info },
128
+ { label: 'operator', value: '/security + /cockpit', valueColor: C.good },
129
+ ], C),
130
+ buildPanelLine(width, [
131
+ [' \u2713 ', C.good],
132
+ [`approvals (${approvalCount}) `, C.good],
133
+ ['\u2715 ', C.bad],
134
+ [`denials (${denialCount}) `, C.bad],
135
+ ['\u25cb ', C.info],
136
+ [`pending (${pendingCount})`, C.info],
137
+ ]),
138
+ buildGuidanceLine(width, '/approval review shell', 'inspect the highest-risk approval lane and refine scoped review posture', C),
139
+ ...detailLines,
140
+ ...recentAuditLines,
141
+ ...ruleSuggestionLines,
142
+ ];
143
+
144
+ return this.renderList(width, height, {
162
145
  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,
146
+ header: headerLines,
147
+ footer: [buildPanelLine(width, [[` Up/Down move Home/End jump selected lane opens the next command path`, C.dim]])],
173
148
  });
174
- while (lines.length < height) lines.push(createEmptyLine(width));
175
- return lines.slice(0, height);
176
149
  }
177
150
  }
@@ -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,14 +36,16 @@ 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');
48
+ this.showSelectionGutter = true; // I5: non-color selection affordance
48
49
  this.readModel = readModel;
49
50
  this.unsub = readModel ? readModel.subscribe(() => this.markDirty()) : null;
50
51
  }
@@ -53,29 +54,45 @@ export class AutomationControlPanel extends BasePanel {
53
54
  this.unsub?.();
54
55
  }
55
56
 
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;
57
+ protected override getPalette(): PanelPalette {
58
+ return C;
59
+ }
60
+
61
+ private getJobs(): readonly AutomationJob[] {
62
+ if (!this.readModel) return [];
63
+ return this.readModel.getSnapshot().jobs;
70
64
  }
71
65
 
72
- private runs() {
66
+ protected getItems(): readonly AutomationRun[] {
73
67
  if (!this.readModel) return [];
74
- return [...this.readModel.getSnapshot().runs];
68
+ return this.readModel.getSnapshot().runs;
69
+ }
70
+
71
+ protected renderItem(run: AutomationRun, _index: number, selected: boolean, width: number): Line {
72
+ const bg = selected ? C.selectBg : undefined;
73
+ const jobs = this.getJobs();
74
+ const name = jobs.find((job) => job.id === run.jobId)?.name ?? run.jobId;
75
+ return buildPanelLine(width, [
76
+ [' ', C.label, bg],
77
+ [run.status.padEnd(11), runStatusColor(run.status), bg],
78
+ [` ${truncateDisplay(name, 22).padEnd(22)}`, C.value, bg],
79
+ [` ${truncateDisplay(run.target.kind, 12).padEnd(12)}`, C.info, bg],
80
+ [` ${truncateDisplay(formatTime(run.queuedAt), Math.max(0, width - 49))}`, C.dim, bg],
81
+ ]);
82
+ }
83
+
84
+ protected override getEmptyStateMessage(): string {
85
+ return ' No automation activity recorded.';
86
+ }
87
+
88
+ protected override getEmptyStateActions(): Array<{ command: string; summary: string }> {
89
+ return [
90
+ { command: '/schedule add cron 0 * * * * repo sweep', summary: 'create a recurring automation job' },
91
+ { command: '/schedule list', summary: 'inspect jobs and run history from the shell' },
92
+ ];
75
93
  }
76
94
 
77
95
  public render(width: number, height: number): Line[] {
78
- this.needsRender = false;
79
96
  const intro = 'Automation jobs, active runs, deliveries, and failure posture across the shared control plane.';
80
97
 
81
98
  if (!this.readModel) {
@@ -99,155 +116,97 @@ export class AutomationControlPanel extends BasePanel {
99
116
 
100
117
  const snapshot = this.readModel.getSnapshot();
101
118
  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
- };
119
+ const runs = this.getItems();
120
+
121
+ const headerLines: Line[] = [
122
+ buildKeyValueLine(width, [
123
+ { label: 'jobs', value: String(snapshot.totalJobs), valueColor: snapshot.totalJobs > 0 ? C.info : C.dim },
124
+ { label: 'runs', value: String(snapshot.totalRuns), valueColor: snapshot.totalRuns > 0 ? C.value : C.dim },
125
+ { label: 'active', value: String(snapshot.activeRunIds.length), valueColor: snapshot.activeRunIds.length > 0 ? C.warn : C.dim },
126
+ { label: 'failed', value: String(snapshot.totalFailed), valueColor: snapshot.totalFailed > 0 ? C.error : C.dim },
127
+ ], C),
128
+ buildKeyValueLine(width, [
129
+ { label: 'deliveries ok', value: String(snapshot.deliveryTotals.succeeded), valueColor: snapshot.deliveryTotals.succeeded > 0 ? C.ok : C.dim },
130
+ { label: 'delivery fail', value: String(snapshot.deliveryTotals.failed), valueColor: snapshot.deliveryTotals.failed > 0 ? C.error : C.dim },
131
+ { label: 'dead letters', value: String(snapshot.deliveryTotals.deadLettered), valueColor: snapshot.deliveryTotals.deadLettered > 0 ? C.warn : C.dim },
132
+ { label: 'sources', value: String(snapshot.sourceCount), valueColor: snapshot.sourceCount > 0 ? C.info : C.dim },
133
+ ], C),
134
+ buildGuidanceLine(width, '/schedule list', 'manage jobs and use the web or surface controls for retries, delivery, and cross-surface sessions', C),
135
+ ];
122
136
 
123
137
  if (jobs.length === 0 && runs.length === 0) {
124
- const workspace = buildPanelWorkspace(width, height, {
138
+ return this.renderList(width, height, {
125
139
  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,
140
+ header: headerLines,
141
+ emptyMessage: ' No automation activity recorded.',
143
142
  });
144
- while (workspace.length < height) workspace.push(createEmptyLine(width));
145
- return workspace;
146
143
  }
147
144
 
148
- this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, runs.length - 1));
145
+ this.clampSelection();
149
146
  const selectedRun = runs[this.selectedIndex];
150
147
  const jobName = selectedRun ? (jobs.find((job) => job.id === selectedRun.jobId)?.name ?? selectedRun.jobId) : 'n/a';
151
148
 
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;
149
+ const footerLines: Line[] = [];
150
+ if (selectedRun) {
151
+ footerLines.push(
152
+ buildPanelLine(width, [
153
+ [' Run: ', C.label],
154
+ [selectedRun.id, C.value],
155
+ [' Status: ', C.label],
156
+ [selectedRun.status, runStatusColor(selectedRun.status)],
157
+ ]),
158
+ buildPanelLine(width, [
159
+ [' Job: ', C.label],
160
+ [jobName, C.value],
161
+ [' Agent: ', C.label],
162
+ [selectedRun.agentId ?? 'n/a', C.info],
163
+ ]),
164
+ buildPanelLine(width, [
165
+ [' Queue: ', C.label],
166
+ [formatTime(selectedRun.queuedAt), C.dim],
167
+ [' End: ', C.label],
168
+ [formatTime(selectedRun.endedAt), C.dim],
169
+ ]),
170
+ buildPanelLine(width, [
171
+ [' Trigger: ', C.label],
172
+ [selectedRun.triggeredBy.kind, C.info],
173
+ [' Target: ', C.label],
174
+ [selectedRun.target.kind, C.value],
175
+ ]),
176
+ buildPanelLine(width, [
177
+ [' Deliveries: ', C.label],
178
+ [String(selectedRun.deliveryIds.length), selectedRun.deliveryIds.length > 0 ? C.info : C.dim],
179
+ [' Route: ', C.label],
180
+ [selectedRun.routeId ?? 'n/a', C.dim],
181
+ ]),
182
+ );
183
+ if (selectedRun.error) {
184
+ footerLines.push(buildPanelLine(width, [
185
+ [' Error: ', C.label],
186
+ [truncateDisplay(selectedRun.error, Math.max(0, width - 10)), C.error],
187
+ ]));
188
+ }
189
+ } else {
190
+ footerLines.push(buildPanelLine(width, [[' No run selected.', C.dim]]));
191
+ }
236
192
 
237
- const sections: PanelWorkspaceSection[] = [
238
- summarySection,
239
- resolvedRuns.section,
240
- detailSection,
241
- jobSection,
242
- ];
243
- const lines = buildPanelWorkspace(width, height, {
193
+ // Jobs quick view
194
+ if (jobs.length > 0) {
195
+ footerLines.push(
196
+ ...jobs.slice(0, 6).map((job) => buildPanelLine(width, [
197
+ [' ', C.label],
198
+ [job.enabled ? 'ENABLED ' : 'PAUSED ', job.enabled ? C.ok : C.warn],
199
+ [truncateDisplay(job.name, 24).padEnd(24), C.value],
200
+ [` next ${truncateDisplay(formatTime(job.nextRunAt), Math.max(0, width - 43))}`, C.dim],
201
+ ])),
202
+ );
203
+ }
204
+ footerLines.push(buildPanelLine(width, [[' Up/Down move through runs', C.dim]]));
205
+
206
+ return this.renderList(width, height, {
244
207
  title: 'Automation Control',
245
- intro,
246
- sections,
247
- footerLines: [buildPanelLine(width, [[' Up/Down move through runs', C.dim]])],
248
- palette: C,
208
+ header: headerLines,
209
+ footer: footerLines,
249
210
  });
250
- while (lines.length < height) lines.push(createEmptyLine(width));
251
- return lines.slice(0, height);
252
211
  }
253
212
  }