@pellux/goodvibes-tui 0.21.0 → 0.23.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 (70) hide show
  1. package/CHANGELOG.md +45 -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/management-commands.ts +1 -1
  7. package/src/cli/management-utils.ts +352 -0
  8. package/src/cli/management.ts +36 -334
  9. package/src/cli/parser.ts +17 -0
  10. package/src/cli/surface-command.ts +1 -1
  11. package/src/cli/types.ts +2 -0
  12. package/src/config/goodvibes-home-audit.ts +2 -0
  13. package/src/core/context-auto-compact.ts +110 -0
  14. package/src/core/conversation-rendering.ts +5 -2
  15. package/src/core/conversation-types.ts +24 -0
  16. package/src/core/conversation.ts +7 -12
  17. package/src/core/stream-event-wiring.ts +125 -7
  18. package/src/core/turn-event-wiring.ts +124 -0
  19. package/src/daemon/cli.ts +5 -0
  20. package/src/input/command-registry.ts +1 -0
  21. package/src/input/commands/channel-runtime.ts +139 -0
  22. package/src/input/commands/control-room-runtime.ts +5 -5
  23. package/src/input/commands/provider.ts +57 -3
  24. package/src/input/commands/runtime-services.ts +30 -1
  25. package/src/input/commands/session-workflow.ts +8 -16
  26. package/src/input/commands/session.ts +70 -20
  27. package/src/input/commands/share-runtime.ts +1 -1
  28. package/src/input/commands/shell-core.ts +54 -4
  29. package/src/input/commands.ts +2 -2
  30. package/src/input/handler-modal-routes.ts +37 -0
  31. package/src/input/handler-modal-token-routes.ts +19 -5
  32. package/src/input/handler-onboarding.ts +18 -0
  33. package/src/input/handler.ts +1 -0
  34. package/src/input/onboarding/onboarding-wizard-apply.ts +10 -0
  35. package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +14 -0
  36. package/src/input/onboarding/onboarding-wizard-steps.ts +6 -0
  37. package/src/input/onboarding/onboarding-wizard-validation.ts +77 -0
  38. package/src/input/settings-modal-behavior.ts +5 -0
  39. package/src/input/settings-modal-data.ts +77 -3
  40. package/src/input/settings-modal-mutations.ts +3 -0
  41. package/src/input/settings-modal-reset.ts +154 -0
  42. package/src/input/settings-modal.ts +55 -13
  43. package/src/main.ts +58 -50
  44. package/src/panels/agent-inspector-panel.ts +120 -18
  45. package/src/panels/agent-inspector-shared.ts +29 -0
  46. package/src/panels/builtin/development.ts +1 -0
  47. package/src/panels/builtin/knowledge.ts +14 -13
  48. package/src/panels/builtin/operations.ts +22 -1
  49. package/src/panels/builtin/shared.ts +7 -0
  50. package/src/panels/cockpit-panel.ts +123 -3
  51. package/src/panels/cockpit-read-model.ts +232 -0
  52. package/src/panels/index.ts +1 -1
  53. package/src/panels/knowledge-graph-panel.ts +84 -0
  54. package/src/panels/memory-panel.ts +370 -40
  55. package/src/panels/session-maintenance.ts +66 -15
  56. package/src/renderer/agent-detail-modal.ts +107 -3
  57. package/src/renderer/compaction-history-modal.ts +55 -0
  58. package/src/renderer/compaction-preview.ts +146 -0
  59. package/src/renderer/context-status-hint.ts +54 -0
  60. package/src/renderer/settings-modal-helpers.ts +2 -2
  61. package/src/renderer/settings-modal.ts +14 -3
  62. package/src/renderer/shell-surface.ts +10 -0
  63. package/src/runtime/bootstrap-command-parts.ts +4 -0
  64. package/src/runtime/bootstrap-core.ts +116 -0
  65. package/src/runtime/bootstrap-shell.ts +11 -0
  66. package/src/runtime/bootstrap.ts +7 -0
  67. package/src/runtime/services.ts +6 -1
  68. package/src/utils/browser.ts +29 -0
  69. package/src/version.ts +1 -1
  70. package/src/panels/knowledge-panel.ts +0 -343
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { CONFIG_SCHEMA, type ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
10
- import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
10
+ import type { ConfigManager, ConfigSetting } from '@pellux/goodvibes-sdk/platform/config';
11
11
  import { getResolvedSettingLookup } from '@/runtime/index.ts';
12
12
  import type { FeatureFlagManager } from '@/runtime/index.ts';
13
13
  import type { McpRegistry } from '@pellux/goodvibes-sdk/platform/mcp';
@@ -114,9 +114,63 @@ export function buildSettingGroups(
114
114
  }
115
115
  }
