@pellux/goodvibes-tui 0.20.3 → 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 (142) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +23 -2
  3. package/docs/foundation-artifacts/operator-contract.json +78 -1
  4. package/package.json +4 -2
  5. package/src/audio/spoken-turn-controller.ts +31 -1
  6. package/src/audio/spoken-turn-wiring.ts +26 -4
  7. package/src/cli/bundle-command.ts +1 -1
  8. package/src/cli/completions/generate.ts +658 -0
  9. package/src/cli/config-overrides.ts +68 -0
  10. package/src/cli/entrypoint.ts +6 -0
  11. package/src/cli/help.ts +4 -2
  12. package/src/cli/management-commands.ts +1 -1
  13. package/src/cli/management.ts +1 -8
  14. package/src/cli/parser.ts +31 -18
  15. package/src/cli/service-command.ts +1 -1
  16. package/src/cli/surface-command.ts +1 -1
  17. package/src/cli/tui-startup.ts +72 -10
  18. package/src/cli/types.ts +14 -3
  19. package/src/cli-flags.ts +1 -0
  20. package/src/config/atomic-write.ts +70 -0
  21. package/src/config/goodvibes-home-audit.ts +2 -0
  22. package/src/config/read-versioned.ts +115 -0
  23. package/src/core/context-auto-compact.ts +77 -0
  24. package/src/core/conversation-rendering.ts +49 -15
  25. package/src/core/conversation.ts +101 -16
  26. package/src/core/format-user-error.ts +192 -0
  27. package/src/core/stream-event-wiring.ts +144 -0
  28. package/src/core/stream-stall-watchdog.ts +103 -0
  29. package/src/core/system-message-router.ts +5 -1
  30. package/src/core/turn-event-wiring.ts +124 -0
  31. package/src/daemon/cli.ts +5 -0
  32. package/src/export/cost-utils.ts +71 -0
  33. package/src/export/gist-uploader.ts +136 -0
  34. package/src/input/command-registry.ts +32 -1
  35. package/src/input/commands/control-room-runtime.ts +10 -10
  36. package/src/input/commands/experience-runtime.ts +5 -4
  37. package/src/input/commands/knowledge.ts +1 -1
  38. package/src/input/commands/local-auth-runtime.ts +27 -5
  39. package/src/input/commands/local-setup.ts +4 -6
  40. package/src/input/commands/memory-product-runtime.ts +8 -6
  41. package/src/input/commands/operator-panel-runtime.ts +1 -1
  42. package/src/input/commands/operator-runtime.ts +3 -10
  43. package/src/input/commands/{integration-runtime.ts → plugin-runtime.ts} +1 -1
  44. package/src/input/commands/provider.ts +57 -3
  45. package/src/input/commands/recall-review.ts +26 -2
  46. package/src/input/commands/services-runtime.ts +2 -2
  47. package/src/input/commands/session-workflow.ts +8 -16
  48. package/src/input/commands/session.ts +70 -20
  49. package/src/input/commands/share-runtime.ts +99 -12
  50. package/src/input/commands/tts-runtime.ts +30 -4
  51. package/src/input/commands.ts +2 -4
  52. package/src/input/delete-key-policy.ts +46 -0
  53. package/src/input/feed-context-factory.ts +2 -0
  54. package/src/input/handler-feed.ts +3 -0
  55. package/src/input/handler-interactions.ts +2 -15
  56. package/src/input/handler-modal-routes.ts +128 -12
  57. package/src/input/handler-modal-token-routes.ts +22 -5
  58. package/src/input/handler-onboarding-cloudflare.ts +1 -1
  59. package/src/input/handler-onboarding.ts +73 -69
  60. package/src/input/handler-types.ts +163 -0
  61. package/src/input/handler.ts +6 -2
  62. package/src/input/input-history.ts +76 -6
  63. package/src/input/model-picker-filter.ts +265 -0
  64. package/src/input/model-picker-items.ts +208 -0
  65. package/src/input/model-picker.ts +92 -325
  66. package/src/input/onboarding/handler-onboarding-routes.ts +7 -2
  67. package/src/input/onboarding/onboarding-verification-helpers.ts +76 -0
  68. package/src/input/onboarding/onboarding-wizard-apply.ts +14 -4
  69. package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +16 -2
  70. package/src/input/onboarding/onboarding-wizard-cloudflare.ts +8 -8
  71. package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +1 -1
  72. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +2 -29
  73. package/src/input/onboarding/onboarding-wizard-rules.ts +28 -28
  74. package/src/input/onboarding/onboarding-wizard-state.ts +20 -20
  75. package/src/input/onboarding/onboarding-wizard-steps.ts +24 -25
  76. package/src/input/onboarding/onboarding-wizard-types.ts +145 -3
  77. package/src/input/onboarding/onboarding-wizard-validation.ts +77 -0
  78. package/src/input/onboarding/onboarding-wizard.ts +3 -3
  79. package/src/input/settings-modal-behavior.ts +5 -0
  80. package/src/input/settings-modal-data.ts +378 -0
  81. package/src/input/settings-modal-mutations.ts +157 -0
  82. package/src/input/settings-modal-reset.ts +154 -0
  83. package/src/input/settings-modal.ts +236 -232
  84. package/src/main.ts +93 -85
  85. package/src/panels/agent-inspector-panel.ts +120 -18
  86. package/src/panels/agent-inspector-shared.ts +29 -0
  87. package/src/panels/builtin/agent.ts +4 -1
  88. package/src/panels/builtin/development.ts +5 -1
  89. package/src/panels/builtin/knowledge.ts +14 -13
  90. package/src/panels/builtin/operations.ts +22 -1
  91. package/src/panels/builtin/shared.ts +7 -0
  92. package/src/panels/cockpit-panel.ts +123 -3
  93. package/src/panels/cockpit-read-model.ts +232 -0
  94. package/src/panels/confirm-state.ts +27 -12
  95. package/src/panels/cost-tracker-panel.ts +23 -67
  96. package/src/panels/eval-panel.ts +10 -9
  97. package/src/panels/index.ts +1 -1
  98. package/src/panels/knowledge-graph-panel.ts +84 -0
  99. package/src/panels/local-auth-panel.ts +124 -4
  100. package/src/panels/memory-panel.ts +370 -40
  101. package/src/panels/project-planning-panel.ts +42 -4
  102. package/src/panels/search-focus.ts +11 -5
  103. package/src/panels/session-maintenance.ts +66 -15
  104. package/src/panels/subscription-panel.ts +33 -25
  105. package/src/panels/types.ts +28 -1
  106. package/src/panels/wrfc-panel.ts +224 -41
  107. package/src/renderer/agent-detail-modal.ts +118 -13
  108. package/src/renderer/code-block.ts +10 -2
  109. package/src/renderer/compositor.ts +18 -4
  110. package/src/renderer/context-inspector.ts +1 -5
  111. package/src/renderer/context-status-hint.ts +54 -0
  112. package/src/renderer/diff.ts +94 -21
  113. package/src/renderer/markdown.ts +29 -13
  114. package/src/renderer/settings-modal-helpers.ts +1 -1
  115. package/src/renderer/settings-modal.ts +90 -10
  116. package/src/renderer/shell-surface.ts +10 -0
  117. package/src/renderer/syntax-highlighter.ts +10 -3
  118. package/src/renderer/term-caps.ts +318 -0
  119. package/src/renderer/theme.ts +158 -0
  120. package/src/renderer/tool-call.ts +12 -2
  121. package/src/renderer/ui-factory.ts +50 -6
  122. package/src/runtime/bootstrap-command-context.ts +1 -0
  123. package/src/runtime/bootstrap-command-parts.ts +18 -0
  124. package/src/runtime/bootstrap-core.ts +145 -13
  125. package/src/runtime/bootstrap-shell.ts +11 -0
  126. package/src/runtime/bootstrap.ts +9 -0
  127. package/src/runtime/onboarding/apply.ts +4 -6
  128. package/src/runtime/onboarding/index.ts +1 -0
  129. package/src/runtime/onboarding/markers.ts +42 -49
  130. package/src/runtime/onboarding/progress.ts +148 -0
  131. package/src/runtime/onboarding/state.ts +133 -55
  132. package/src/runtime/onboarding/types.ts +20 -0
  133. package/src/runtime/services.ts +27 -1
  134. package/src/runtime/wrfc-persistence.ts +237 -0
  135. package/src/shell/blocking-input.ts +20 -5
  136. package/src/tools/wrfc-agent-guard.ts +64 -3
  137. package/src/utils/format-elapsed.ts +30 -0
  138. package/src/utils/terminal-width.ts +45 -0
  139. package/src/version.ts +1 -1
  140. package/src/work-plans/work-plan-store.ts +4 -6
  141. package/src/panels/knowledge-panel.ts +0 -345
  142. package/src/planning/project-planning-coordinator.ts +0 -543
