@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.
- package/CHANGELOG.md +170 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/core/conversation-rendering.ts +20 -6
- package/src/input/commands/session.ts +0 -1
- package/src/input/feed-context-factory.ts +236 -0
- package/src/input/handler-feed-routes.ts +10 -0
- package/src/input/handler-feed.ts +44 -6
- package/src/input/handler-shortcuts.ts +138 -125
- package/src/input/handler.ts +121 -119
- package/src/input/keybindings.ts +30 -0
- package/src/panels/approval-panel.ts +54 -74
- package/src/panels/automation-control-panel.ts +119 -161
- package/src/panels/base-panel.ts +71 -0
- package/src/panels/communication-panel.ts +68 -107
- package/src/panels/confirm-state.ts +61 -0
- package/src/panels/control-plane-panel.ts +116 -172
- package/src/panels/git-panel.ts +9 -0
- package/src/panels/hooks-panel.ts +101 -138
- package/src/panels/incident-review-panel.ts +55 -107
- package/src/panels/knowledge-panel.ts +63 -14
- package/src/panels/local-auth-panel.ts +76 -93
- package/src/panels/marketplace-panel.ts +19 -12
- package/src/panels/mcp-panel.ts +108 -155
- package/src/panels/ops-control-panel.ts +50 -85
- package/src/panels/panel-manager.ts +22 -2
- package/src/panels/plugins-panel.ts +36 -60
- package/src/panels/routes-panel.ts +89 -141
- package/src/panels/scrollable-list-panel.ts +71 -16
- package/src/panels/security-panel.ts +101 -137
- package/src/panels/services-panel.ts +58 -102
- package/src/panels/settings-sync-panel.ts +76 -122
- package/src/panels/skills-panel.ts +44 -0
- package/src/panels/subscription-panel.ts +69 -80
- package/src/panels/tasks-panel.ts +129 -179
- package/src/panels/watchers-panel.ts +88 -137
- package/src/renderer/buffer.ts +11 -0
- package/src/renderer/diff.ts +8 -0
- package/src/renderer/help-overlay.ts +37 -28
- package/src/renderer/markdown.ts +3 -145
- package/src/renderer/status-token.ts +71 -0
- package/src/version.ts +1 -1
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
import type { Line } from '../types/grid.ts';
|
|
2
|
-
import {
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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.
|
|
78
|
+
this.clampSelection();
|
|
77
79
|
const policySnapshot = this.policyRuntimeState.getSnapshot();
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
+
protected getItems(): readonly AutomationRun[] {
|
|
73
66
|
if (!this.readModel) return [];
|
|
74
|
-
return
|
|
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.
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
137
|
+
return this.renderList(width, height, {
|
|
125
138
|
title: 'Automation Control',
|
|
126
|
-
|
|
127
|
-
|
|
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.
|
|
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
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
246
|
-
|
|
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
|
}
|
package/src/panels/base-panel.ts
CHANGED
|
@@ -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
|