116
116
 
117
+ // Inject the synthetic tts.speed entry into the tts category.
118
+ // tts.speed is not yet a ConfigKey in the SDK schema (pending SDK addition).
119
+ // The entry is surfaced here with an honest description caveat so users can
120
+ // see and understand the setting before the SDK schema catches up.
121
+ if (ttsEntries && !ttsEntries.some((e) => e.setting.key === ('tts.speed' as ConfigKey))) {
122
+ ttsEntries.push(buildTtsSpeedSyntheticEntry(configManager));
123
+ }
124
+
117
125
  return groups;
118
126
  }
119
127
 
128
+ // ---------------------------------------------------------------------------
129
+ // TTS_SPEED_DEFAULT — the pending-SDK default for tts.speed
130
+ // ---------------------------------------------------------------------------
131
+
132
+ /**
133
+ * Pending default for tts.speed. Matches the value the SDK will use once
134
+ * the schema field is added: 1 (normal speed, provider default).
135
+ * Used for the synthetic settings-modal entry and isDefault comparisons.
136
+ */
137
+ export const TTS_SPEED_DEFAULT = 1;
138
+
139
+ /**
140
+ * The synthetic ConfigSetting descriptor for tts.speed.
141
+ * `tts.speed` is not yet a ConfigKey in the SDK schema. This descriptor is
142
+ * TUI-local and is injected into the tts settings group so users can see
143
+ * and interact with the setting before the SDK schema catches up.
144
+ *
145
+ * The key is cast to ConfigKey because ConfigSetting requires it and the SDK
146
+ * will add this key in a future release. The cast is safe: configManager.get
147
+ * returns undefined for unknown keys rather than throwing.
148
+ */
149
+ export const TTS_SPEED_SYNTHETIC_SETTING: ConfigSetting = {
150
+ key: 'tts.speed' as ConfigKey,
151
+ type: 'number',
152
+ default: TTS_SPEED_DEFAULT,
153
+ description: 'Playback speed multiplier passed to the TTS provider (1.0 = normal). Takes effect immediately via the TUI bridge; SDK schema registration is pending (native typing only).',
154
+ };
155
+
156
+ /**
157
+ * Build the synthetic SettingEntry for tts.speed.
158
+ *
159
+ * Reads the raw value from configManager using a cast key (tts.speed is not
160
+ * yet a valid ConfigKey). If the value is absent or not a positive finite
161
+ * number, falls back to TTS_SPEED_DEFAULT and marks isDefault true.
162
+ */
163
+ export function buildTtsSpeedSyntheticEntry(configManager: Pick<ConfigManager, 'get'>): SettingEntry {
164
+ const raw = configManager.get('tts.speed' as ConfigKey);
165
+ const parsed = typeof raw === 'number' ? raw : parseFloat(String(raw ?? ''));
166
+ const currentValue: number = isFinite(parsed) && parsed > 0 ? parsed : TTS_SPEED_DEFAULT;
167
+ return {
168
+ setting: TTS_SPEED_SYNTHETIC_SETTING,
169
+ currentValue,
170
+ isDefault: deepEqual(currentValue, TTS_SPEED_DEFAULT),
171
+ };
172
+ }
173
+
120
174
  // ---------------------------------------------------------------------------