@@ -1,5 +1,4 @@
1
1
  import { writeFile } from 'node:fs/promises';
2
- import { resolve } from 'path';
3
2
  import type { CommandRegistry } from '../command-registry.ts';
4
3
  import {
5
4
  type ExportMessage,
@@ -11,13 +10,21 @@ import {
11
10
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
12
11
  import { requireShellPaths } from './runtime-services.ts';
13
12
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
13
+ import { calcSessionCost } from '../../export/cost-utils.ts';
14
+ import {
15
+ GistUploadTarget,
16
+ NO_TOKEN_GUIDANCE,
17
+ resolveGithubToken,
18
+ } from '../../export/gist-uploader.ts';
19
+ import { copyToClipboard } from '../../utils/clipboard.ts';
20
+ import { openBrowser } from '../../cli/management.ts';
14
21
 
15
22
  export function registerShareRuntimeCommands(registry: CommandRegistry): void {
16
23
  registry.register({
17
24
  name: 'share',
18
25
  aliases: [],
19
26
  description: 'Export the current session to a shareable format (html, json, md)',
20
- usage: '<html|json|md> [path] [--redact]',
27
+ usage: '<html|json|md> [path] [--redact] [--upload] [--copy] [--open]',
21
28
  argsHint: '<html|json|md> [path]',
22
29
  async handler(args, ctx) {
23
30
  const shellPaths = requireShellPaths(ctx);
@@ -27,20 +34,28 @@ export function registerShareRuntimeCommands(registry: CommandRegistry): void {
27
34
  const format = args[0]?.toLowerCase() as Format | undefined;
28
35
  if (!format || !FORMATS.includes(format)) {
29
36
  ctx.print(
30
- 'Usage: /share <html|json|md> [path] [--redact]\n'
37
+ 'Usage: /share <html|json|md> [path] [--redact] [--upload] [--copy] [--open]\n'
31
38
  + ' html — self-contained HTML with syntax highlighting\n'
32
39
  + ' json — structured JSON (machine-readable)\n'
33
40
  + ' md — Markdown\n\n'
34
41
  + 'Options:\n'
35
- + ' --redact Redact API keys and personal paths from output\n\n'
42
+ + ' --redact Redact API keys and personal paths from output\n'
43
+ + ' --upload Upload export as a secret GitHub Gist and print the share link\n'
44
+ + ' --copy Copy the export file path to the clipboard\n'
45
+ + ' --open Open HTML export in the default browser\n\n'
36
46
  + 'Default path: ~/goodvibes-exports/session-<timestamp>.<ext>',
37
47
  );
38
48
  return;
39
49
  }
40
50
 
41
51
  const remainingArgs = args.slice(1);
42
- const redact = remainingArgs.includes('--redact');
43
- const pathArgs = remainingArgs.filter(a => a !== '--redact');
52
+ const redact = remainingArgs.includes('--redact');
53
+ const upload = remainingArgs.includes('--upload');
54
+ const doCopy = remainingArgs.includes('--copy');
55
+ const doOpen = remainingArgs.includes('--open');
56
+ const pathArgs = remainingArgs.filter(
57
+ (a) => a !== '--redact' && a !== '--upload' && a !== '--copy' && a !== '--open',
58
+ );
44
59
  const outputPath = pathArgs.length > 0
45
60
  ? shellPaths.resolveWorkspacePath(pathArgs[0])
46
61
  : defaultExportPath(format, shellPaths.homeDirectory);
@@ -69,7 +84,7 @@ export function registerShareRuntimeCommands(registry: CommandRegistry): void {
69
84
  return;
70
85
  }
71
86
 
72
- const messages: ExportMessage[] = convData.messages.map(m => ({
87
+ const messages: ExportMessage[] = convData.messages.map((m) => ({
73
88
  role: m.role as ExportMessage['role'],
74
89
  content: m.content as string,
75
90
  toolCalls: m.toolCalls,
@@ -80,13 +95,32 @@ export function registerShareRuntimeCommands(registry: CommandRegistry): void {
80
95
  usage: m.usage,
81
96
  cancelled: m.cancelled,
82
97
  }));
98
+
99
+ // Accumulate per-message usage totals for session cost calculation.
100
+ let totalInput = 0;
101
+ let totalOutput = 0;
102
+ let totalCacheRead = 0;
103
+ let totalCacheWrite = 0;
104
+ for (const m of convData.messages) {
105
+ if (m.usage) {
106
+ totalInput += m.usage.inputTokens ?? 0;
107
+ totalOutput += m.usage.outputTokens ?? 0;
108
+ totalCacheRead += m.usage.cacheReadTokens ?? 0;
109
+ totalCacheWrite += m.usage.cacheWriteTokens ?? 0;
110
+ }
111
+ }
112
+ const sessionModel = ctx.session.runtime.model;
113
+ const sessionCostUsd = calcSessionCost(
114
+ totalInput, totalOutput, totalCacheRead, totalCacheWrite, sessionModel,
115
+ );
116
+
83
117
  const metadata = {
84
- model: ctx.session.runtime.model,
118
+ model: sessionModel,
85
119
  provider: ctx.session.runtime.provider,
86
120
  sessionId: ctx.session.runtime.sessionId,
87
121
  title: ctx.session.conversationManager.title || undefined,
88
122
  };
89
- const options = { redact };
123
+ const options = { redact, cost: sessionCostUsd };
90
124
 
91
125
  let outputContent: string;
92
126
  try {
@@ -99,11 +133,14 @@ export function registerShareRuntimeCommands(registry: CommandRegistry): void {
99
133
  }
100
134
 
101
135
  const { mkdirSync } = await import('node:fs');
102
- const { dirname } = await import('node:path');
136
+ const { dirname, basename } = await import('node:path');
103
137
  try {
104
138
  mkdirSync(dirname(outputPath), { recursive: true });
105
139
  } catch (mkdirErr) {
106
- logger.warn(`[share] mkdir failed for ${dirname(outputPath)}:`, mkdirErr instanceof Error ? { message: mkdirErr.message } : undefined);
140
+ logger.warn(
141
+ `[share] mkdir failed for ${dirname(outputPath)}:`,
142
+ mkdirErr instanceof Error ? { message: mkdirErr.message } : undefined,
143
+ );
107
144
  }
108
145
 
109
146
  try {
@@ -113,7 +150,57 @@ export function registerShareRuntimeCommands(registry: CommandRegistry): void {
113
150
  return;
114
151
  }
115
152
 
116
- ctx.print(`Exported ${format.toUpperCase()} session to ${outputPath}${redact ? ' (sensitive data redacted)' : ''}`);
153
+ // Optional Gist upload: resolve auth token then push content as a secret gist.
154
+ let shareLink: string | undefined;
155
+ if (upload) {
156
+ let authHeaders: Record<string, string> | null = null;
157
+ try {
158
+ const svcRegistry = ctx.platform.serviceRegistry;
159
+ if (svcRegistry) {
160
+ authHeaders = await svcRegistry.resolveAuth('github').catch(() => null);
161
+ }
162
+ } catch {
163
+ // serviceRegistry absent or resolveAuth threw — fall through to env var
164
+ }
165
+
166
+ const token = resolveGithubToken(authHeaders ?? undefined);
167
+ if (!token) {
168
+ ctx.print(NO_TOKEN_GUIDANCE);
169
+ } else {
170
+ const gistFilename = basename(outputPath);
171
+ const description = metadata.title
172
+ ? `GoodVibes session: ${metadata.title}`
173
+ : 'GoodVibes session export';
174
+ const uploader = new GistUploadTarget(token, description);
175
+ const result = await uploader.upload(outputContent, gistFilename);
176
+ if (result.ok) {
177
+ shareLink = result.url;
178
+ } else {
179
+ ctx.print(`Upload failed: ${result.error}`);
180
+ }
181
+ }
182
+ }
183
+
184
+ // Copy export path to clipboard if requested.
185
+ if (doCopy) {
186
+ copyToClipboard(outputPath);
187
+ }
188
+
189
+ // Open the exported HTML in the default browser if requested.
190
+ if (doOpen && format === 'html') {
191
+ openBrowser(`file://${outputPath}`);
192
+ }
193
+
194
+ // Emit the summary line, with all post-export hints inline.
195
+ const hints: string[] = [];
196
+ if (redact) hints.push('(sensitive data redacted)');
197
+ if (shareLink) hints.push(`Share link: ${shareLink}`);
198
+ if (doCopy) hints.push('(path copied to clipboard)');
199
+ if (doOpen && format === 'html') hints.push('(opened in browser)');
200
+ if (doOpen && format !== 'html') hints.push('(--open ignored: only applies to html)');
201
+
202
+ const hint = hints.length > 0 ? ' ' + hints.join(' ') : '';
203
+ ctx.print(`Exported ${format.toUpperCase()} session to ${outputPath}${hint}`);
117
204
  },
118
205
  });
119
206
  }
@@ -3,19 +3,46 @@ import type { CommandRegistry } from '../command-registry.ts';
3
3
  export function registerTtsRuntimeCommands(registry: CommandRegistry): void {
4
4
  registry.register({
5
5
  name: 'tts',
6
- description: 'Submit a normal prompt and play the assistant response through live TTS',
7
- usage: '<prompt>|stop',
6
+ description: 'Submit a prompt for live TTS playback, or control always-speak mode',
7
+ usage: '<prompt>|stop|on|off',
8
8
  handler(args, ctx) {
9
9
  const first = (args[0] ?? '').toLowerCase();
10
+
11
+ // /tts stop — cancel active playback
10
12
  if (first === 'stop' || first === 'cancel') {
11
13
  ctx.stopSpokenOutput?.();
12
14
  ctx.print('Live TTS playback stopped.');
13
15
  return;
14
16
  }
15
17
 
18
+ // /tts on — enable always-speak mode (every turn spoken automatically)
19
+ if (first === 'on') {
20
+ if (!ctx.platform.voiceService) {
21
+ ctx.print('Live TTS is not available in this runtime.');
22
+ return;
23
+ }
24
+ ctx.platform.configManager.setDynamic('ui.voiceEnabled', true);
25
+ ctx.platform.configManager.save();
26
+ ctx.print('Always-speak mode enabled. Every submitted turn will be played through live TTS.');
27
+ ctx.renderRequest();
28
+ return;
29
+ }
30
+
31
+ // /tts off — disable always-speak mode
32
+ if (first === 'off') {
33
+ ctx.platform.configManager.setDynamic('ui.voiceEnabled', false);
34
+ ctx.platform.configManager.save();
35
+ ctx.print('Always-speak mode disabled. Use /tts <prompt> to speak individual turns.');
36
+ ctx.renderRequest();
37
+ return;
38
+ }
39
+
40
+ // /tts <prompt> — mark this prompt for spoken output
16
41
  const prompt = args.join(' ').trim();
17
42
  if (!prompt) {
18
- ctx.print('Usage: /tts <prompt> or /tts stop');
43
+ const enabled = ctx.platform.configManager.get('ui.voiceEnabled');
44
+ ctx.print(`Usage: /tts <prompt>, /tts stop, /tts on, /tts off
45
+ Always-speak mode is currently ${enabled ? 'on' : 'off'}.`);
19
46
  return;
20
47
  }
21
48
  if (!ctx.submitSpokenInput) {
@@ -25,5 +52,4 @@ export function registerTtsRuntimeCommands(registry: CommandRegistry): void {
25
52
  ctx.submitSpokenInput(prompt);
26
53
  },
27
54
  });
28
-
29
55
  }
@@ -7,13 +7,12 @@ import { recallCommand } from './commands/memory.ts';
7
7
  import { knowledgeCommand } from './commands/knowledge.ts';
8
8
  import { registerShellCoreCommands } from './commands/shell-core.ts';
9
9
  import { registerConfigCommand } from './commands/config.ts';
10
- import { registerSessionWorkflowCommands } from './commands/session-workflow.ts';
11
10
  import { registerDiscoveryRuntimeCommands } from './commands/discovery-runtime.ts';
12
11
  import { registerPlanningRuntimeCommands } from './commands/planning-runtime.ts';
13
12
  import { registerScheduleRuntimeCommands } from './commands/schedule-runtime.ts';
14
13
  import { registerBranchRuntimeCommands } from './commands/branch-runtime.ts';
15
14
  import { registerOperatorRuntimeCommands } from './commands/operator-runtime.ts';
16
- import { registerIntegrationRuntimeCommands } from './commands/integration-runtime.ts';
15
+ import { registerPluginRuntimeCommands } from './commands/plugin-runtime.ts';
17
16
  import { registerDiffRuntimeCommands } from './commands/diff-runtime.ts';
18
17
  import { registerGitRuntimeCommands } from './commands/git-runtime.ts';
19
18
  import { registerNotifyRuntimeCommands } from './commands/notify-runtime.ts';
@@ -65,7 +64,7 @@ export function registerBuiltinCommands(registry: CommandRegistry): void {
65
64
  registerShellCoreCommands(registry);
66
65
  registerConfigCommand(registry);
67
66
  registerOperatorRuntimeCommands(registry);
68
- registerIntegrationRuntimeCommands(registry);
67
+ registerPluginRuntimeCommands(registry);
69
68
  registerDiffRuntimeCommands(registry);
70
69
  registerGitRuntimeCommands(registry);
71
70
  registerNotifyRuntimeCommands(registry);
@@ -107,7 +106,6 @@ export function registerBuiltinCommands(registry: CommandRegistry): void {
107
106
  registerCloudflareRuntimeCommands(registry);
108
107
  registerWorkPlanRuntimeCommands(registry);
109
108
  registerLocalRuntimeCommands(registry);
110
- registerSessionWorkflowCommands(registry);
111
109
  registerDiscoveryRuntimeCommands(registry);
112
110
  registerPlanningRuntimeCommands(registry);
113
111
  registerScheduleRuntimeCommands(registry);
@@ -0,0 +1,46 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Delete-key policy — single source of truth for text-editing key semantics
3
+ //
4
+ // In text-editing contexts:
5
+ // 'backspace' → delete one character backward (the character before cursor /
6
+ // the last character in an end-anchored buffer)
7
+ // 'delete' → forward-delete (the character after cursor) when a moveable
8
+ // cursor exists; a no-op in cursorless/end-anchored contexts,
9
+ // EXCEPT where it opens a confirmation-gated clear action
10
+ // (see planning panel: Delete opens the ConfirmState gate that
11
+ // lets the user clear their entire draft answer).
12
+ //
13
+ // Consequences for each surface:
14
+ // Panel search filters (end-anchored, no cursor)
15
+ // 'backspace' → remove last char ✓
16
+ // 'delete' → no-op ✓ (no cursor; nothing is "forward")
17
+ //
18
+ // Selection modal filters (end-anchored, no cursor)
19
+ // 'backspace' → remove last char ✓
20
+ // 'delete' → no-op ✓
21
+ //
22
+ // Planning panel draft answer (end-anchored, no cursor)
23
+ // 'backspace' → remove last char ✓
24
+ // 'delete' → open ConfirmState gate (y/Enter confirms clear; n/Esc cancels)
25
+ // The draft is NOT wiped until the user confirms.
26
+ //
27
+ // NEVER assign bulk-destructive actions (clear / wipe) to the Delete key
28
+ // without an explicit confirmation gate.
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * Returns true when `key` should perform a backward-delete (remove the
33
+ * character before the cursor / at end of an end-anchored buffer).
34
+ */
35
+ export function isTextBackspace(key: string): boolean {
36
+ return key === 'backspace';
37
+ }
38
+
39
+ /**
40
+ * Returns true when `key` should perform a forward-delete (remove the
41
+ * character after the cursor). Only meaningful when a moveable cursor
42
+ * exists; callers should treat this as a no-op when there is no cursor.
43
+ */
44
+ export function isTextForwardDelete(key: string): boolean {
45
+ return key === 'delete';
46
+ }
@@ -161,6 +161,8 @@ export interface FeedContextClosures {
161
161
  openProviderModelPickerWithTarget: (target: ModelPickerTarget, source?: 'settings' | 'onboarding') => boolean;
162
162
  onModelPickerCommit: () => boolean;
163
163
  onOnboardingAction: (action: import('./onboarding/onboarding-wizard.ts').OnboardingWizardAction) => void;
164
+ /** Called after any wizard step navigation so the handler can persist progress. */
165
+ onStepChange?: () => void;
164
166
  }
165
167
 
166
168
  /**
@@ -162,6 +162,8 @@ export interface InputFeedContext {
162
162
  readonly openProviderModelPickerWithTarget: (target: ModelPickerTarget, source?: 'settings' | 'onboarding') => boolean;
163
163
  readonly onModelPickerCommit: () => boolean;
164
164
  readonly onOnboardingAction: (action: OnboardingWizardAction) => void;
165
+ /** Called after any wizard step navigation so the handler can persist progress. */
166
+ readonly onStepChange?: () => void;
165
167
  readonly exitApp: () => void;
166
168
  }
167
169
 
@@ -228,6 +230,7 @@ export function feedInputTokens(context: InputFeedContext, tokens: readonly Inpu
228
230
  openProviderModelPickerWithTarget: context.openProviderModelPickerWithTarget,
229
231
  onModelPickerCommit: context.onModelPickerCommit,
230
232
  onOnboardingAction: context.onOnboardingAction,
233
+ onStepChange: context.onStepChange,
231
234
  }, token);
232
235
  context.selectionCallback = modalRoute.selectionCallback;
233
236
  context.helpOverlayActive = modalRoute.helpOverlayActive;
@@ -1,29 +1,16 @@
1
1
  import { buildProviderAccountSnapshot } from '@/runtime/index.ts';
2
2
  import type { OnboardingWizardMode } from './onboarding/onboarding-wizard.ts';
3
- import { collectOnboardingSnapshot, readOnboardingCheckMarker, writeOnboardingCheckMarker } from '../runtime/onboarding/index.ts';
3
+ import { collectOnboardingSnapshot } from '../runtime/onboarding/index.ts';
4
4
  import { cleanupMarkerRegistry, expandPrompt, findMarkerAtPos, handleBlockCopy, handleBlockRerun, handleBlockSave, handleBlockToggle, handleBookmark, handleClipboardPaste, handleCopy, handleCtrlC, handleDiffApply, registerPaste } from './handler-content-actions.ts';
5
5
  import { clearModalStack, handleEscape, modalOpened } from './handler-modal-stack.ts';
6
6
  import { openOnboardingWizardState, type OpenOnboardingWizardOptions } from './handler-ui-state.ts';
7
- import type { InputHandler } from './handler.ts';
7
+ import type { InputHandlerLike as InputHandler } from './handler-types.ts';
8
8
 
9
9
  export function openOnboardingWizardForHandler(
10
10
  handler: InputHandler,
11
11
  modeOrOptions: OnboardingWizardMode | OpenOnboardingWizardOptions = 'new',
12
12
  ): void {
13
13
  const options = typeof modeOrOptions === 'string' ? { mode: modeOrOptions } : modeOrOptions;
14
- const userMarker = readOnboardingCheckMarker(handler.uiServices.environment.shellPaths, 'user');
15
- if (!userMarker.payload) {
16
- try {
17
- writeOnboardingCheckMarker(handler.uiServices.environment.shellPaths, {
18
- scope: 'user',
19
- source: 'wizard',
20
- mode: options.mode ?? 'new',
21
- });
22
- } catch (error) {
23
- const message = error instanceof Error ? error.message : String(error);
24
- handler.commandContext?.print?.(`Onboarding check marker could not be written: ${message}`);
25
- }
26
- }
27
14
  if (!handler.modalStack.includes('onboarding')) handler.modalOpened('onboarding');
28
15
  handler.clearOnboardingModelPickerCancelState();
29
16
  openOnboardingWizardState(handler.onboardingWizard, options);
@@ -2,6 +2,7 @@ import type { InputToken } from '@pellux/goodvibes-sdk/platform/core';
2
2
  import type { SelectionResult, SelectionAction } from './selection-modal.ts';
3
3
  import type { CommandContext } from './command-registry.ts';
4
4
  import { openTtsProviderPicker, openTtsVoicePicker } from './tts-settings-actions.ts';
5
+ import { isTextBackspace } from './delete-key-policy.ts';
5
6
 
6
7
  type SelectionRouteState = {
7
8
  selectionModal: {
@@ -136,10 +137,13 @@ export function handleSelectionModalToken(state: SelectionRouteState, token: Inp
136
137
  getAdjustmentStep(selected, token.shift),
137
138
  );
138
139
  }
139
- } else if (token.logicalName === 'backspace') {
140
+ } else if (isTextBackspace(token.logicalName ?? '')) {
140
141
  if (state.selectionModal.allowSearch && state.selectionModal.searchFocused && state.selectionModal.query.length > 0) {
141
142
  state.selectionModal.setQuery(state.selectionModal.query.slice(0, -1));
142
143
  }
144
+ // 'delete' is intentionally absent here: modal search filters are
145
+ // end-anchored with no cursor, so forward-delete is a no-op per the
146
+ // delete-key policy (src/input/delete-key-policy.ts).
143
147
  } else if (state.selectionModal.allowSearch && !state.selectionModal.searchFocused && token.logicalName === '/') {
144
148
  state.selectionModal.focusSearch();
145
149
  } else if (!state.selectionModal.searchFocused && token.logicalName && token.logicalName.length === 1) {
@@ -212,9 +216,14 @@ type SettingsRouteState = {
212
216
  editingMode: boolean;
213
217
  currentCategory: string;
214
218
  focusPane?: 'categories' | 'settings';
219
+ /** True when the user is actively typing into the search input bar. */
220
+ searchFocused: boolean;
221
+ /** Current cross-category search query. */
222
+ searchQuery: string;
215
223
  commitEdit: () => void;
216
224
  toggleSelectedFlag: () => void;
217
225
  activateSelected: () => void;
226
+ handleSubscriptionLogoutKey?: (key: string) => 'confirmed' | 'cancelled' | 'absorbed' | 'inactive';
218
227
  adjustSelected: (direction: 'left' | 'right', step?: number) => void;
219
228
  moveFocusedUp?: () => void;
220
229
  moveFocusedDown?: () => void;
@@ -227,10 +236,29 @@ type SettingsRouteState = {
227
236
  prevCategory?: () => void;
228
237
  editBackspace: () => void;
229
238
  editChar: (char: string) => void;
239
+ /** Enter search mode (focus the search input bar). */
240
+ focusSearch: () => void;
241
+ /** Exit search mode without clearing the query. */
242
+ blurSearch: () => void;
243
+ /** Set search query and recompute results. */
244
+ setSearchQuery: (query: string) => void;
245
+ /** Clear search query, results, and exit search mode. */
246
+ clearSearch: () => void;
247
+ /** Cancel inline edit without saving (mirrors SettingsModal.cancelEdit). */
248
+ cancelEdit: () => void;
230
249
  pendingModelPickerTarget: import('./model-picker.ts').ModelPickerTarget | null;
231
250
  pendingProviderModelPickerTarget?: import('./model-picker.ts').ModelPickerTarget | null;
232
251
  pendingSettingsPickerAction?: 'tts-provider' | 'tts-voice' | null;
233
252
  resetSelected?: () => { key: string; value: unknown } | null;
253
+ initiateResetCategory?: () => void;
254
+ initiateResetAll?: () => void;
255
+ handleResetConfirmKey?: (
256
+ key: string,
257
+ ) =>
258
+ | { result: 'confirmed'; entries: ReadonlyArray<{ key: string; value: unknown }> }
259
+ | 'cancelled'
260
+ | 'absorbed'
261
+ | 'inactive';
234
262
  };
235
263
  commandContext?: CommandContext;
236
264
  /** Called when the settings modal requests the model picker for a non-main target. */
@@ -279,42 +307,132 @@ function consumeSettingsPickerRequest(state: SettingsRouteState): void {
279
307
  export function handleSettingsModalToken(state: SettingsRouteState, token: InputToken): boolean {
280
308
  if (!state.settingsModal.active) return false;
281
309
 
310
+ // Subscription logout confirm gate: routes all keys through the unified
311
+ // confirm contract before normal dispatch when a confirm is pending.
312
+ if (state.settingsModal.handleSubscriptionLogoutKey) {
313
+ const key = token.type === 'key'
314
+ ? (token.logicalName ?? '')
315
+ : token.type === 'text'
316
+ ? token.value
317
+ : '';
318
+ const logoutResult = state.settingsModal.handleSubscriptionLogoutKey(key);
319
+ if (logoutResult !== 'inactive') {
320
+ state.requestRender();
321
+ return true;
322
+ }
323
+ }
324
+
325
+ // Reset confirm gate: routes all keys through the confirm contract before
326
+ // normal dispatch when a category or all-settings reset is pending.
327
+ if (state.settingsModal.handleResetConfirmKey) {
328
+ const key = token.type === 'key'
329
+ ? (token.logicalName ?? '')
330
+ : token.type === 'text'
331
+ ? token.value
332
+ : '';
333
+ const resetResult = state.settingsModal.handleResetConfirmKey(key);
334
+ if (resetResult !== 'inactive') {
335
+ if (typeof resetResult === 'object' && resetResult.result === 'confirmed') {
336
+ // Sync runtime for every reset entry so provider.model / reasoningEffort
337
+ // stay consistent with the live session without requiring a restart.
338
+ for (const entry of resetResult.entries) {
339
+ syncRuntimeAfterSettingReset(state.commandContext, entry.key, entry.value);
340
+ }
341
+ }
342
+ state.requestRender();
343
+ return true;
344
+ }
345
+ }
346
+
282
347
  if (token.type === 'key') {
283
348
  const focusPane = state.settingsModal.focusPane ?? 'settings';
284
349
  if (token.logicalName === 'escape') {
350
+ // Cancel inline edit first — mirrors the global contract in handler-modal-stack.ts.
351
+ // Must check editingMode before searchFocused: the reachable path
352
+ // search→Enter(string/number)→Esc must cancel the edit, NOT just clear search.
353
+ if (state.settingsModal.editingMode) {
354
+ state.settingsModal.cancelEdit();
355
+ state.requestRender();
356
+ return true;
357
+ }
358
+ // Two-stage escape: if in search mode, first Esc exits search (clearSearch),
359
+ // second Esc closes the modal.
360
+ if (state.settingsModal.searchFocused) {
361
+ state.settingsModal.clearSearch();
362
+ state.requestRender();
363
+ return true;
364
+ }
285
365
  state.handleEscape();
286
366
  return true;
287
367
  }
288
- if (token.logicalName === 'enter' || (token.logicalName === 'space' && !state.settingsModal.editingMode)) {
368
+ if (token.logicalName === 'enter' || (token.logicalName === 'space' && !state.settingsModal.editingMode && !state.settingsModal.searchFocused)) {
289
369
  if (state.settingsModal.editingMode) state.settingsModal.commitEdit();
290
- else if (focusPane === 'categories') state.settingsModal.focusSettings?.();
370
+ else if (state.settingsModal.searchFocused) {
371
+ // Enter in search mode: activate the selected search result
372
+ state.settingsModal.activateSelected();
373
+ consumeSettingsPickerRequest(state);
374
+ } else if (focusPane === 'categories') state.settingsModal.focusSettings?.();
291
375
  else if (state.settingsModal.currentCategory === 'flags') state.settingsModal.toggleSelectedFlag();
292
376
  else {
293
377
  state.settingsModal.activateSelected();
294
378
  consumeSettingsPickerRequest(state);
295
379
  }
296
- } else if ((token.logicalName === 'left' || token.logicalName === 'right') && !state.settingsModal.editingMode) {
380
+ } else if ((token.logicalName === 'left' || token.logicalName === 'right') && !state.settingsModal.editingMode && !state.settingsModal.searchFocused) {
297
381
  if (token.logicalName === 'left') state.settingsModal.focusCategories?.();
298
382
  else state.settingsModal.focusSettings?.();
299
383
  } else if (token.logicalName === 'up') {
300
- if (state.settingsModal.moveFocusedUp) state.settingsModal.moveFocusedUp();
384
+ if (state.settingsModal.searchFocused) {
385
+ state.settingsModal.moveUp?.();
386
+ } else if (state.settingsModal.moveFocusedUp) state.settingsModal.moveFocusedUp();
301
387
  else state.settingsModal.moveUp?.();
302
388
  } else if (token.logicalName === 'down') {
303
- if (state.settingsModal.moveFocusedDown) state.settingsModal.moveFocusedDown();
389
+ if (state.settingsModal.searchFocused) {
390
+ state.settingsModal.moveDown?.();
391
+ } else if (state.settingsModal.moveFocusedDown) state.settingsModal.moveFocusedDown();
304
392
  else state.settingsModal.moveDown?.();
305
393
  }
306
- else if (token.logicalName === 'r' && !state.settingsModal.editingMode) {
394
+ else if (token.logicalName === 'r' && token.shift && token.ctrl && !state.settingsModal.editingMode && !state.settingsModal.searchFocused) {
395
+ state.settingsModal.initiateResetAll?.();
396
+ }
397
+ else if (token.logicalName === 'r' && token.shift && !state.settingsModal.editingMode && !state.settingsModal.searchFocused) {
398
+ state.settingsModal.initiateResetCategory?.();
399
+ }
400
+ else if (token.logicalName === 'r' && !state.settingsModal.editingMode && !state.settingsModal.searchFocused) {
307
401
  const reset = state.settingsModal.resetSelected?.();
308
402
  if (reset) syncRuntimeAfterSettingReset(state.commandContext, reset.key, reset.value);
309
403
  }
310
- else if (token.logicalName === 'tab') {
404
+ else if (token.logicalName === 'tab' && !state.settingsModal.searchFocused) {
311
405
  if (state.settingsModal.toggleFocusPane) state.settingsModal.toggleFocusPane();
312
406
  else if (focusPane === 'categories') state.settingsModal.focusSettings?.();
313
407
  else state.settingsModal.focusCategories?.();
314
408
  }
315
- else if (token.logicalName === 'backspace' && state.settingsModal.editingMode) state.settingsModal.editBackspace();
409
+ else if (isTextBackspace(token.logicalName ?? '')) {
410
+ if (state.settingsModal.editingMode) {
411
+ state.settingsModal.editBackspace();
412
+ } else if (state.settingsModal.searchFocused) {
413
+ // Backspace in search mode: trim query
414
+ const trimmed = state.settingsModal.searchQuery.slice(0, -1);
415
+ state.settingsModal.setSearchQuery(trimmed);
416
+ }
417
+ }
418
+ // token.logicalName === 'delete' is intentionally absent: search filters
419
+ // are end-anchored with no cursor, so forward-delete is a no-op per
420
+ // delete-key policy (src/input/delete-key-policy.ts).
421
+ else if (!state.settingsModal.editingMode && !state.settingsModal.searchFocused && token.logicalName === '/') {
422
+ state.settingsModal.focusSearch();
423
+ }
316
424
  } else if (token.type === 'text') {
317
- if (token.value === ' ' && !state.settingsModal.editingMode) {
425
+ if (state.settingsModal.editingMode) {
426
+ // editingMode takes priority over search — Enter on a string/number search
427
+ // result enters inline edit; subsequent chars must go to editChar, not the query.
428
+ state.settingsModal.editChar(token.value);
429
+ } else if (state.settingsModal.searchFocused) {
430
+ // Any printable char in search mode appends to the query
431
+ state.settingsModal.setSearchQuery(state.settingsModal.searchQuery + token.value);
432
+ } else if (token.value === '/' && !state.settingsModal.editingMode) {
433
+ // / enters search mode
434
+ state.settingsModal.focusSearch();
435
+ } else if (token.value === ' ' && !state.settingsModal.editingMode) {
318
436
  const focusPane = state.settingsModal.focusPane ?? 'settings';
319
437
  if (focusPane === 'categories') state.settingsModal.focusSettings?.();
320
438
  else if (state.settingsModal.currentCategory === 'flags') state.settingsModal.toggleSelectedFlag();
@@ -322,8 +440,6 @@ export function handleSettingsModalToken(state: SettingsRouteState, token: Input
322
440
  state.settingsModal.activateSelected();
323
441
  consumeSettingsPickerRequest(state);
324
442
  }
325
- } else if (state.settingsModal.editingMode) {
326
- state.settingsModal.editChar(token.value);
327
443
  } else if (token.value === 'r') {
328
444
  const reset = state.settingsModal.resetSelected?.();
329
445
  if (reset) syncRuntimeAfterSettingReset(state.commandContext, reset.key, reset.value);
@@ -92,6 +92,8 @@ export type ModalTokenRouteState = {
92
92
  restoreOnboardingModelPickerCancelState?: () => void;
93
93
  onModelPickerCommit?: () => boolean;
94
94
  onOnboardingAction?: (action: OnboardingWizardAction) => void;
95
+ /** Called after any wizard step navigation so the handler can persist progress. */
96
+ onStepChange?: () => void;
95
97
  };
96
98
 
97
99
  export function handleModalTokenRoutes(state: ModalTokenRouteState, token: InputToken): {
@@ -216,6 +218,7 @@ export function handleModalTokenRoutes(state: ModalTokenRouteState, token: Input
216
218
  handleEscape: state.handleEscape,
217
219
  openModelPickerWithTarget: state.openModelPickerWithTarget,
218
220
  onAction: state.onOnboardingAction,
221
+ onStepChange: state.onStepChange,
219
222
  }, token)) {
220
223
  return withState(state, true);
221
224
  }
@@ -229,11 +232,25 @@ export function handleModalTokenRoutes(state: ModalTokenRouteState, token: Input
229
232
  return withState(state, true);
230
233
  }
231
234
 
232
- if (handleEscapeOnlyModalToken({
233
- active: state.agentDetailModal.active,
234
- requestRender: state.requestRender,
235
- handleEscape: state.handleEscape,
236
- }, token)) {
235
+ // Agent detail modal: route c + confirm keys before escape-close.
236
+ // handleKey() consumes confirm-flow keys (y, Enter, n, Esc) and the 'c'
237
+ // initiator; unhandled keys (including Esc when no confirm is pending)
238
+ // fall through to escape-close below.
239
+ if (state.agentDetailModal.active) {
240
+ const keyStr: string =
241
+ token.type === 'key' ? (token.logicalName ?? '') :
242
+ token.type === 'text' ? token.value : '';
243
+ if (keyStr && state.agentDetailModal.handleKey(keyStr)) {
244
+ state.requestRender();
245
+ return withState(state, true);
246
+ }
247
+ // 'c' was not consumed (non-cancellable), or any other key.
248
+ // Esc closes the modal; all other keys are absorbed by the active modal.
249
+ if (token.type === 'key' && token.logicalName === 'escape') {
250
+ state.handleEscape();
251
+ return withState(state, true);
252
+ }
253
+ state.requestRender();
237
254
  return withState(state, true);
238
255
  }
239
256
 
@@ -12,7 +12,7 @@ import {
12
12
  type CloudflareVerifyResult,
13
13
  } from '../runtime/cloudflare-control-plane.ts';
14
14
  import type { OnboardingVerificationItem } from '../runtime/onboarding/index.ts';
15
- import type { InputHandler } from './handler.ts';
15
+ import type { InputHandlerLike as InputHandler } from './handler-types.ts';
16
16
  import type { OnboardingWizardAction, OnboardingWizardApplyFeedback } from './onboarding/onboarding-wizard.ts';
17
17
  import {
18
18
  buildCloudflareApiTokenRef,