@pellux/goodvibes-tui 0.21.0 → 0.22.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 (54) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +1 -1
  3. package/package.json +2 -1
  4. package/src/cli/completions/generate.ts +4 -8
  5. package/src/cli/entrypoint.ts +6 -0
  6. package/src/cli/parser.ts +17 -0
  7. package/src/cli/types.ts +2 -0
  8. package/src/config/goodvibes-home-audit.ts +2 -0
  9. package/src/core/context-auto-compact.ts +77 -0
  10. package/src/core/turn-event-wiring.ts +124 -0
  11. package/src/daemon/cli.ts +5 -0
  12. package/src/input/command-registry.ts +1 -0
  13. package/src/input/commands/control-room-runtime.ts +5 -5
  14. package/src/input/commands/provider.ts +57 -3
  15. package/src/input/commands/session-workflow.ts +8 -16
  16. package/src/input/commands/session.ts +70 -20
  17. package/src/input/commands.ts +0 -2
  18. package/src/input/handler-modal-routes.ts +37 -0
  19. package/src/input/handler-modal-token-routes.ts +19 -5
  20. package/src/input/handler-onboarding.ts +18 -0
  21. package/src/input/handler.ts +1 -0
  22. package/src/input/onboarding/onboarding-wizard-apply.ts +10 -0
  23. package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +14 -0
  24. package/src/input/onboarding/onboarding-wizard-steps.ts +6 -0
  25. package/src/input/onboarding/onboarding-wizard-validation.ts +77 -0
  26. package/src/input/settings-modal-behavior.ts +5 -0
  27. package/src/input/settings-modal-data.ts +77 -3
  28. package/src/input/settings-modal-mutations.ts +3 -0
  29. package/src/input/settings-modal-reset.ts +154 -0
  30. package/src/input/settings-modal.ts +55 -13
  31. package/src/main.ts +36 -28
  32. package/src/panels/agent-inspector-panel.ts +120 -18
  33. package/src/panels/agent-inspector-shared.ts +29 -0
  34. package/src/panels/builtin/development.ts +1 -0
  35. package/src/panels/builtin/knowledge.ts +14 -13
  36. package/src/panels/builtin/operations.ts +22 -1
  37. package/src/panels/builtin/shared.ts +7 -0
  38. package/src/panels/cockpit-panel.ts +123 -3
  39. package/src/panels/cockpit-read-model.ts +232 -0
  40. package/src/panels/index.ts +1 -1
  41. package/src/panels/knowledge-graph-panel.ts +84 -0
  42. package/src/panels/memory-panel.ts +370 -40
  43. package/src/panels/session-maintenance.ts +66 -15
  44. package/src/renderer/agent-detail-modal.ts +107 -3
  45. package/src/renderer/context-status-hint.ts +54 -0
  46. package/src/renderer/settings-modal.ts +14 -3
  47. package/src/renderer/shell-surface.ts +10 -0
  48. package/src/runtime/bootstrap-command-parts.ts +4 -0
  49. package/src/runtime/bootstrap-core.ts +24 -0
  50. package/src/runtime/bootstrap-shell.ts +11 -0
  51. package/src/runtime/bootstrap.ts +7 -0
  52. package/src/runtime/services.ts +6 -1
  53. package/src/version.ts +1 -1
  54. package/src/panels/knowledge-panel.ts +0 -343