121
175
  // buildFlagEntries — snapshot of current feature flag states
122
176
  // ---------------------------------------------------------------------------
@@ -177,13 +231,31 @@ export function buildNetworkFilteredItems(
177
231
  // refreshEntryValues — re-reads currentValue/isDefault for all loaded entries
178
232
  // ---------------------------------------------------------------------------
179
233
 
234
+ /**
235
+ * Normalize a raw config value for the tts.speed synthetic entry.
236
+ * Returns the raw value if it is a positive finite number, otherwise falls
237
+ * back to TTS_SPEED_DEFAULT. Mirrors the logic in buildTtsSpeedSyntheticEntry.
238
+ */
239
+ function normalizeTtsSpeedValue(raw: unknown): number {
240
+ const parsed = typeof raw === 'number' ? raw : parseFloat(String(raw ?? ''));
241
+ return isFinite(parsed) && parsed > 0 ? parsed : TTS_SPEED_DEFAULT;
242
+ }
243
+
180
244
  export function refreshEntryValues(
181
245
  groups: Map<SettingsCategory, SettingEntry[]>,
182
246
  configManager: ConfigManager,
183
247
  ): void {
184
248
  for (const entries of groups.values()) {
185
249
  for (const entry of entries) {
186
- entry.currentValue = configManager.get(entry.setting.key as ConfigKey);
250
+ const raw = configManager.get(entry.setting.key as ConfigKey);
251
+ // Synthetic entries (e.g. tts.speed) that have no SDK schema key return
252
+ // undefined from configManager. Normalize using the same logic used at
253
+ // construction time so isDefault stays accurate.
254
+ if (entry.setting.key === ('tts.speed' as ConfigKey)) {
255
+ entry.currentValue = normalizeTtsSpeedValue(raw);
256
+ } else {
257
+ entry.currentValue = raw;
258
+ }
187
259
  entry.isDefault = deepEqual(entry.currentValue, entry.setting.default);
188
260
  }
189
261
  }
@@ -201,7 +273,9 @@ export function updateEntryForKey(
201
273
  for (const entries of groups.values()) {
202
274
  const entry = entries.find((candidate) => candidate.setting.key === key);
203
275
  if (entry) {
204
- entry.currentValue = configManager.get(key);
276
+ const raw = configManager.get(key);
277
+ // Synthetic tts.speed entry: normalize using the same fallback logic.
278
+ entry.currentValue = key === ('tts.speed' as ConfigKey) ? normalizeTtsSpeedValue(raw) : raw;
205
279
  entry.isDefault = deepEqual(entry.currentValue, entry.setting.default);
206
280
  }
207
281
  }
@@ -55,6 +55,9 @@ export function applySettingValue({
55
55
  refreshGroups: () => void;
56
56
  }): ApplyValueResult {
57
57
  const previousValue = configManager.get(key);
58
+ // REQUIRES_RESTART: SDK's ConfigSetting has no requiresRestart field yet (see
59
+ // goodvibes-sdk HANDOFF-FROM-TUI-SESSION-20260611.md §Item 8). Until it does,
60
+ // we detect restart-triggering keys by sub-key name heuristic below.
58
61
  const isRestartKey = ['host', 'port', 'hostMode', 'enabled'].includes(key.split('.')[1] ?? '');
59
62
 
60
63
  try {
@@ -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';
@@ -56,19 +54,16 @@ import { allowTerminalWrite, installTuiTerminalOutputGuard } from './runtime/ter
56
54
  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
- import { wireStreamEventMetrics, type StreamMetrics } from './core/stream-event-wiring.ts';
60
-
61
- const ALT_SCREEN_ENTER = '\x1b[?1049h';
62
- const ALT_SCREEN_EXIT = '\x1b[?1049l';
63
- const MOUSE_ENABLE = '\x1b[?1000h\x1b[?1002h\x1b[?1006h';
64
- const MOUSE_DISABLE = '\x1b[?1006l\x1b[?1002l\x1b[?1000l';
65
- const CURSOR_HIDE = '\x1b[?25l';
66
- const CURSOR_SHOW = '\x1b[?25h';
67
- const CLEAR_SCREEN = '\x1b[2J\x1b[3J\x1b[H';
68
- const KEYBOARD_EXT_ENABLE = '\x1b[>4;2m' + '\x1b[?1u';
69
- const KEYBOARD_EXT_DISABLE = '\x1b[>4;0m' + '\x1b[?1l';
70
- const PASTE_ENABLE = '\x1b[?2004h';
71
- const PASTE_DISABLE = '\x1b[?2004l';
57
+ import { wireStreamEventMetrics, type StreamMetrics, type WireStreamEventMetricsResult } 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';
61
+
62
+ const ALT_SCREEN_ENTER = '\x1b[?1049h'; const ALT_SCREEN_EXIT = '\x1b[?1049l';
63
+ const MOUSE_ENABLE = '\x1b[?1000h\x1b[?1002h\x1b[?1006h'; const MOUSE_DISABLE = '\x1b[?1006l\x1b[?1002l\x1b[?1000l';
64
+ const CURSOR_HIDE = '\x1b[?25l'; const CURSOR_SHOW = '\x1b[?25h'; const CLEAR_SCREEN = '\x1b[2J\x1b[3J\x1b[H';
65
+ const KEYBOARD_EXT_ENABLE = '\x1b[>4;2m' + '\x1b[?1u'; const KEYBOARD_EXT_DISABLE = '\x1b[>4;0m' + '\x1b[?1l';
66
+ const PASTE_ENABLE = '\x1b[?2004h'; const PASTE_DISABLE = '\x1b[?2004l';
72
67
 
73
68
  async function main() {
74
69
  const stdout = process.stdout;
@@ -104,6 +99,7 @@ async function main() {
104
99
  permissionPromptRef,
105
100
  _writeLastSessionPointer: writeLastSessionPointer,
106
101
  systemMessageRouter,
102
+ setOpenAgentDetail,
107
103
  } = ctx;
108
104
  const workingDir = ctx.services.workingDirectory;
109
105
  const homeDirectory = ctx.services.homeDirectory;
@@ -313,10 +309,11 @@ async function main() {
313
309
  }
314
310
  if (processedText || content) {
315
311
  void (async () => {
316
- let inputOptions = options.spokenOutput ? createSpokenTurnInputOptions() : undefined;
317
- if (options.spokenOutput && processedText) {
318
- spokenTurns.submitNextTurn(processedText);
319
- }
312
+ const inputOptions = options.spokenOutput ? createSpokenTurnInputOptions() : undefined;
313
+ if (options.spokenOutput && processedText) { spokenTurns.submitNextTurn(processedText); }
314
+ // Snapshot pre-submission state for failover retryTurn; also clears visited set.
315
+ retryCtx = { count: conversation.getMessageCount(), text: processedText, content, opts: inputOptions };
316
+ streamResult.clearFailoverVisited();
320
317
  orchestrator.handleUserInput(processedText, content, inputOptions).catch((err: unknown) => {
321
318
  logger.debug('handleUserInput safety catch (already handled by runTurn)', { error: summarizeError(err) });
322
319
  });
@@ -439,6 +436,7 @@ async function main() {
439
436
  input.filePicker.setOnUpdate(() => render());
440
437
  input.agentDetailModal.setOnRefresh(() => render());
441
438
  input.processModal.setOnRefresh(() => render());
439
+ setOpenAgentDetail((id) => input.agentDetailModal.open(id));
442
440
 
443
441
  // Model picker callback is handled in bootstrap.ts — do not duplicate here.
444
442
  input.setHistory(inputHistory);
@@ -479,6 +477,17 @@ async function main() {
479
477
  hasAttachments: input.getImageAttachments().size > 0,
480
478
  turnState: sessionSnapshot.turnState,
481
479
  });
480
+ const maintenanceStatus = evaluateSessionMaintenance({
481
+ configManager,
482
+ currentTokens: orchestrator.lastInputTokens,
483
+ contextWindow: currentModel.contextWindow,
484
+ sessionMemoryCount: ctx.services.sessionMemoryStore.list().length,
485
+ });
486
+ const contextStatusHint = buildContextStatusHint({
487
+ level: maintenanceStatus.level,
488
+ autoCompactEnabled: maintenanceStatus.autoCompactEnabled,
489
+ usagePct: maintenanceStatus.usagePct,
490
+ });
482
491
  const footerLines = buildShellFooter({
483
492
  width,
484
493
  promptText: promptInfo.visibleLines.join('\n'),
@@ -496,6 +505,7 @@ async function main() {
496
505
  workingDir,
497
506
  provider: runtime.provider,
498
507
  contextWindow: currentModel.contextWindow,
508
+ contextStatusHint,
499
509
  // behavior.autoCompactThreshold is stored as a percent integer (e.g. 80);
500
510
  // the meter expects a fraction [0..1]. Clamp to [0,1] to guard nonsense values.
501
511
  compactThreshold: Math.min(1, Math.max(0, (configManager.get('behavior.autoCompactThreshold') as number) / 100)),
@@ -674,43 +684,41 @@ async function main() {
674
684
  render,
675
685
  });
676
686
 
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
- }));
703
-
704
- // --- Stream metrics + tool-timer event wiring ---
705
- const streamUnsubs = wireStreamEventMetrics({
687
+ const { refreshGit, unsubs: turnUnsubs } = wireTurnEventHandlers({
706
688
  events: uiServices.events,
689
+ conversation,
690
+ runtime,
707
691
  orchestrator,
692
+ configManager,
708
693
  providerRegistry,
709
694
  systemMessageRouter,
695
+ hookDispatcher,
696
+ workingDir,
697
+ homeDirectory,
698
+ sessionManager: ctx.services.sessionManager,
699
+ gitStatusProvider,
700
+ lastGitInfoRef,
701
+ buildSessionContinuityHints,
710
702
  render,
711
- metrics: streamMetrics,
712
703
  });
713
- unsubs.push(...streamUnsubs);
704
+ unsubs.push(...turnUnsubs);
705
+
706
+ // Stable turn context for failover retry — set in submitInput, read by retryTurn.
707
+ let retryCtx: { count: number; text: string; content?: ContentPart[]; opts?: Parameters<typeof orchestrator.handleUserInput>[2] } | null = null;
708
+ const streamResult: WireStreamEventMetricsResult = wireStreamEventMetrics({
709
+ events: uiServices.events, orchestrator, providerRegistry,
710
+ systemMessageRouter, render, metrics: streamMetrics,
711
+ providerOptimizer: ctx.services.providerOptimizer,
712
+ retryTurn: () => {
713
+ if (!retryCtx) return;
714
+ const { count, text, content: rContent, opts: rOpts } = retryCtx;
715
+ // Roll back to pre-submission count (strips error system messages), then
716
+ // re-submit. SDK gap — no retry-in-place; see HANDOFF item (Issue 2).
717
+ conversation.removeMessagesAfter(count);
718
+ orchestrator.handleUserInput(text, rContent, rOpts).catch((e: unknown) => logger.debug('retryTurn', { error: summarizeError(e) }));
719
+ },
720
+ });
721
+ unsubs.push(...streamResult.unsubs);
714
722
 
715
723
  // --- Terminal setup ---
716
724
  stdin.setRawMode(true);