@pellux/goodvibes-tui 0.18.23 → 0.19.1
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 +71 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +7 -3
- package/src/core/conversation-rendering.ts +8 -6
- package/src/core/orchestrator.ts +1 -1
- package/src/daemon/cli.ts +54 -0
- package/src/input/commands/diff-runtime.ts +6 -5
- package/src/input/commands/guidance-runtime.ts +1 -1
- package/src/input/commands/health-runtime.ts +2 -2
- package/src/input/commands/local-setup-review.ts +1 -1
- package/src/input/commands/session-content.ts +1 -1
- package/src/input/commands/shell-core.ts +3 -2
- package/src/input/commands/skills-runtime.ts +2 -2
- package/src/input/commands/subscription-runtime.ts +4 -4
- package/src/input/handler.ts +8 -10
- package/src/input/model-picker.ts +6 -2
- package/src/input/panel-integration-actions.ts +2 -1
- package/src/input/settings-modal-types.ts +60 -0
- package/src/input/settings-modal.ts +83 -65
- package/src/main.ts +52 -0
- package/src/panels/agent-inspector-panel.ts +10 -9
- package/src/panels/agent-logs-panel.ts +26 -6
- package/src/panels/approval-panel.ts +1 -0
- package/src/panels/automation-control-panel.ts +1 -0
- package/src/panels/base-panel.ts +108 -3
- package/src/panels/communication-panel.ts +1 -0
- package/src/panels/context-visualizer-panel.ts +2 -0
- package/src/panels/control-plane-panel.ts +1 -0
- package/src/panels/diff-panel.ts +2 -0
- package/src/panels/file-explorer-panel.ts +51 -31
- package/src/panels/file-preview-panel.ts +57 -35
- package/src/panels/git-panel.ts +12 -13
- package/src/panels/hooks-panel.ts +3 -1
- package/src/panels/incident-review-panel.ts +4 -2
- package/src/panels/knowledge-panel.ts +75 -107
- package/src/panels/local-auth-panel.ts +1 -0
- package/src/panels/marketplace-panel.ts +51 -69
- package/src/panels/mcp-panel.ts +3 -1
- package/src/panels/memory-panel.ts +90 -158
- package/src/panels/ops-control-panel.ts +1 -0
- package/src/panels/orchestration-panel.ts +70 -51
- package/src/panels/panel-list-panel.ts +5 -4
- package/src/panels/panel-manager.ts +3 -0
- package/src/panels/plan-dashboard-panel.ts +2 -0
- package/src/panels/plugins-panel.ts +1 -0
- package/src/panels/polish.ts +51 -2
- package/src/panels/provider-accounts-panel.ts +1 -0
- package/src/panels/provider-health-panel.ts +6 -8
- package/src/panels/routes-panel.ts +3 -1
- package/src/panels/schedule-panel.ts +7 -6
- package/src/panels/scrollable-list-panel.ts +19 -2
- package/src/panels/security-panel.ts +17 -15
- package/src/panels/services-panel.ts +6 -4
- package/src/panels/session-browser-panel.ts +19 -18
- package/src/panels/settings-sync-panel.ts +3 -1
- package/src/panels/skills-panel.ts +114 -230
- package/src/panels/subscription-panel.ts +1 -0
- package/src/panels/system-messages-panel.ts +147 -141
- package/src/panels/tasks-panel.ts +1 -0
- package/src/panels/token-budget-panel.ts +2 -0
- package/src/panels/watchers-panel.ts +1 -0
- package/src/panels/worktree-panel.ts +1 -0
- package/src/panels/wrfc-panel.ts +2 -0
- package/src/renderer/agent-detail-modal.ts +2 -2
- package/src/renderer/ansi-sanitize.ts +76 -0
- package/src/renderer/buffer.ts +12 -1
- package/src/renderer/help-overlay.ts +14 -3
- package/src/renderer/model-picker-overlay.ts +9 -2
- package/src/renderer/settings-modal-helpers.ts +27 -0
- package/src/renderer/settings-modal.ts +18 -1
- package/src/renderer/status-glyphs.ts +21 -0
- package/src/renderer/status-token.ts +4 -8
- package/src/renderer/tool-call.ts +4 -3
- package/src/runtime/bootstrap-core.ts +1 -1
- package/src/runtime/bootstrap-hook-bridge.ts +1 -1
- package/src/runtime/bootstrap.ts +7 -8
- package/src/runtime/diagnostics/panels/policy.ts +2 -1
- package/src/shell/ui-openers.ts +44 -3
- package/src/version.ts +1 -1
|
@@ -23,67 +23,23 @@ import type { FeatureFlag, FlagState } from '@pellux/goodvibes-sdk/platform/runt
|
|
|
23
23
|
import type { McpRegistry } from '@pellux/goodvibes-sdk/platform/mcp/registry';
|
|
24
24
|
import { logger } from '@pellux/goodvibes-sdk/platform/utils/logger';
|
|
25
25
|
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils/error-display';
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
'danger',
|
|
44
|
-
'tools',
|
|
45
|
-
'flags',
|
|
46
|
-
];
|
|
47
|
-
|
|
48
|
-
export interface SettingEntry {
|
|
49
|
-
setting: ConfigSetting;
|
|
50
|
-
currentValue: unknown;
|
|
51
|
-
isDefault: boolean;
|
|
52
|
-
effectiveSource?: 'default' | 'local' | 'synced' | 'managed';
|
|
53
|
-
locked?: boolean;
|
|
54
|
-
conflict?: boolean;
|
|
55
|
-
sourceLabel?: string;
|
|
56
|
-
lockReason?: string;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** A single feature flag entry for the flags tab. */
|
|
60
|
-
export interface FlagEntry {
|
|
61
|
-
flag: FeatureFlag;
|
|
62
|
-
state: FlagState;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export interface McpEntry {
|
|
66
|
-
name: string;
|
|
67
|
-
connected: boolean;
|
|
68
|
-
role: 'general' | 'docs' | 'filesystem' | 'git' | 'database' | 'browser' | 'automation' | 'ops' | 'remote';
|
|
69
|
-
trustMode: 'constrained' | 'ask-on-risk' | 'allow-all' | 'blocked';
|
|
70
|
-
allowedPaths: string[];
|
|
71
|
-
allowedHosts: string[];
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export interface SubscriptionEntry {
|
|
75
|
-
provider: string;
|
|
76
|
-
state: 'active' | 'pending' | 'available';
|
|
77
|
-
tokenType?: string;
|
|
78
|
-
expiresAt?: number;
|
|
79
|
-
oauthConfigured: boolean;
|
|
80
|
-
activeRoute?: ProviderAuthRoute;
|
|
81
|
-
preferredRoute?: ProviderAuthRoute;
|
|
82
|
-
authFreshness?: ProviderAuthFreshness;
|
|
83
|
-
routeReason?: string;
|
|
84
|
-
issues?: string[];
|
|
85
|
-
nextActions?: string[];
|
|
86
|
-
}
|
|
26
|
+
import {
|
|
27
|
+
SETTINGS_CATEGORIES,
|
|
28
|
+
type FlagEntry,
|
|
29
|
+
type McpEntry,
|
|
30
|
+
type SettingEntry,
|
|
31
|
+
type SettingsCategory,
|
|
32
|
+
type SubscriptionEntry,
|
|
33
|
+
} from './settings-modal-types.ts';
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
SETTINGS_CATEGORIES,
|
|
37
|
+
type FlagEntry,
|
|
38
|
+
type McpEntry,
|
|
39
|
+
type SettingEntry,
|
|
40
|
+
type SettingsCategory,
|
|
41
|
+
type SubscriptionEntry,
|
|
42
|
+
} from './settings-modal-types.ts';
|
|
87
43
|
|
|
88
44
|
/**
|
|
89
45
|
* Map a config key to the model picker target it should open, or null if the
|
|
@@ -151,6 +107,13 @@ export class SettingsModal {
|
|
|
151
107
|
/** Provider subscription entries (populated when subscriptions tab is active). */
|
|
152
108
|
public subscriptionEntries: SubscriptionEntry[] = [];
|
|
153
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Set after a network-category save that touches controlPlane or httpListener
|
|
112
|
+
* config keys. Renderer reads this to display a transient restart notice.
|
|
113
|
+
* Cleared on next open() or close().
|
|
114
|
+
*/
|
|
115
|
+
public lastSaveTriggeredRestart: 'control-plane' | 'http-listener' | 'web' | null = null;
|
|
116
|
+
|
|
154
117
|
private configManager: ConfigManager | null = null;
|
|
155
118
|
private featureFlagManager: FeatureFlagManager | null = null;
|
|
156
119
|
private mcpRegistry: McpRegistry | null = null;
|
|
@@ -185,6 +148,7 @@ export class SettingsModal {
|
|
|
185
148
|
this.editBuffer = '';
|
|
186
149
|
this.mcpAllowAllConfirmationTarget = null;
|
|
187
150
|
this.subscriptionLogoutConfirmationTarget = null;
|
|
151
|
+
this.lastSaveTriggeredRestart = null;
|
|
188
152
|
this.active = true;
|
|
189
153
|
}
|
|
190
154
|
|
|
@@ -194,6 +158,7 @@ export class SettingsModal {
|
|
|
194
158
|
this.editBuffer = '';
|
|
195
159
|
this.mcpAllowAllConfirmationTarget = null;
|
|
196
160
|
this.subscriptionLogoutConfirmationTarget = null;
|
|
161
|
+
this.lastSaveTriggeredRestart = null;
|
|
197
162
|
this.serviceRegistry = null;
|
|
198
163
|
}
|
|
199
164
|
|
|
@@ -548,7 +513,15 @@ export class SettingsModal {
|
|
|
548
513
|
for (const setting of CONFIG_SCHEMA) {
|
|
549
514
|
const rawCat = setting.key.split('.')[0] as string;
|
|
550
515
|
// Route helper.* settings into the tools group for unified display
|
|
551
|
-
|
|
516
|
+
// Route controlPlane.* and httpListener.* into the network group
|
|
517
|
+
let cat: SettingsCategory;
|
|
518
|
+
if (rawCat === 'helper') {
|
|
519
|
+
cat = 'tools';
|
|
520
|
+
} else if (rawCat === 'controlPlane' || rawCat === 'httpListener' || rawCat === 'web') {
|
|
521
|
+
cat = 'network';
|
|
522
|
+
} else {
|
|
523
|
+
cat = rawCat as SettingsCategory;
|
|
524
|
+
}
|
|
552
525
|
if (!this.groups.has(cat)) continue;
|
|
553
526
|
const currentValue = configManager.get(setting.key as ConfigKey);
|
|
554
527
|
const resolved = getResolvedSettingLookup(configManager, setting.key as ConfigKey)?.entry;
|
|
@@ -720,17 +693,62 @@ export class SettingsModal {
|
|
|
720
693
|
/** Returns [] for the flags category (flags use flagEntries instead). */
|
|
721
694
|
private _currentItems(): SettingEntry[] {
|
|
722
695
|
if (this.currentCategory === 'flags' || this.currentCategory === 'mcp' || this.currentCategory === 'subscriptions') return [];
|
|
723
|
-
|
|
696
|
+
const items = this.groups.get(this.currentCategory) ?? [];
|
|
697
|
+
if (this.currentCategory === 'network') {
|
|
698
|
+
// Hide host fields when the corresponding hostMode is not 'custom'
|
|
699
|
+
return items.filter(entry => {
|
|
700
|
+
if (entry.setting.key === 'controlPlane.host') {
|
|
701
|
+
const hostMode = this.configManager?.get('controlPlane.hostMode');
|
|
702
|
+
return hostMode === 'custom';
|
|
703
|
+
}
|
|
704
|
+
if (entry.setting.key === 'httpListener.host') {
|
|
705
|
+
const hostMode = this.configManager?.get('httpListener.hostMode');
|
|
706
|
+
return hostMode === 'custom';
|
|
707
|
+
}
|
|
708
|
+
if (entry.setting.key === 'web.host') {
|
|
709
|
+
const hostMode = this.configManager?.get('web.hostMode');
|
|
710
|
+
return hostMode === 'custom';
|
|
711
|
+
}
|
|
712
|
+
return true;
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
return items;
|
|
724
716
|
}
|
|
725
717
|
|
|
726
718
|
private _setValue(key: ConfigKey, value: unknown): void {
|
|
727
719
|
if (!this.configManager) return;
|
|
720
|
+
// Diff previous value before writing — avoids false restart notices on no-op saves
|
|
721
|
+
const previousValue = this.configManager.get(key);
|
|
722
|
+
const isRestartKey = ['host', 'port', 'hostMode', 'enabled'].includes(key.split('.')[1] ?? '');
|
|
728
723
|
try {
|
|
729
724
|
this.configManager.setDynamic(key, value);
|
|
730
725
|
// Update the cached entry in-place — avoids full schema re-scan on each edit
|
|
731
726
|
const rawCat = key.split('.')[0] as string;
|
|
732
|
-
//
|
|
733
|
-
|
|
727
|
+
// Resolve the display category from the key prefix
|
|
728
|
+
let cat: SettingsCategory;
|
|
729
|
+
if (rawCat === 'helper') {
|
|
730
|
+
cat = 'tools';
|
|
731
|
+
} else if (rawCat === 'controlPlane') {
|
|
732
|
+
cat = 'network';
|
|
733
|
+
// SDK auto-restarts the daemon server on controlPlane binding changes
|
|
734
|
+
if (isRestartKey && previousValue !== value) {
|
|
735
|
+
this.lastSaveTriggeredRestart = 'control-plane';
|
|
736
|
+
}
|
|
737
|
+
} else if (rawCat === 'httpListener') {
|
|
738
|
+
cat = 'network';
|
|
739
|
+
// SDK auto-restarts the HTTP listener on binding changes
|
|
740
|
+
if (isRestartKey && previousValue !== value) {
|
|
741
|
+
this.lastSaveTriggeredRestart = 'http-listener';
|
|
742
|
+
}
|
|
743
|
+
} else if (rawCat === 'web') {
|
|
744
|
+
cat = 'network';
|
|
745
|
+
// SDK auto-restarts the web server on binding changes
|
|
746
|
+
if (isRestartKey && previousValue !== value) {
|
|
747
|
+
this.lastSaveTriggeredRestart = 'web';
|
|
748
|
+
}
|
|
749
|
+
} else {
|
|
750
|
+
cat = rawCat as SettingsCategory;
|
|
751
|
+
}
|
|
734
752
|
const entries = this.groups.get(cat);
|
|
735
753
|
if (entries) {
|
|
736
754
|
const entry = entries.find(e => e.setting.key === key);
|
package/src/main.ts
CHANGED
|
@@ -80,6 +80,49 @@ function resolveShellEntrypointOwnership(): ShellEntrypointOwnership {
|
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// CLI flag parsing (TUI shell entry point)
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
type TuiCliFlags = {
|
|
88
|
+
readonly provider: string | undefined;
|
|
89
|
+
readonly model: string | undefined;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
function parseTuiCliFlags(argv: readonly string[]): TuiCliFlags {
|
|
93
|
+
let provider: string | undefined;
|
|
94
|
+
let model: string | undefined;
|
|
95
|
+
|
|
96
|
+
for (let i = 0; i < argv.length; i++) {
|
|
97
|
+
const arg = argv[i];
|
|
98
|
+
if (arg === '--help' || arg === '-h') {
|
|
99
|
+
// eslint-disable-next-line no-console
|
|
100
|
+
console.log([
|
|
101
|
+
'Usage: goodvibes [options]',
|
|
102
|
+
'',
|
|
103
|
+
'Options:',
|
|
104
|
+
' --provider <id> Override the provider from settings.json at startup',
|
|
105
|
+
' --model <registryKey> Override the model from settings.json at startup',
|
|
106
|
+
' Format: provider:modelId (e.g. inception:mercury-2)',
|
|
107
|
+
' If provider:modelId format is used, --provider is inferred',
|
|
108
|
+
' --help, -h Show this help message',
|
|
109
|
+
].join('\n'));
|
|
110
|
+
process.exit(0);
|
|
111
|
+
}
|
|
112
|
+
if (arg === '--provider' && argv[i + 1] !== undefined) {
|
|
113
|
+
provider = argv[++i];
|
|
114
|
+
} else if (arg === '--model' && argv[i + 1] !== undefined) {
|
|
115
|
+
model = argv[++i];
|
|
116
|
+
// Infer provider from registryKey format (provider:modelId) if --provider not given
|
|
117
|
+
if (typeof model === 'string' && model.includes(':') && provider === undefined) {
|
|
118
|
+
provider = model.split(':')[0];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { provider, model };
|
|
124
|
+
}
|
|
125
|
+
|
|
83
126
|
async function main() {
|
|
84
127
|
const stdout = process.stdout;
|
|
85
128
|
const stdin = process.stdin;
|
|
@@ -95,6 +138,15 @@ async function main() {
|
|
|
95
138
|
});
|
|
96
139
|
new GlobalNetworkTransportInstaller().install(configManager);
|
|
97
140
|
|
|
141
|
+
// Apply CLI flags — override settings.json before the provider registry is constructed
|
|
142
|
+
const cliFlags = parseTuiCliFlags(process.argv.slice(2));
|
|
143
|
+
if (cliFlags.provider !== undefined) {
|
|
144
|
+
configManager.set('provider.provider', cliFlags.provider);
|
|
145
|
+
}
|
|
146
|
+
if (cliFlags.model !== undefined) {
|
|
147
|
+
configManager.set('provider.model', cliFlags.model);
|
|
148
|
+
}
|
|
149
|
+
|
|
98
150
|
// ── Bootstrap all runtime subsystems ─────────────────────────────────────
|
|
99
151
|
// bootstrapRuntime initializes all subsystems in dependency order and returns
|
|
100
152
|
// a fully-wired BootstrapContext. main.ts owns terminal setup, the render loop,
|
|
@@ -97,7 +97,7 @@ export class AgentInspectorPanel extends BasePanel {
|
|
|
97
97
|
private cursorIndex = 0;
|
|
98
98
|
|
|
99
99
|
// Refresh timer (active only while panel is active)
|
|
100
|
-
private
|
|
100
|
+
private refreshTimerId: ReturnType<typeof setInterval> | null = null;
|
|
101
101
|
|
|
102
102
|
// Row cache — cleared on markDirty(), computed once per render cycle
|
|
103
103
|
private _cachedRows: DisplayRow[] | null = null;
|
|
@@ -131,7 +131,7 @@ export class AgentInspectorPanel extends BasePanel {
|
|
|
131
131
|
this.cursorIndex = 0;
|
|
132
132
|
this.timeline = [];
|
|
133
133
|
this.markDirty();
|
|
134
|
-
this._refreshTimeline().catch(() => {});
|
|
134
|
+
this._refreshTimeline().catch((err) => { logger.debug('agent inspector timeline refresh failed', { err }); });
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
// -------------------------------------------------------------------------
|
|
@@ -149,6 +149,7 @@ export class AgentInspectorPanel extends BasePanel {
|
|
|
149
149
|
|
|
150
150
|
override onDestroy(): void {
|
|
151
151
|
this._stopRefresh();
|
|
152
|
+
super.onDestroy();
|
|
152
153
|
}
|
|
153
154
|
|
|
154
155
|
// -------------------------------------------------------------------------
|
|
@@ -461,16 +462,16 @@ export class AgentInspectorPanel extends BasePanel {
|
|
|
461
462
|
}
|
|
462
463
|
|
|
463
464
|
private _startRefresh(): void {
|
|
464
|
-
if (this.
|
|
465
|
-
this.
|
|
466
|
-
this._refreshTimeline().catch(() => {});
|
|
467
|
-
}, REFRESH_MS);
|
|
465
|
+
if (this.refreshTimerId) return;
|
|
466
|
+
this.refreshTimerId = this.registerTimer(setInterval(() => {
|
|
467
|
+
this._refreshTimeline().catch((err) => { logger.debug('agent inspector timeline refresh tick failed', { err }); });
|
|
468
|
+
}, REFRESH_MS));
|
|
468
469
|
}
|
|
469
470
|
|
|
470
471
|
private _stopRefresh(): void {
|
|
471
|
-
if (this.
|
|
472
|
-
|
|
473
|
-
this.
|
|
472
|
+
if (this.refreshTimerId) {
|
|
473
|
+
this.clearTimer(this.refreshTimerId);
|
|
474
|
+
this.refreshTimerId = null;
|
|
474
475
|
}
|
|
475
476
|
}
|
|
476
477
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
268
|
+
try {
|
|
269
|
+
await fsPromises.access(sessionFile);
|
|
270
|
+
} catch {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
264
273
|
|
|
265
274
|
try {
|
|
266
|
-
const content =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
|
@@ -33,6 +33,7 @@ export class ApprovalPanel extends ScrollableListPanel<ApprovalRow> {
|
|
|
33
33
|
|
|
34
34
|
public constructor(policyRuntimeState: Pick<PolicyRuntimeState, 'getSnapshot'>) {
|
|
35
35
|
super('approval', 'Approval', 'A', 'monitoring');
|
|
36
|
+
this.showSelectionGutter = true; // I5: non-color selection affordance
|
|
36
37
|
this.policyRuntimeState = policyRuntimeState;
|
|
37
38
|
}
|
|
38
39
|
|
|
@@ -45,6 +45,7 @@ export class AutomationControlPanel extends ScrollableListPanel<AutomationRun> {
|
|
|
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
|
}
|
package/src/panels/base-panel.ts
CHANGED
|
@@ -11,14 +11,54 @@ export abstract class BasePanel implements Panel {
|
|
|
11
11
|
public isPinned = false;
|
|
12
12
|
protected readonly componentHealthMonitor?: ComponentHealthMonitor;
|
|
13
13
|
|
|
14
|
+
// -------------------------------------------------------------------------
|
|
15
|
+
// Timer registry
|
|
16
|
+
// -------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/** All timers registered via registerTimer(). Cleared automatically on onDestroy(). */
|
|
19
|
+
private readonly _timers: Set<ReturnType<typeof setTimeout> | ReturnType<typeof setInterval>> = new Set();
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Register a timer id (from setInterval or setTimeout) so it is
|
|
23
|
+
* automatically cleared when the panel is destroyed. Returns the id
|
|
24
|
+
* unchanged so the call can be chained inline:
|
|
25
|
+
*
|
|
26
|
+
* ```ts
|
|
27
|
+
* this.registerTimer(setInterval(() => this.refresh(), 5_000));
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
protected registerTimer<T extends ReturnType<typeof setTimeout> | ReturnType<typeof setInterval>>(id: T): T {
|
|
31
|
+
this._timers.add(id);
|
|
32
|
+
return id;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Clear a specific timer and remove it from the registry.
|
|
37
|
+
* Safe to call with an id that was never registered or already cleared.
|
|
38
|
+
*/
|
|
39
|
+
protected clearTimer(id: ReturnType<typeof setTimeout> | ReturnType<typeof setInterval>): void {
|
|
40
|
+
clearInterval(id as ReturnType<typeof setInterval>);
|
|
41
|
+
this._timers.delete(id);
|
|
42
|
+
}
|
|
43
|
+
|
|
14
44
|
// -------------------------------------------------------------------------
|
|
15
45
|
// I2: Error surface slot
|
|
16
46
|
// -------------------------------------------------------------------------
|
|
17
47
|
|
|
18
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* Last error message to surface in the panel footer.
|
|
50
|
+
* Auto-cleared on the next keystroke by `ScrollableListPanel.handleInput()` (and any
|
|
51
|
+
* subclass that calls `super.handleInput()` or manually calls `this.clearError()` at
|
|
52
|
+
* the start of its handler). BasePanel itself does NOT auto-clear — only subclasses
|
|
53
|
+
* that opt into the contract do.
|
|
54
|
+
*/
|
|
19
55
|
protected lastError: string | null = null;
|
|
20
56
|
|
|
21
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* Set a transient error message. Triggers a re-render.
|
|
59
|
+
* The error will be auto-cleared on the next keystroke if the panel extends
|
|
60
|
+
* `ScrollableListPanel` (which calls `clearError()` at the top of `handleInput()`).
|
|
61
|
+
*/
|
|
22
62
|
protected setError(msg: string): void {
|
|
23
63
|
this.lastError = msg;
|
|
24
64
|
this.needsRender = true;
|
|
@@ -66,6 +106,32 @@ export abstract class BasePanel implements Panel {
|
|
|
66
106
|
this.needsRender = true;
|
|
67
107
|
}
|
|
68
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Run an async operation with the panel's loading spinner visible.
|
|
111
|
+
* The spinner is always cleared on completion, whether the operation succeeds or throws
|
|
112
|
+
* (uses try/finally). Rethrows any error so callers can handle it or forward to setError.
|
|
113
|
+
*
|
|
114
|
+
* @param label Optional label shown next to the spinner.
|
|
115
|
+
* @param fn The async work to run.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```ts
|
|
119
|
+
* try {
|
|
120
|
+
* await this.withLoading('Loading diff…', () => this.fetchDiff());
|
|
121
|
+
* } catch (err) {
|
|
122
|
+
* this.setError(summarizeError(err));
|
|
123
|
+
* }
|
|
124
|
+
* ```
|
|
125
|
+
*/
|
|
126
|
+
protected async withLoading<T>(label: string | undefined, fn: () => Promise<T>): Promise<T> {
|
|
127
|
+
this.startLoading(label);
|
|
128
|
+
try {
|
|
129
|
+
return await fn();
|
|
130
|
+
} finally {
|
|
131
|
+
this.stopLoading();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
69
135
|
/**
|
|
70
136
|
* Build a spinner Line for the loading state.
|
|
71
137
|
* Returns null when loadingState is not 'loading'.
|
|
@@ -107,7 +173,17 @@ export abstract class BasePanel implements Panel {
|
|
|
107
173
|
|
|
108
174
|
onActivate(): void { this.needsRender = true; }
|
|
109
175
|
onDeactivate(): void {}
|
|
110
|
-
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Called when the panel is permanently removed. Subclasses should call
|
|
179
|
+
* `super.onDestroy()` to ensure all registered timers are cleared.
|
|
180
|
+
*/
|
|
181
|
+
onDestroy(): void {
|
|
182
|
+
for (const id of this._timers) {
|
|
183
|
+
clearInterval(id as ReturnType<typeof setInterval>);
|
|
184
|
+
}
|
|
185
|
+
this._timers.clear();
|
|
186
|
+
}
|
|
111
187
|
|
|
112
188
|
abstract render(width: number, height: number): Line[];
|
|
113
189
|
|
|
@@ -146,4 +222,33 @@ export abstract class BasePanel implements Panel {
|
|
|
146
222
|
protected reportRenderDuration(durationMs: number, now: number = Date.now()): void {
|
|
147
223
|
this.componentHealthMonitor?.recordRender(this.id, durationMs, now);
|
|
148
224
|
}
|
|
225
|
+
|
|
226
|
+
/** Cache of the most recent lines produced by trackedRender. */
|
|
227
|
+
private _lastTrackedLines: Line[] = [];
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Wrap a render body with canRenderNow throttle check, wall-clock timing,
|
|
231
|
+
* and automatic reportRenderDuration.
|
|
232
|
+
*
|
|
233
|
+
* When throttled, returns the previously cached lines (stale but correctly
|
|
234
|
+
* sized) rather than empty lines, avoiding a flicker on every skipped frame.
|
|
235
|
+
*
|
|
236
|
+
* Usage:
|
|
237
|
+
* ```ts
|
|
238
|
+
* render(width: number, height: number): Line[] {
|
|
239
|
+
* return this.trackedRender(() => {
|
|
240
|
+
* // expensive render logic
|
|
241
|
+
* return lines;
|
|
242
|
+
* });
|
|
243
|
+
* }
|
|
244
|
+
* ```
|
|
245
|
+
*/
|
|
246
|
+
protected trackedRender(fn: () => Line[]): Line[] {
|
|
247
|
+
if (!this.canRenderNow()) return this._lastTrackedLines;
|
|
248
|
+
const start = Date.now();
|
|
249
|
+
const lines = fn();
|
|
250
|
+
this.reportRenderDuration(Date.now() - start);
|
|
251
|
+
this._lastTrackedLines = lines;
|
|
252
|
+
return lines;
|
|
253
|
+
}
|
|
149
254
|
}
|
|
@@ -32,6 +32,7 @@ export class CommunicationPanel extends ScrollableListPanel<CommunicationRecord>
|
|
|
32
32
|
|
|
33
33
|
public constructor(readModel?: UiReadModel<UiCommunicationSnapshot>) {
|
|
34
34
|
super('communication', 'Communication', 'Y', 'monitoring');
|
|
35
|
+
this.showSelectionGutter = true; // I5: non-color selection affordance
|
|
35
36
|
this.readModel = readModel;
|
|
36
37
|
this.unsub = readModel ? readModel.subscribe(() => this.markDirty()) : null;
|
|
37
38
|
}
|
|
@@ -72,6 +72,7 @@ export class ContextVisualizerPanel extends BasePanel {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
render(width: number, height: number): Line[] {
|
|
75
|
+
return this.trackedRender(() => {
|
|
75
76
|
if (height <= 0 || width <= 0) return [];
|
|
76
77
|
|
|
77
78
|
const input = this.snapshot.input;
|
|
@@ -131,6 +132,7 @@ export class ContextVisualizerPanel extends BasePanel {
|
|
|
131
132
|
],
|
|
132
133
|
palette: DEFAULT_PANEL_PALETTE,
|
|
133
134
|
});
|
|
135
|
+
});
|
|
134
136
|
}
|
|
135
137
|
|
|
136
138
|
private _renderBar(width: number, barWidth: number, input: number, limit: number): Line {
|
|
@@ -43,6 +43,7 @@ export class ControlPlanePanel extends ScrollableListPanel<ControlPlaneClient> {
|
|
|
43
43
|
|
|
44
44
|
public constructor(private readonly readModel?: UiReadModel<UiControlPlaneSnapshot>) {
|
|
45
45
|
super('control-plane', 'Control Plane', 'C', 'monitoring');
|
|
46
|
+
this.showSelectionGutter = true; // I5: non-color selection affordance
|
|
46
47
|
this.unsub = readModel ? readModel.subscribe(() => this.markDirty()) : null;
|
|
47
48
|
}
|
|
48
49
|
|
package/src/panels/diff-panel.ts
CHANGED
|
@@ -345,6 +345,7 @@ export class DiffPanel extends BasePanel {
|
|
|
345
345
|
// -------------------------------------------------------------------------
|
|
346
346
|
|
|
347
347
|
render(width: number, height: number): Line[] {
|
|
348
|
+
return this.trackedRender(() => {
|
|
348
349
|
if (height <= 0 || width <= 0) return [];
|
|
349
350
|
|
|
350
351
|
if (this.entries.length === 0) {
|
|
@@ -440,6 +441,7 @@ export class DiffPanel extends BasePanel {
|
|
|
440
441
|
sections,
|
|
441
442
|
footerLines: [this.renderStatusBar(width, entry)],
|
|
442
443
|
});
|
|
444
|
+
});
|
|
443
445
|
}
|
|
444
446
|
|
|
445
447
|
// ── Tab bar ──────────────────────────────────────────────────────────────
|