@@ -0,0 +1,154 @@
1
+ /**
2
+ * settings-modal-reset — pure reset helpers for SettingsModal.
3
+ *
4
+ * These functions encapsulate the three reset operations:
5
+ * - resetSelected: reset the currently selected setting to its schema default
6
+ * - initiateResetCategory: arm the category-reset confirmation gate
7
+ * - initiateResetAll: arm the reset-all confirmation gate
8
+ * - handleResetConfirmKey: route a keypress through the active gate
9
+ *
10
+ * Each function takes its dependencies as explicit arguments rather than
11
+ * accessing class-level state. resetCategoryConfirm and resetAllConfirm
12
+ * remain public class fields on SettingsModal — the renderer reads them
13
+ * directly to decide the footer state.
14
+ */
15
+
16
+ import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
17
+ import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
18
+ import { buildGoodVibesSecretKey, isSecretConfigKey } from '../config/secret-config.ts';
19
+ import type { SettingEntry, SettingsCategory } from './settings-modal-types.ts';
20
+ import type { SettingsSecretsManager } from './settings-modal-secrets.ts';
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // resetSelected
24
+ // ---------------------------------------------------------------------------
25
+
26
+ export function resetSelected({
27
+ editingMode,
28
+ hasConfigManager,
29
+ selected,
30
+ secretsManager,
31
+ setValue,
32
+ }: {
33
+ editingMode: boolean;
34
+ hasConfigManager: boolean;
35
+ selected: SettingEntry | null;
36
+ secretsManager: SettingsSecretsManager | null;
37
+ setValue: (key: ConfigKey, value: unknown) => void;
38
+ }): { key: ConfigKey; value: unknown } | null {
39
+ if (editingMode || !hasConfigManager) return null;
40
+ if (!selected) return null;
41
+ const key = selected.setting.key as ConfigKey;
42
+ setValue(key, selected.setting.default);
43
+ if (isSecretConfigKey(key) && secretsManager) {
44
+ void secretsManager.delete(buildGoodVibesSecretKey(key), { scope: 'user' }).catch((error) => {
45
+ logger.error('SettingsModal: failed to clear secret while resetting setting', { key, error: summarizeError(error) });
46
+ });
47
+ }
48
+ return { key, value: selected.setting.default };
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // initiateResetCategory
53
+ // ---------------------------------------------------------------------------
54
+
55
+ export function initiateResetCategory({
56
+ hasConfigManager,
57
+ currentCategory,
58
+ setResetCategoryConfirm,
59
+ setResetAllConfirm,
60
+ }: {
61
+ hasConfigManager: boolean;
62
+ currentCategory: string;
63
+ setResetCategoryConfirm: (value: { readonly subject: string } | null) => void;
64
+ setResetAllConfirm: (value: { readonly subject: 'all' } | null) => void;
65
+ }): void {
66
+ if (!hasConfigManager) return;
67
+ setResetCategoryConfirm({ subject: currentCategory });
68
+ setResetAllConfirm(null);
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // initiateResetAll
73
+ // ---------------------------------------------------------------------------
74
+
75
+ export function initiateResetAll({
76
+ hasConfigManager,
77
+ setResetCategoryConfirm,
78
+ setResetAllConfirm,
79
+ }: {
80
+ hasConfigManager: boolean;
81
+ setResetCategoryConfirm: (value: { readonly subject: string } | null) => void;
82
+ setResetAllConfirm: (value: { readonly subject: 'all' } | null) => void;
83
+ }): void {
84
+ if (!hasConfigManager) return;
85
+ setResetAllConfirm({ subject: 'all' });
86
+ setResetCategoryConfirm(null);
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // handleResetConfirmKey
91
+ // ---------------------------------------------------------------------------
92
+
93
+ export type ResetConfirmKeyResult =
94
+ | { result: 'confirmed'; entries: ReadonlyArray<{ key: string; value: unknown }> }
95
+ | 'cancelled'
96
+ | 'absorbed'
97
+ | 'inactive';
98
+
99
+ export function handleResetConfirmKey({
100
+ key,
101
+ resetCategoryConfirm,
102
+ resetAllConfirm,
103
+ hasConfigManager,
104
+ currentItems,
105
+ groups,
106
+ setValue,
107
+ setResetCategoryConfirm,
108
+ setResetAllConfirm,
109
+ }: {
110
+ key: string;
111
+ resetCategoryConfirm: { readonly subject: string } | null;
112
+ resetAllConfirm: { readonly subject: 'all' } | null;
113
+ hasConfigManager: boolean;
114
+ currentItems: () => SettingEntry[];
115
+ groups: Map<SettingsCategory, SettingEntry[]>;
116
+ setValue: (key: ConfigKey, value: unknown) => void;
117
+ setResetCategoryConfirm: (value: { readonly subject: string } | null) => void;
118
+ setResetAllConfirm: (value: { readonly subject: 'all' } | null) => void;
119
+ }): ResetConfirmKeyResult {
120
+ const gate = resetCategoryConfirm ?? resetAllConfirm;
121
+ if (!gate || !hasConfigManager) return 'inactive';
122
+
123
+ if (key === 'enter' || key === 'y') {
124
+ const entries: Array<{ key: string; value: unknown }> = [];
125
+ if (resetCategoryConfirm) {
126
+ // Reset all settings in the current category to defaults.
127
+ const items = currentItems();
128
+ for (const item of items) {
129
+ setValue(item.setting.key as ConfigKey, item.setting.default);
130
+ entries.push({ key: item.setting.key, value: item.setting.default });
131
+ }
132
+ setResetCategoryConfirm(null);
133
+ } else {
134
+ // Reset ALL settings across all categories to defaults.
135
+ for (const [, items] of groups) {
136
+ for (const item of items) {
137
+ setValue(item.setting.key as ConfigKey, item.setting.default);
138
+ entries.push({ key: item.setting.key, value: item.setting.default });
139
+ }
140
+ }
141
+ setResetAllConfirm(null);
142
+ }
143
+ return { result: 'confirmed', entries };
144
+ }
145
+
146
+ if (key === 'escape' || key === 'n') {
147
+ setResetCategoryConfirm(null);
148
+ setResetAllConfirm(null);
149
+ return 'cancelled';
150
+ }
151
+
152
+ // All other keys are absorbed while the gate is active.
153
+ return 'absorbed';
154
+ }
@@ -19,7 +19,7 @@ import type { ModelPickerTarget } from './model-picker.ts';
19
19
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
20
20
  import type { SubscriptionManager } from '@pellux/goodvibes-sdk/platform/config';
21
21
  import type { ServiceInspectionQuery } from '../runtime/ui-service-queries.ts';
22
- import { buildGoodVibesSecretKey, isSecretConfigKey } from '../config/secret-config.ts';
22
+ import { isSecretConfigKey } from '../config/secret-config.ts';
23
23
  import {
24
24
  getNumericAdjustmentMeta,
25
25
  modelPickerLaunchForKey,
@@ -32,7 +32,7 @@ import {
32
32
  import type { FeatureFlagManager } from '@/runtime/index.ts';
33
33
  import type { FlagState } from '@/runtime/index.ts';
34
34
  import type { McpRegistry } from '@pellux/goodvibes-sdk/platform/mcp';
35
- import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
35
+
36
36
  import {
37
37
  SETTINGS_CATEGORIES,
38
38
  SETTINGS_CATEGORY_GROUPS,
@@ -60,6 +60,13 @@ import {
60
60
  persistFlagState,
61
61
  type SettingAppliedCallback,
62
62
  } from './settings-modal-mutations.ts';
63
+ import {
64
+ resetSelected as _resetSelected,
65
+ initiateResetCategory as _initiateResetCategory,
66
+ initiateResetAll as _initiateResetAll,
67
+ handleResetConfirmKey as _handleResetConfirmKey,
68
+ type ResetConfirmKeyResult,
69
+ } from './settings-modal-reset.ts';
63
70
 
64
71
  export interface SettingsModalChange {
65
72
  readonly key: ConfigKey;
@@ -124,6 +131,11 @@ export class SettingsModal {
124
131
  /** Provider awaiting explicit logout confirmation, if any. */
125
132
  public subscriptionLogoutConfirmationTarget: string | null = null;
126
133
 
134
+ /** Pending category-reset confirmation gate, or null when inactive. */
135
+ public resetCategoryConfirm: { readonly subject: string } | null = null;
136
+ /** Pending reset-all confirmation gate, or null when inactive. */
137
+ public resetAllConfirm: { readonly subject: 'all' } | null = null;
138
+
127
139
  /** Settings grouped by category. */
128
140
  public groups: Map<SettingsCategory, SettingEntry[]> = new Map();
129
141
 
@@ -674,17 +686,47 @@ export class SettingsModal {
674
686
  }
675
687
 
676
688
  resetSelected(): { key: ConfigKey; value: unknown } | null {
677
- if (this.editingMode || !this.configManager) return null;
678
- const entry = this.getSelected();
679
- if (!entry) return null;
680
- const key = entry.setting.key as ConfigKey;
681
- this._setValue(key, entry.setting.default);
682
- if (isSecretConfigKey(key) && this.secretsManager) {
683
- void this.secretsManager.delete(buildGoodVibesSecretKey(key), { scope: 'user' }).catch((error) => {
684
- logger.error('SettingsModal: failed to clear secret while resetting setting', { key, error: summarizeError(error) });
685
- });
686
- }
687
- return { key, value: entry.setting.default };
689
+ return _resetSelected({
690
+ editingMode: this.editingMode,
691
+ hasConfigManager: this.configManager !== null,
692
+ selected: this.getSelected(),
693
+ secretsManager: this.secretsManager,
694
+ setValue: (key, value) => this._setValue(key, value),
695
+ });
696
+ }
697
+
698
+ /** Arm a category-reset confirmation gate for the current category. */
699
+ initiateResetCategory(): void {
700
+ _initiateResetCategory({
701
+ hasConfigManager: this.configManager !== null,
702
+ currentCategory: this.currentCategory,
703
+ setResetCategoryConfirm: (v) => { this.resetCategoryConfirm = v; },
704
+ setResetAllConfirm: (v) => { this.resetAllConfirm = v; },
705
+ });
706
+ }
707
+
708
+ /** Arm a reset-all confirmation gate. */
709
+ initiateResetAll(): void {
710
+ _initiateResetAll({
711
+ hasConfigManager: this.configManager !== null,
712
+ setResetCategoryConfirm: (v) => { this.resetCategoryConfirm = v; },
713
+ setResetAllConfirm: (v) => { this.resetAllConfirm = v; },
714
+ });
715
+ }
716
+
717
+ /** Route a key through the active reset confirm gate. See ResetConfirmKeyResult for the return contract. */
718
+ handleResetConfirmKey(key: string): ResetConfirmKeyResult {
719
+ return _handleResetConfirmKey({
720
+ key,
721
+ resetCategoryConfirm: this.resetCategoryConfirm,
722
+ resetAllConfirm: this.resetAllConfirm,
723
+ hasConfigManager: this.configManager !== null,
724
+ currentItems: () => this._currentItems(),
725
+ groups: this.groups,
726
+ setValue: (k, value) => this._setValue(k, value),
727
+ setResetCategoryConfirm: (v) => { this.resetCategoryConfirm = v; },
728
+ setResetAllConfirm: (v) => { this.resetAllConfirm = v; },
729
+ });
688
730
  }
689
731
 
690
732
  /** Handle a keystroke in edit mode: regular chars appended, Backspace removes last char. */
package/src/main.ts CHANGED
@@ -33,12 +33,10 @@ import { renderPanelTabBar } from './renderer/panel-tab-bar.ts';
33
33
  import { bootstrapRuntime } from './runtime/bootstrap.ts';
34
34
  import type { BootstrapContext } from './runtime/bootstrap.ts';
35
35
  import type { HITLMode } from '@pellux/goodvibes-sdk/platform/state';
36
- import type { HookPhase, HookCategory, HookEventPath } from '@pellux/goodvibes-sdk/platform/hooks';
37
36
  import {
38
37
  checkRecoveryFile,
39
38
  deleteRecoveryFile,
40
39
  loadRecoveryConversation,
41
- persistConversation,
42
40
  writeRecoveryFile,
43
41
  } from '@/runtime/index.ts';
44
42
  import { handleBlockingShellInput, type PendingPermissionState } from './shell/blocking-input.ts';
@@ -57,6 +55,9 @@ import { buildCommandArgsHint } from './input/command-args-hint.ts';
57
55
  import { summarizeRunningAgents } from './renderer/process-summary.ts';
58
56
  import { formatUserFacingErrorLine } from './core/format-user-error.ts';
59
57
  import { wireStreamEventMetrics, type StreamMetrics } from './core/stream-event-wiring.ts';
58
+ import { wireTurnEventHandlers } from './core/turn-event-wiring.ts';
59
+ import { buildContextStatusHint } from './renderer/context-status-hint.ts';
60
+ import { evaluateSessionMaintenance } from './panels/session-maintenance.ts';
60
61
 
61
62
  const ALT_SCREEN_ENTER = '\x1b[?1049h';
62
63
  const ALT_SCREEN_EXIT = '\x1b[?1049l';
@@ -104,6 +105,7 @@ async function main() {
104
105
  permissionPromptRef,
105
106
  _writeLastSessionPointer: writeLastSessionPointer,
106
107
  systemMessageRouter,
108
+ setOpenAgentDetail,
107
109
  } = ctx;
108
110
  const workingDir = ctx.services.workingDirectory;
109
111
  const homeDirectory = ctx.services.homeDirectory;
@@ -439,6 +441,7 @@ async function main() {
439
441
  input.filePicker.setOnUpdate(() => render());
440
442
  input.agentDetailModal.setOnRefresh(() => render());
441
443
  input.processModal.setOnRefresh(() => render());
444
+ setOpenAgentDetail((id) => input.agentDetailModal.open(id));
442
445
 
443
446
  // Model picker callback is handled in bootstrap.ts — do not duplicate here.
444
447
  input.setHistory(inputHistory);
@@ -479,6 +482,17 @@ async function main() {
479
482
  hasAttachments: input.getImageAttachments().size > 0,
480
483
  turnState: sessionSnapshot.turnState,
481
484
  });
485
+ const maintenanceStatus = evaluateSessionMaintenance({
486
+ configManager,
487
+ currentTokens: orchestrator.lastInputTokens,
488
+ contextWindow: currentModel.contextWindow,
489
+ sessionMemoryCount: ctx.services.sessionMemoryStore.list().length,
490
+ });
491
+ const contextStatusHint = buildContextStatusHint({
492
+ level: maintenanceStatus.level,
493
+ autoCompactEnabled: maintenanceStatus.autoCompactEnabled,
494
+ usagePct: maintenanceStatus.usagePct,
495
+ });
482
496
  const footerLines = buildShellFooter({
483
497
  width,
484
498
  promptText: promptInfo.visibleLines.join('\n'),
@@ -496,6 +510,7 @@ async function main() {
496
510
  workingDir,
497
511
  provider: runtime.provider,
498
512
  contextWindow: currentModel.contextWindow,
513
+ contextStatusHint,
499
514
  // behavior.autoCompactThreshold is stored as a percent integer (e.g. 80);
500
515
  // the meter expects a fraction [0..1]. Clamp to [0,1] to guard nonsense values.
501
516
  compactThreshold: Math.min(1, Math.max(0, (configManager.get('behavior.autoCompactThreshold') as number) / 100)),
@@ -674,32 +689,25 @@ async function main() {
674
689
  render,
675
690
  });
676
691
 
677
- // --- Streaming speed + tool preview wiring ---
678
- const refreshGit = () => gitStatusProvider.refresh().then((info) => { lastGitInfoRef.value = info; render(); }).catch(() => { /* non-fatal */ });
679
- // Refresh git status after each turn completes or after tool results arrive
680
- unsubs.push(uiServices.events.turns.on('TURN_COMPLETED', () => {
681
- // Auto-save after every LLM turn so kills don't lose the session
682
- try {
683
- const snapshot = conversation.toJSON() as { messages: Array<import('./core/conversation.ts').ConversationMessageSnapshot>; timestamp?: number };
684
- const persisted = buildPersistedSessionContext(snapshot.messages, conversation.getTitleSource(), buildSessionContinuityHints());
685
- persistConversation(
686
- runtime.sessionId,
687
- { ...snapshot, ...persisted },
688
- runtime.model,
689
- runtime.provider,
690
- conversation.title || '',
691
- { workingDirectory: workingDir, homeDirectory, sessionManager: ctx.services.sessionManager },
692
- );
693
- hookDispatcher.fire({ path: 'Lifecycle:session:save' as HookEventPath, phase: 'Lifecycle' as HookPhase, category: 'session' as HookCategory, specific: 'save', sessionId: runtime.sessionId, timestamp: Date.now(), payload: { sessionId: runtime.sessionId } }).catch((err: unknown) => logger.debug('hook fire error', { error: summarizeError(err) }));
694
- } catch (e) { logger.debug('auto-save on turn:complete failed', { error: summarizeError(e) }); }
695
- refreshGit();
696
- }));
697
- unsubs.push(uiServices.events.tools.on('TOOL_SUCCEEDED', () => {
698
- refreshGit();
699
- }));
700
- unsubs.push(uiServices.events.tools.on('TOOL_FAILED', () => {
701
- refreshGit();
702
- }));
692
+ // --- Turn-completed / git-refresh event wiring ---
693
+ const { refreshGit, unsubs: turnUnsubs } = wireTurnEventHandlers({
694
+ events: uiServices.events,
695
+ conversation,
696
+ runtime,
697
+ orchestrator,
698
+ configManager,
699
+ providerRegistry,
700
+ systemMessageRouter,
701
+ hookDispatcher,
702
+ workingDir,
703
+ homeDirectory,
704
+ sessionManager: ctx.services.sessionManager,
705
+ gitStatusProvider,
706
+ lastGitInfoRef,
707
+ buildSessionContinuityHints,
708
+ render,
709
+ });
710
+ unsubs.push(...turnUnsubs);
703
711
 
704
712
  // --- Stream metrics + tool-timer event wiring ---
705
713
  const streamUnsubs = wireStreamEventMetrics({
@@ -21,6 +21,10 @@ import {
21
21
  resolveScrollablePanelSection,
22
22
  DEFAULT_PANEL_PALETTE,
23
23
  } from './polish.ts';
24
+ import {
25
+ type ConfirmState,
26
+ handleConfirmInput,
27
+ } from './confirm-state.ts';
24
28
  import { truncateDisplay } from '../utils/terminal-width.ts';
25
29
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
26
30
  import {
@@ -32,6 +36,8 @@ import {
32
36
  formatAgentDuration as formatMs,
33
37
  formatAgentTime as shortTime,
34
38
  jsonlToTimeline,
39
+ AGENT_TERMINAL_STATUSES,
40
+ AGENT_STALL_THRESHOLD_MS,
35
41
  } from './agent-inspector-shared.ts';
36
42
 
37
43
  // ---------------------------------------------------------------------------
@@ -77,10 +83,14 @@ const COLOR = {
77
83
  // AgentInspectorPanel
78
84
  // ---------------------------------------------------------------------------
79
85
 
86
+ // AGENT_TERMINAL_STATUSES and AGENT_STALL_THRESHOLD_MS imported from agent-inspector-shared.ts
87
+
80
88
  export interface AgentInspectorPanelDeps {
81
- readonly agentManager: Pick<AgentManager, 'list' | 'getStatus'>;
89
+ readonly agentManager: Pick<AgentManager, 'list' | 'getStatus' | 'cancel'>;
82
90
  readonly agentMessageBus: Pick<AgentMessageBus, 'getMessages'>;
83
91
  readonly workingDirectory: string;
92
+ /** Cancel the agent by id. Uses the same orphan-free path as WRFC. Returns true if cancelled. */
93
+ readonly cancelAgent: (agentId: string) => boolean;
84
94
  }
85
95
 
86
96
  export class AgentInspectorPanel extends BasePanel {
@@ -102,6 +112,9 @@ export class AgentInspectorPanel extends BasePanel {
102
112
  // Row cache — cleared on markDirty(), computed once per render cycle
103
113
  private _cachedRows: DisplayRow[] | null = null;
104
114
 
115
+ /** Pending cancel confirmation — subject is the agent id to cancel. */
116
+ private confirmCancel: ConfirmState<string> | null = null;
117
+
105
118
  constructor(private readonly deps: AgentInspectorPanelDeps) {
106
119
  super('inspector', 'Inspector', 'I', 'agent');
107
120
  }
@@ -157,13 +170,36 @@ export class AgentInspectorPanel extends BasePanel {
157
170
  // -------------------------------------------------------------------------
158
171
 
159
172
  handleInput(key: string): boolean {
173
+ // Confirm-cancel flow takes priority — same contract as WRFC panel.
174
+ if (this.confirmCancel) {
175
+ const result = handleConfirmInput(this.confirmCancel, key);
176
+ if (result === 'confirmed') {
177
+ const rec = this.selectedAgentId
178
+ ? this.deps.agentManager.getStatus(this.selectedAgentId)
179
+ : null;
180
+ if (rec && !AGENT_TERMINAL_STATUSES.has(rec.status)) {
181
+ this.deps.cancelAgent(rec.id);
182
+ }
183
+ this.confirmCancel = null;
184
+ this.markDirty();
185
+ return true;
186
+ }
187
+ if (result === 'cancelled') {
188
+ this.confirmCancel = null;
189
+ this.markDirty();
190
+ }
191
+ // absorbed: confirm stays pending
192
+ return true;
193
+ }
194
+
160
195
  switch (key) {
161
- case 'up': this._moveCursor(-1); return true;
162
- case 'down': this._moveCursor(1); return true;
163
- case 'pageup': this._scroll(-10); return true;
164
- case 'pagedown': this._scroll(10); return true;
165
- case 'return': this._toggleExpand(); return true;
166
- case 'tab': this._nextAgent(); return true;
196
+ case 'up': this._moveCursor(-1); return true;
197
+ case 'down': this._moveCursor(1); return true;
198
+ case 'pageup': this._scroll(-10); return true;
199
+ case 'pagedown': this._scroll(10); return true;
200
+ case 'return': this._toggleExpand(); return true;
201
+ case 'tab': this._nextAgent(); return true;
202
+ case 'c': this._beginCancelConfirm(); return true;
167
203
  default: return false;
168
204
  }
169
205
  }
@@ -217,6 +253,11 @@ export class AgentInspectorPanel extends BasePanel {
217
253
  }
218
254
 
219
255
  summaryLines.push(this._renderAgentInfoSummary(width, rec));
256
+ const now = Date.now();
257
+ const isStalled = this._isAgentStalled(rec, now);
258
+ if (isStalled) {
259
+ summaryLines.push(buildPanelLine(width, [[' STALLED', '#f59e0b'], [' — no activity for 5+ minutes', DEFAULT_PANEL_PALETTE.dim]]));
260
+ }
220
261
  const allRows = this._getCachedRows();
221
262
  if (allRows.length === 0) {
222
263
  return buildPanelWorkspace(width, height, {
@@ -243,13 +284,42 @@ export class AgentInspectorPanel extends BasePanel {
243
284
  }
244
285
 
245
286
  this.cursorIndex = Math.max(0, Math.min(this.cursorIndex, allRows.length - 1));
287
+ const selectedRec = this.selectedAgentId
288
+ ? this.deps.agentManager.getStatus(this.selectedAgentId)
289
+ : null;
290
+ const cancellable = selectedRec && !AGENT_TERMINAL_STATUSES.has(selectedRec.status);
246
291
  const summarySection = { title: 'Summary', lines: summaryLines } as const;
247
292
  const agentsSection = { title: 'Agents', lines: [selectorLine] } as const;
293
+
294
+ // Confirm-cancel overlay section.
295
+ const confirmSection = this.confirmCancel ? {
296
+ title: 'Confirm Cancel',
297
+ lines: [
298
+ buildPanelLine(width, [
299
+ [' Cancel agent "', DEFAULT_PANEL_PALETTE.warn],
300
+ [this.confirmCancel.label, DEFAULT_PANEL_PALETTE.value],
301
+ ['"?', DEFAULT_PANEL_PALETTE.warn],
302
+ ]),
303
+ buildPanelLine(width, [
304
+ [' y', DEFAULT_PANEL_PALETTE.info], [' confirm', DEFAULT_PANEL_PALETTE.dim],
305
+ [' Enter', DEFAULT_PANEL_PALETTE.info], [' confirm', DEFAULT_PANEL_PALETTE.dim],
306
+ [' n / Esc', DEFAULT_PANEL_PALETTE.info], [' cancel', DEFAULT_PANEL_PALETTE.dim],
307
+ ]),
308
+ ],
309
+ } : null;
310
+
311
+ const cancelHintFg = cancellable ? DEFAULT_PANEL_PALETTE.info : DEFAULT_PANEL_PALETTE.dim;
312
+ const footerLine = buildPanelLine(width, [
313
+ [` L${this.cursorIndex + 1}/${allRows.length}`, DEFAULT_PANEL_PALETTE.dim],
314
+ [' Tab', DEFAULT_PANEL_PALETTE.info], [' cycle agents', DEFAULT_PANEL_PALETTE.dim],
315
+ [' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim],
316
+ [' Enter', DEFAULT_PANEL_PALETTE.info], [' expand', DEFAULT_PANEL_PALETTE.dim],
317
+ [' c', cancelHintFg], [cancellable ? ' cancel' : ' cancel (n/a)', DEFAULT_PANEL_PALETTE.dim],
318
+ ]);
319
+
248
320
  const timelineSection = resolveScrollablePanelSection(width, height, {
249
321
  intro: 'Inspect a selected agent timeline, tool activity, expanded details, and live/historical message flow.',
250
- footerLines: [
251
- buildPanelLine(width, [[` L${this.cursorIndex + 1}/${allRows.length}`, DEFAULT_PANEL_PALETTE.dim], [' Tab', DEFAULT_PANEL_PALETTE.info], [' cycle agents', DEFAULT_PANEL_PALETTE.dim], [' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Enter', DEFAULT_PANEL_PALETTE.info], [' expand', DEFAULT_PANEL_PALETTE.dim]]),
252
- ],
322
+ footerLines: [footerLine],
253
323
  palette: DEFAULT_PANEL_PALETTE,
254
324
  beforeSections: [summarySection, agentsSection],
255
325
  section: {
@@ -259,20 +329,22 @@ export class AgentInspectorPanel extends BasePanel {
259
329
  scrollOffset: this.scrollOffset,
260
330
  minRows: 8,
261
331
  },
332
+ afterSections: confirmSection ? [confirmSection] : undefined,
262
333
  });
263
334
  this.scrollOffset = timelineSection.scrollOffset;
264
335
 
336
+ const sections = [
337
+ summarySection,
338
+ agentsSection,
339
+ timelineSection.section,
340
+ ...(confirmSection ? [confirmSection] : []),
341
+ ];
342
+
265
343
  return buildPanelWorkspace(width, height, {
266
344
  title: ` Inspector [${agents.length} agent${agents.length !== 1 ? 's' : ''}]`,
267
345
  intro: 'Inspect a selected agent timeline, tool activity, expanded details, and live/historical message flow.',
268
- sections: [
269
- summarySection,
270
- agentsSection,
271
- timelineSection.section,
272
- ],
273
- footerLines: [
274
- buildPanelLine(width, [[` L${this.cursorIndex + 1}/${allRows.length}`, DEFAULT_PANEL_PALETTE.dim], [' Tab', DEFAULT_PANEL_PALETTE.info], [' cycle agents', DEFAULT_PANEL_PALETTE.dim], [' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Enter', DEFAULT_PANEL_PALETTE.info], [' expand', DEFAULT_PANEL_PALETTE.dim]]),
275
- ],
346
+ sections,
347
+ footerLines: [footerLine],
276
348
  palette: DEFAULT_PANEL_PALETTE,
277
349
  });
278
350
  }
@@ -517,4 +589,34 @@ export class AgentInspectorPanel extends BasePanel {
517
589
  this.inspectAgent(next.id);
518
590
  }
519
591
  }
592
+
593
+ // -------------------------------------------------------------------------
594
+ // Private — cancel + stall
595
+ // -------------------------------------------------------------------------
596
+
597
+ /** Initiate cancel-confirm flow for the selected agent (noop if terminal or none selected). */
598
+ private _beginCancelConfirm(): void {
599
+ if (!this.selectedAgentId) return;
600
+ const rec = this.deps.agentManager.getStatus(this.selectedAgentId);
601
+ if (!rec || AGENT_TERMINAL_STATUSES.has(rec.status)) return;
602
+ const label = rec.task.split('\n')[0]?.slice(0, 40) ?? rec.id.slice(-8);
603
+ this.confirmCancel = { subject: rec.id, label };
604
+ this.markDirty();
605
+ }
606
+
607
+ /** Returns whether an agent is considered stalled (non-terminal, running past threshold). */
608
+ private _isAgentStalled(rec: AgentRecord, now: number): boolean {
609
+ if (AGENT_TERMINAL_STATUSES.has(rec.status)) return false;
610
+ return (now - rec.startedAt) >= AGENT_STALL_THRESHOLD_MS;
611
+ }
612
+
613
+ /**
614
+ * Count of all tracked agents that are stalled (non-terminal, no activity
615
+ * for AGENT_STALL_THRESHOLD_MS). Exposed so callers can aggregate a
616
+ * stalledAgentCount for cockpit / roster read-models.
617
+ */
618
+ getStalledAgentCount(): number {
619
+ const now = Date.now();
620
+ return this.deps.agentManager.list().filter(rec => this._isAgentStalled(rec, now)).length;
621
+ }
520
622
  }
@@ -1,5 +1,34 @@
1
1
  export type AgentInspectorEntryKind = 'user' | 'assistant' | 'tool_call' | 'tool_result' | 'session' | 'error';
2
2
 
3
+ // ---------------------------------------------------------------------------
4
+ // Shared agent status / stall constants
5
+ // Used by AgentInspectorPanel, AgentDetailModal, and cockpit read-model consumers.
6
+ // ---------------------------------------------------------------------------
7
+
8
+ /** Terminal statuses — cancel not offered; stall check skipped. */
9
+ export const AGENT_TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled']);
10
+
11
+ /** Agents in a non-terminal state for longer than this are considered STALLED. */
12
+ export const AGENT_STALL_THRESHOLD_MS = 5 * 60 * 1000;
13
+
14
+ /**
15
+ * Count stalled agents from a raw record list.
16
+ * An agent is stalled when it is non-terminal and has been running for at
17
+ * least AGENT_STALL_THRESHOLD_MS without completing.
18
+ *
19
+ * Extracted as a standalone export so read-models and panels can share the
20
+ * canonical stall-count logic (TASK-046).
21
+ */
22
+ export function countStalledAgents(
23
+ records: ReadonlyArray<{ status: string; startedAt: number }>,
24
+ now: number = Date.now(),
25
+ ): number {
26
+ return records.filter(
27
+ (r) => !AGENT_TERMINAL_STATUSES.has(r.status) && (now - r.startedAt) >= AGENT_STALL_THRESHOLD_MS,
28
+ ).length;
29
+ }
30
+
31
+
3
32
  export interface AgentTimelineEntry {
4
33
  kind: AgentInspectorEntryKind;
5
34
  timestamp: number;
@@ -51,6 +51,7 @@ export function registerDevelopmentPanels(manager: PanelManager, deps: ResolvedB
51
51
  agentManager: ui.agents.agentManager,
52
52
  agentMessageBus: ui.agents.agentMessageBus,
53
53
  workingDirectory: ui.environment.workingDirectory,
54
+ cancelAgent: (agentId: string) => ui.agents.agentManager.cancel(agentId),
54
55
  });
55
56
  },
56
57
  });
@@ -1,26 +1,27 @@
1
1
  import type { PanelManager } from '../panel-manager.ts';
2
2
  import { MemoryPanel } from '../memory-panel.ts';
3
- import { KnowledgePanel } from '../knowledge-panel.ts';
3
+ import { KnowledgeGraphPanel } from '../knowledge-graph-panel.ts';
4
4
  import type { ResolvedBuiltinPanelDeps } from './shared.ts';
5
5
 
6
6
  export function registerKnowledgePanels(manager: PanelManager, deps: ResolvedBuiltinPanelDeps): void {
7
- if (!deps.memoryRegistry) return;
8
-
9
- const { memoryRegistry } = deps;
7
+ // KnowledgeGraphPanel is a no-arg panel — always register it regardless of memoryRegistry.
10
8
  manager.registerType({
11
9
  id: 'knowledge',
12
10
  name: 'Knowledge',
13
11
  icon: 'K',
14
12
  category: 'agent',
15
13
  description: 'Structured project knowledge: risks, runbooks, architecture notes, incidents, and durable facts',
16
- factory: () => new KnowledgePanel(memoryRegistry),
17
- });
18
- manager.registerType({
19
- id: 'memory',
20
- name: 'Memory',
21
- icon: 'M',
22
- category: 'agent',
23
- description: 'Project memory: decisions, constraints, incidents, and patterns with provenance links',
24
- factory: () => new MemoryPanel(memoryRegistry),
14
+ factory: () => new KnowledgeGraphPanel(),
25
15
  });
16
+ if (deps.memoryRegistry) {
17
+ const { memoryRegistry } = deps;
18
+ manager.registerType({
19
+ id: 'memory',
20
+ name: 'Memory',
21
+ icon: 'M',
22
+ category: 'agent',
23
+ description: 'Project memory: decisions, constraints, incidents, and patterns with provenance links',
24
+ factory: () => new MemoryPanel(memoryRegistry),
25
+ });
26
+ }
26
27
  }