@crouton-kit/crouter 0.3.70 → 0.3.79

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 (193) hide show
  1. package/README.md +1 -1
  2. package/dist/build-root.d.ts +12 -4
  3. package/dist/build-root.js +25 -6
  4. package/dist/builtin-memory/00-runtime-base.md +3 -10
  5. package/dist/builtin-memory/04-base-worker.md +18 -0
  6. package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
  7. package/dist/builtin-memory/crouter-development/plugins.md +82 -5
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +1115 -1
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +234 -71
  11. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
  12. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +2 -516
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
  14. package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
  15. package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
  16. package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
  17. package/dist/builtin-views/canvas/tui.mjs +8 -9
  18. package/dist/builtin-views/chat/tui.mjs +12 -12
  19. package/dist/builtin-views/git-pr/tui.mjs +7 -8
  20. package/dist/builtin-views/inbox/tui.mjs +27 -41
  21. package/dist/builtin-views/linkedin/tui.mjs +23 -37
  22. package/dist/builtin-views/prompt-review/tui.mjs +11 -11
  23. package/dist/builtin-views/settings/tui.mjs +11 -11
  24. package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
  25. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +11 -6
  26. package/dist/clients/attach/__tests__/attach-keybindings.test.d.ts +1 -0
  27. package/dist/clients/attach/__tests__/attach-keybindings.test.js +113 -0
  28. package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
  29. package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
  30. package/dist/clients/attach/__tests__/mermaid-render.test.js +28 -0
  31. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
  32. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
  33. package/dist/clients/attach/attach-cmd.d.ts +9 -1
  34. package/dist/clients/attach/attach-cmd.js +841 -792
  35. package/dist/clients/attach/auth-pickers.d.ts +0 -12
  36. package/dist/clients/attach/auth-pickers.js +64 -15
  37. package/dist/clients/attach/canvas-panels.js +2 -3
  38. package/dist/clients/attach/chat-view.d.ts +7 -8
  39. package/dist/clients/attach/chat-view.js +119 -79
  40. package/dist/clients/attach/edit-diff-render.d.ts +6 -0
  41. package/dist/clients/attach/edit-diff-render.js +263 -0
  42. package/dist/clients/attach/graph-overlay.d.ts +12 -2
  43. package/dist/clients/attach/graph-overlay.js +83 -33
  44. package/dist/clients/attach/input-controller.d.ts +28 -2
  45. package/dist/clients/attach/input-controller.js +38 -5
  46. package/dist/clients/attach/mermaid-render.js +5 -2
  47. package/dist/clients/attach/pickers.d.ts +8 -7
  48. package/dist/clients/attach/pickers.js +11 -17
  49. package/dist/clients/attach/slash-commands.d.ts +9 -0
  50. package/dist/clients/attach/slash-commands.js +127 -7
  51. package/dist/clients/attach/titled-editor.d.ts +12 -1
  52. package/dist/clients/attach/titled-editor.js +103 -8
  53. package/dist/commands/canvas-browse.js +2 -2
  54. package/dist/commands/human/queue.js +3 -4
  55. package/dist/commands/memory/lint.js +39 -5
  56. package/dist/commands/memory/write.js +1 -0
  57. package/dist/commands/node.js +9 -2
  58. package/dist/commands/pkg/plugin-inspect.js +22 -1
  59. package/dist/commands/pkg/plugin-manage.js +31 -9
  60. package/dist/commands/surface-tmux-spread.js +1 -3
  61. package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
  62. package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
  63. package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
  64. package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
  65. package/dist/commands/sys/__tests__/setup-core.test.js +158 -1
  66. package/dist/commands/sys/config.js +18 -21
  67. package/dist/commands/sys/doctor.js +42 -4
  68. package/dist/commands/sys/setup-core.d.ts +49 -1
  69. package/dist/commands/sys/setup-core.js +161 -4
  70. package/dist/commands/sys/setup.d.ts +88 -0
  71. package/dist/commands/sys/setup.js +943 -169
  72. package/dist/commands/view-pick.d.ts +4 -0
  73. package/dist/commands/view-pick.js +17 -7
  74. package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
  75. package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
  76. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
  77. package/dist/core/__tests__/canvas-inbox-watcher.test.js +34 -9
  78. package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +1 -0
  79. package/dist/core/__tests__/command-plugins-surfaces.test.js +298 -0
  80. package/dist/core/__tests__/command-plugins.test.d.ts +1 -0
  81. package/dist/core/__tests__/command-plugins.test.js +444 -0
  82. package/dist/core/__tests__/fault-classifier.test.js +15 -0
  83. package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
  84. package/dist/core/__tests__/fixtures/fake-engine.js +9 -1
  85. package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
  86. package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
  87. package/dist/core/__tests__/preview-registry-sync.test.js +30 -1
  88. package/dist/core/__tests__/scope-crouter-home-fence.test.d.ts +1 -0
  89. package/dist/core/__tests__/scope-crouter-home-fence.test.js +55 -0
  90. package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
  91. package/dist/core/__tests__/stream-watchdog.test.js +70 -0
  92. package/dist/core/__tests__/tmux-surface.test.js +72 -0
  93. package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
  94. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  95. package/dist/core/canvas/browse/app.d.ts +6 -0
  96. package/dist/core/canvas/browse/app.js +230 -41
  97. package/dist/core/canvas/browse/model.d.ts +10 -6
  98. package/dist/core/canvas/browse/model.js +20 -12
  99. package/dist/core/canvas/browse/pins.d.ts +4 -0
  100. package/dist/core/canvas/browse/pins.js +29 -0
  101. package/dist/core/canvas/browse/render.d.ts +13 -1
  102. package/dist/core/canvas/browse/render.js +72 -15
  103. package/dist/core/canvas/nav-model.js +5 -11
  104. package/dist/core/canvas/paths.d.ts +5 -0
  105. package/dist/core/canvas/paths.js +7 -0
  106. package/dist/core/command-plugins/adapter.d.ts +15 -0
  107. package/dist/core/command-plugins/adapter.js +145 -0
  108. package/dist/core/command-plugins/compose.d.ts +5 -0
  109. package/dist/core/command-plugins/compose.js +56 -0
  110. package/dist/core/command-plugins/discovery.d.ts +104 -0
  111. package/dist/core/command-plugins/discovery.js +565 -0
  112. package/dist/core/config.d.ts +48 -0
  113. package/dist/core/config.js +228 -110
  114. package/dist/core/fault-classifier.js +1 -1
  115. package/dist/core/fs-utils.d.ts +1 -0
  116. package/dist/core/fs-utils.js +15 -1
  117. package/dist/core/keybindings/__tests__/bespoke-consumers.test.d.ts +1 -0
  118. package/dist/core/keybindings/__tests__/bespoke-consumers.test.js +40 -0
  119. package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
  120. package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
  121. package/dist/core/keybindings/catalog.d.ts +14 -0
  122. package/dist/core/keybindings/catalog.js +257 -0
  123. package/dist/core/keybindings/index.d.ts +5 -0
  124. package/dist/core/keybindings/index.js +4 -0
  125. package/dist/core/keybindings/match.d.ts +29 -0
  126. package/dist/core/keybindings/match.js +99 -0
  127. package/dist/core/keybindings/persistence.d.ts +14 -0
  128. package/dist/core/keybindings/persistence.js +31 -0
  129. package/dist/core/keybindings/resolve.d.ts +14 -0
  130. package/dist/core/keybindings/resolve.js +305 -0
  131. package/dist/core/keybindings/types.d.ts +37 -0
  132. package/dist/core/keybindings/types.js +1 -0
  133. package/dist/core/memory-resolver.d.ts +1 -1
  134. package/dist/core/memory-resolver.js +10 -3
  135. package/dist/core/predicate.d.ts +5 -3
  136. package/dist/core/predicate.js +5 -3
  137. package/dist/core/profiles/select.d.ts +6 -0
  138. package/dist/core/profiles/select.js +86 -52
  139. package/dist/core/provider-management.d.ts +12 -0
  140. package/dist/core/provider-management.js +24 -0
  141. package/dist/core/runtime/broker-protocol.d.ts +33 -5
  142. package/dist/core/runtime/broker.js +164 -14
  143. package/dist/core/runtime/launch.d.ts +39 -6
  144. package/dist/core/runtime/launch.js +78 -20
  145. package/dist/core/runtime/naming.js +3 -3
  146. package/dist/core/runtime/pi-cli.d.ts +6 -0
  147. package/dist/core/runtime/pi-cli.js +16 -2
  148. package/dist/core/runtime/pi-vendored.d.ts +8 -0
  149. package/dist/core/runtime/pi-vendored.js +14 -0
  150. package/dist/core/runtime/placement.d.ts +2 -2
  151. package/dist/core/runtime/placement.js +4 -1
  152. package/dist/core/runtime/promote.js +4 -0
  153. package/dist/core/runtime/recap.d.ts +3 -3
  154. package/dist/core/runtime/recap.js +75 -47
  155. package/dist/core/runtime/recycle.js +6 -1
  156. package/dist/core/runtime/reset.js +5 -0
  157. package/dist/core/runtime/session-list-cache.d.ts +33 -0
  158. package/dist/core/runtime/session-list-cache.js +338 -0
  159. package/dist/core/runtime/spawn.js +52 -45
  160. package/dist/core/runtime/stream-watchdog.d.ts +26 -0
  161. package/dist/core/runtime/stream-watchdog.js +75 -0
  162. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  163. package/dist/core/runtime/tmux-chrome.js +2 -2
  164. package/dist/core/runtime/tmux.d.ts +18 -15
  165. package/dist/core/runtime/tmux.js +217 -110
  166. package/dist/core/scope.js +27 -4
  167. package/dist/core/subscription-state.d.ts +90 -0
  168. package/dist/core/subscription-state.js +762 -0
  169. package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
  170. package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
  171. package/dist/core/tui/host.d.ts +30 -1
  172. package/dist/core/tui/host.js +148 -34
  173. package/dist/core/view/contract.d.ts +17 -3
  174. package/dist/daemon/crtrd.js +253 -12
  175. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
  176. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
  177. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
  178. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
  179. package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
  180. package/dist/pi-extensions/canvas-recap.d.ts +4 -0
  181. package/dist/pi-extensions/canvas-recap.js +73 -46
  182. package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
  183. package/dist/pi-extensions/canvas-stophook.js +33 -4
  184. package/dist/types.d.ts +9 -26
  185. package/dist/types.js +1 -28
  186. package/dist/web-client/assets/{index-DiFuLcp6.js → index--md2ylfi.js} +1 -1
  187. package/dist/web-client/index.html +1 -1
  188. package/dist/web-client/sw.js +1 -1
  189. package/package.json +8 -4
  190. package/dist/pi-extensions/canvas-nav.d.ts +0 -43
  191. package/dist/pi-extensions/canvas-nav.js +0 -640
  192. package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
  193. package/dist/pi-extensions/widget-order-bus.js +0 -34
@@ -1,8 +1,16 @@
1
1
  import { defineLeaf } from '../../core/command.js';
2
2
  import { getMarkdownTheme, getSelectListTheme, getSettingsListTheme } from '@earendil-works/pi-coding-agent';
3
3
  import { TUI, ProcessTerminal, Input, matchesKey, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
4
+ import { AuthStorage, LoginDialogComponent, ModelRegistry } from '@earendil-works/pi-coding-agent';
5
+ import { loginAnthropic, loginOpenAICodex } from '@earendil-works/pi-ai/oauth';
6
+ import { execFile } from 'node:child_process';
7
+ import { join } from 'node:path';
4
8
  import { applyTheme, defaultAgentDir } from '../../clients/attach/config-load.js';
5
- import { buildLoginPicker, summarizeProviderAuth } from '../../clients/attach/auth-pickers.js';
9
+ import { ANTHROPIC_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID, promoteSubscription, readSubscriptionPool, commitManagedLogin, renameSubscription, } from '../../core/subscription-state.js';
10
+ import { removeProviderRow } from '../../core/provider-management.js';
11
+ import { readRawScopeConfig } from '../../core/config.js';
12
+ import { BINDING_CATALOG, formatBinding, formatGesture, matchesPiTuiInput, persistUserKeybindings, resolveKeybindings, KeybindingConcurrentEditError, KeybindingValidationError, } from '../../core/keybindings/index.js';
13
+ import { installTmuxBindings } from '../../core/runtime/tmux-chrome.js';
6
14
  const ANSI_GREEN = (s) => `\x1b[32m${s}\x1b[39m`;
7
15
  const ANSI_YELLOW = (s) => `\x1b[33m${s}\x1b[39m`;
8
16
  const theme = {
@@ -33,7 +41,7 @@ const theme = {
33
41
  return getMarkdownTheme().bold(text);
34
42
  },
35
43
  };
36
- import { buildSetupInitialState, executeSetupSubmission, formatSetupExecutionSummary, renderSetupStaticInstructions, resolveExaKeyPath, } from './setup-core.js';
44
+ import { buildSetupInitialState, executeSetupSubmission, formatSetupExecutionSummary, renderSetupStaticInstructions, resolveExaKeyPath, resolveComposioKeyPath, detectExaKey, detectComposioKey, SetupKeybindingsDraft, } from './setup-core.js';
37
45
  function wrapText(text, width) {
38
46
  const clean = text.trim();
39
47
  if (clean === '')
@@ -67,6 +75,15 @@ function currentWidth() {
67
75
  function toolLabel(tool) {
68
76
  return tool === 'nvim' ? 'nvim' : 'tmux';
69
77
  }
78
+ /** Inline badge for an optional API-key tab header: green ✓ when a key is
79
+ * already resolvable (naming its source so an env-provided key isn't mistaken
80
+ * for the writable file), dim — when absent. */
81
+ function keyStatusBadge(presence, envVar) {
82
+ if (!presence.present)
83
+ return theme.fg('dim', ' · not set');
84
+ const where = presence.source === 'env' ? `${envVar} env` : 'saved';
85
+ return theme.fg('success', ` ✓ configured (${where})`);
86
+ }
70
87
  function renderRows(lines, width) {
71
88
  return lines.map((line) => truncateToWidth(line, width));
72
89
  }
@@ -97,33 +114,386 @@ function framed(lines, width) {
97
114
  });
98
115
  return [top, ...body, bottom];
99
116
  }
100
- class SetupWizard {
117
+ class ProviderPanel {
118
+ tui;
119
+ agentDir;
120
+ renameInput = new Input();
121
+ rows = [];
122
+ selected = 0;
123
+ mode = 'list';
124
+ notice = '';
125
+ catalog = [];
126
+ catalogIndex = 0;
127
+ loginProvider;
128
+ loginLabel = '';
129
+ dialog;
130
+ loginFlow;
131
+ // Wired by the host wizard to bust its render-line cache. The panel proxies pi's
132
+ // LoginDialogComponent (it is not a real pi TUI child), so the host only repaints on
133
+ // synchronous input. startLogin drives OAuth through async promise chains whose state
134
+ // transitions land between keystrokes; notifyChange() lets each one force a fresh frame.
135
+ onChange;
136
+ constructor(tui, agentDir) {
137
+ this.tui = tui;
138
+ this.agentDir = agentDir;
139
+ this.renameInput.onSubmit = () => this.rename();
140
+ this.renameInput.onEscape = () => { this.mode = 'list'; };
141
+ this.reload();
142
+ }
143
+ // Repaint the host after an async login transition: bust the wizard's cached lines
144
+ // (onChange) so render() recomputes the NEW state, then ask pi's loop to draw a frame.
145
+ notifyChange() {
146
+ this.onChange?.();
147
+ this.tui.requestRender();
148
+ }
149
+ providerCatalog(auth, registry) {
150
+ const oauth = auth.getOAuthProviders();
151
+ const oauthIds = new Set(oauth.map((provider) => provider.id));
152
+ const seen = new Set();
153
+ return [
154
+ ...oauth.map((provider) => ({ providerId: provider.id, name: provider.name, managed: provider.id === ANTHROPIC_PROVIDER_ID || provider.id === OPENAI_CODEX_PROVIDER_ID, authType: 'oauth' })),
155
+ ...registry.getAll().map((model) => ({ providerId: model.provider, name: registry.getProviderDisplayName(model.provider), managed: model.provider === ANTHROPIC_PROVIDER_ID || model.provider === OPENAI_CODEX_PROVIDER_ID, authType: oauthIds.has(model.provider) ? 'oauth' : 'api_key' })),
156
+ ].filter((provider) => !seen.has(provider.providerId) && Boolean(seen.add(provider.providerId)));
157
+ }
158
+ reload() {
159
+ const auth = AuthStorage.create(join(this.agentDir, 'auth.json'));
160
+ const registry = ModelRegistry.create(auth, join(this.agentDir, 'models.json'));
161
+ const rows = [];
162
+ for (const provider of this.providerCatalog(auth, registry)) {
163
+ const status = auth.getAuthStatus(provider.providerId);
164
+ if (provider.managed) {
165
+ const pool = readSubscriptionPool(provider.providerId);
166
+ if (pool.length === 0 && !status.source)
167
+ continue;
168
+ rows.push({ ...provider, configured: pool.length > 0 || status.configured, source: status.source, sourceLabel: status.label });
169
+ pool.forEach((account, index) => rows.push({ ...provider, label: account.label === provider.providerId ? 'Default account' : account.label, preferred: index === 0, cooldownUntil: account.rateLimitedUntil, accountId: account.accountId }));
170
+ continue;
171
+ }
172
+ if (!status.source)
173
+ continue;
174
+ const stored = auth.get(provider.providerId);
175
+ rows.push({ ...provider, configured: status.configured, source: status.source, sourceLabel: status.label, accountId: typeof stored?.accountId === 'string' ? stored.accountId : undefined });
176
+ }
177
+ this.rows = rows;
178
+ this.selected = Math.min(this.selected, Math.max(0, rows.length));
179
+ }
180
+ selectedRow() { return this.rows[this.selected]; }
181
+ openBrowser(url) {
182
+ const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
183
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
184
+ execFile(command, args, { timeout: 5_000 }, () => { });
185
+ }
186
+ openCatalog() {
187
+ const auth = AuthStorage.create(join(this.agentDir, 'auth.json'));
188
+ this.catalog = this.providerCatalog(auth, ModelRegistry.create(auth, join(this.agentDir, 'models.json')));
189
+ this.catalogIndex = 0;
190
+ this.mode = 'catalog';
191
+ }
192
+ startLogin(provider, label = provider.providerId) {
193
+ this.loginProvider = provider;
194
+ this.loginLabel = label;
195
+ const auth = AuthStorage.create(join(this.agentDir, 'auth.json'));
196
+ let flow;
197
+ const complete = (message) => {
198
+ if (!flow.active || this.loginFlow !== flow)
199
+ return;
200
+ flow.active = false;
201
+ this.loginFlow = undefined;
202
+ this.dialog = undefined;
203
+ this.mode = 'list';
204
+ this.notice = message;
205
+ this.reload();
206
+ this.notifyChange();
207
+ };
208
+ const dialog = new LoginDialogComponent(this.tui, provider.providerId, (success, message) => {
209
+ if (!success)
210
+ complete(message ? `Login failed: ${message}` : 'Login cancelled');
211
+ }, provider.name);
212
+ flow = { dialog, active: true };
213
+ this.loginFlow = flow;
214
+ this.dialog = dialog;
215
+ this.mode = 'login';
216
+ const isActive = () => flow.active && this.loginFlow === flow && !dialog.signal.aborted;
217
+ const fail = (error) => {
218
+ complete(error.name === 'AbortError' ? 'Login cancelled' : `Login error: ${error instanceof Error ? error.message : String(error)}`);
219
+ };
220
+ if (provider.authType === 'api_key') {
221
+ void dialog.showPrompt(`Enter API key for ${provider.name}:`, 'sk-...').then((key) => {
222
+ if (!isActive())
223
+ return;
224
+ if (!key.trim()) {
225
+ complete('No key entered — login cancelled');
226
+ return;
227
+ }
228
+ auth.set(provider.providerId, { type: 'api_key', key: key.trim() });
229
+ complete(`API key saved for ${provider.name}`);
230
+ }).catch(fail);
231
+ return;
232
+ }
233
+ if (!provider.managed) {
234
+ void auth.login(provider.providerId, {
235
+ onAuth: (info) => { if (isActive()) {
236
+ dialog.showAuth(info.url, info.instructions);
237
+ this.openBrowser(info.url);
238
+ this.notifyChange();
239
+ } },
240
+ onDeviceCode: (info) => { if (isActive()) {
241
+ dialog.showDeviceCode(info);
242
+ this.openBrowser(info.verificationUri);
243
+ this.notifyChange();
244
+ } },
245
+ onPrompt: (prompt) => { const answer = dialog.showPrompt(prompt.message, prompt.placeholder); this.notifyChange(); return answer; },
246
+ onProgress: (message) => { if (isActive()) {
247
+ dialog.showProgress(message);
248
+ this.notifyChange();
249
+ } },
250
+ onManualCodeInput: () => { const answer = dialog.showManualInput('Paste the code or URL:'); this.notifyChange(); return answer; },
251
+ onSelect: async (prompt) => { const answer = dialog.showPrompt(`${prompt.message}\n${prompt.options.map((option, index) => `${index + 1}. ${option.label}`).join('\n')}`, '1'); this.notifyChange(); return prompt.options[(Number.parseInt(await answer, 10) || 1) - 1]?.id; },
252
+ signal: dialog.signal,
253
+ }).then(() => { if (isActive())
254
+ complete(`Logged in to ${provider.name}`); }).catch(fail);
255
+ return;
256
+ }
257
+ const login = provider.providerId === ANTHROPIC_PROVIDER_ID ? loginAnthropic : loginOpenAICodex;
258
+ // Snapshot the default slot's rate-limit timestamp BEFORE OAuth begins: a strictly newer
259
+ // concurrent 429 recorded during the login must survive a same-account re-login.
260
+ const defaultLastRateLimitedAt = readSubscriptionPool(provider.providerId).find((entry) => entry.label === provider.providerId)?.lastRateLimitedAt ?? 0;
261
+ void login({
262
+ onAuth: ({ url, instructions }) => { if (isActive()) {
263
+ dialog.showAuth(url, instructions);
264
+ this.openBrowser(url);
265
+ this.notifyChange();
266
+ } },
267
+ onPrompt: async (prompt) => { const answer = dialog.showPrompt(prompt.message, prompt.placeholder); this.notifyChange(); return answer; },
268
+ onProgress: (message) => { if (isActive()) {
269
+ dialog.showProgress(message);
270
+ this.notifyChange();
271
+ } },
272
+ onManualCodeInput: () => { const answer = dialog.showManualInput('Paste the code or URL:'); this.notifyChange(); return answer; },
273
+ }).then((credential) => {
274
+ if (!isActive())
275
+ return;
276
+ const accountId = typeof credential.accountId === 'string' && credential.accountId.trim() ? credential.accountId.trim() : undefined;
277
+ commitManagedLogin(provider.providerId, this.loginLabel, { refresh: credential.refresh, access: credential.access, expires: credential.expires, accountId, defaultLastRateLimitedAt });
278
+ if (isActive())
279
+ complete(`Logged in to ${provider.name}`);
280
+ }).catch(fail);
281
+ }
282
+ isNestedFlowActive() { return this.mode === 'login'; }
283
+ dispose() {
284
+ const flow = this.loginFlow;
285
+ if (!flow)
286
+ return;
287
+ flow.active = false;
288
+ this.loginFlow = undefined;
289
+ this.dialog = undefined;
290
+ flow.dialog.handleInput('\u001b');
291
+ }
292
+ rename() {
293
+ const row = this.selectedRow();
294
+ if (!row?.managed || !row.label || row.label === 'Default account')
295
+ return;
296
+ try {
297
+ renameSubscription(row.providerId, row.label, this.renameInput.getValue());
298
+ this.notice = `Renamed ${row.name} account`;
299
+ this.mode = 'list';
300
+ this.reload();
301
+ }
302
+ catch (error) {
303
+ this.notice = error instanceof Error ? error.message : String(error);
304
+ this.mode = 'list';
305
+ }
306
+ }
307
+ handleInput(data) {
308
+ if (this.mode === 'login') {
309
+ this.dialog?.handleInput(data);
310
+ return true;
311
+ }
312
+ if (this.mode === 'rename' || this.mode === 'label') {
313
+ this.renameInput.handleInput(data);
314
+ return true;
315
+ }
316
+ if (this.mode === 'catalog') {
317
+ if (matchesKey(data, 'escape')) {
318
+ this.mode = 'list';
319
+ return true;
320
+ }
321
+ if (matchesKey(data, 'j') || matchesKey(data, 'down')) {
322
+ this.catalogIndex = Math.min(this.catalog.length - 1, this.catalogIndex + 1);
323
+ return true;
324
+ }
325
+ if (matchesKey(data, 'k') || matchesKey(data, 'up')) {
326
+ this.catalogIndex = Math.max(0, this.catalogIndex - 1);
327
+ return true;
328
+ }
329
+ if (matchesKey(data, 'enter')) {
330
+ const provider = this.catalog[this.catalogIndex];
331
+ if (provider.managed) {
332
+ this.loginProvider = provider;
333
+ this.renameInput.setValue('');
334
+ this.renameInput.onSubmit = () => { const label = this.renameInput.getValue().trim(); if (label)
335
+ this.startLogin(provider, label); };
336
+ this.mode = 'label';
337
+ }
338
+ else
339
+ this.startLogin(provider);
340
+ return true;
341
+ }
342
+ return true;
343
+ }
344
+ if (this.mode === 'remove') {
345
+ if (matchesKey(data, 'y') || matchesKey(data, 'enter')) {
346
+ const row = this.selectedRow();
347
+ if (row && (row.managed || row.configured)) {
348
+ try {
349
+ this.notice = removeProviderRow(this.agentDir, row);
350
+ this.reload();
351
+ }
352
+ catch (error) {
353
+ this.notice = error instanceof Error ? error.message : String(error);
354
+ }
355
+ }
356
+ }
357
+ this.mode = 'list';
358
+ return true;
359
+ }
360
+ if (matchesKey(data, 'j') || matchesKey(data, 'down')) {
361
+ this.selected = Math.min(this.rows.length, this.selected + 1);
362
+ return true;
363
+ }
364
+ if (matchesKey(data, 'k') || matchesKey(data, 'up')) {
365
+ this.selected = Math.max(0, this.selected - 1);
366
+ return true;
367
+ }
368
+ const row = this.selectedRow();
369
+ if (matchesKey(data, 'p') && row?.managed && row.label) {
370
+ try {
371
+ promoteSubscription(row.providerId, row.label === 'Default account' ? row.providerId : row.label);
372
+ this.notice = `Preferred ${row.name} account`;
373
+ this.reload();
374
+ }
375
+ catch (error) {
376
+ this.notice = error instanceof Error ? error.message : String(error);
377
+ }
378
+ return true;
379
+ }
380
+ if (matchesKey(data, 'r') && row?.managed && row.label && row.label !== 'Default account') {
381
+ this.renameInput.setValue(row.label);
382
+ this.renameInput.onSubmit = () => this.rename();
383
+ this.mode = 'rename';
384
+ return true;
385
+ }
386
+ if (matchesKey(data, 'x') && (row?.managed || row?.configured)) {
387
+ this.mode = 'remove';
388
+ return true;
389
+ }
390
+ if (matchesKey(data, 'a')) {
391
+ this.openCatalog();
392
+ return true;
393
+ }
394
+ const viewOnly = row?.source === 'environment' || row?.source === 'runtime';
395
+ if (matchesKey(data, 'l') && row && !viewOnly) {
396
+ if (row.managed && row.label)
397
+ this.startLogin(row, row.label === 'Default account' ? row.providerId : row.label);
398
+ else
399
+ this.startLogin(row);
400
+ return true;
401
+ }
402
+ if (matchesKey(data, 'enter')) {
403
+ if (row && !viewOnly)
404
+ this.startLogin(row, row.managed && row.label ? (row.label === 'Default account' ? row.providerId : row.label) : row.providerId);
405
+ else if (!row)
406
+ this.openCatalog();
407
+ return true;
408
+ }
409
+ return false;
410
+ }
411
+ render(width) {
412
+ const body = [theme.fg('accent', theme.bold('Providers')), theme.fg('dim', `${this.rows.filter((row) => row.label === undefined).length} configured`)];
413
+ if (this.mode === 'login')
414
+ return framed(this.dialog?.render(Math.max(1, width - 4)) ?? [], width);
415
+ if (this.mode === 'rename' || this.mode === 'label')
416
+ body.push('', theme.fg('accent', this.mode === 'rename' ? 'Rename account' : `Name the ${this.loginProvider?.name ?? 'provider'} account`), ...this.renameInput.render(Math.max(1, width - 8)));
417
+ else if (this.mode === 'catalog') {
418
+ body.push('', theme.fg('dim', 'Select a provider to add · esc back'));
419
+ this.catalog.forEach((provider, index) => body.push(`${index === this.catalogIndex ? theme.fg('accent', '▶') : ' '} ${provider.name}`));
420
+ }
421
+ else if (this.mode === 'remove') {
422
+ const target = this.selectedRow();
423
+ const label = target?.label ?? (target?.managed ? `${target.name} · all accounts` : target?.name ?? 'account');
424
+ body.push('', theme.fg('warning', `Remove ${label}? y/enter confirm · any other key cancel`));
425
+ }
426
+ else {
427
+ if (this.rows.length === 0)
428
+ body.push('', theme.fg('warning', 'No provider configured yet.'));
429
+ for (let index = 0; index < this.rows.length; index++) {
430
+ const row = this.rows[index];
431
+ const selected = index === this.selected ? theme.fg('accent', '▶') : ' ';
432
+ if (!row.label) {
433
+ const state = row.managed
434
+ ? `${this.rows.filter((item) => item.providerId === row.providerId && item.label).length} accounts`
435
+ : row.source === 'environment' || row.source === 'runtime'
436
+ ? `${row.source}${row.sourceLabel ? ` · ${row.sourceLabel}` : ''} (view only)`
437
+ : 'configured';
438
+ body.push(`${selected} ${theme.fg('accent', row.name)}${theme.fg('dim', ` ${state}`)}`);
439
+ }
440
+ else {
441
+ const health = row.cooldownUntil && row.cooldownUntil > Date.now() ? `cooldown ${Math.ceil((row.cooldownUntil - Date.now()) / 60000)}m` : 'ready';
442
+ const identity = row.accountId ? ` · ${row.accountId.length > 12 ? `${row.accountId.slice(0, 8)}…${row.accountId.slice(-4)}` : row.accountId}` : '';
443
+ body.push(`${selected} ${row.preferred ? theme.fg('success', '●') : ' '} ${row.label}${theme.fg('dim', identity)} ${theme.fg('dim', health)}${row.preferred ? theme.fg('accent', ' preferred') : ''}`);
444
+ }
445
+ }
446
+ body.push('', `${this.selected === this.rows.length ? theme.fg('accent', '▶') : ' '} ${theme.fg('accent', '+ Add provider')}`);
447
+ if (this.notice)
448
+ body.push('', theme.fg('dim', this.notice));
449
+ }
450
+ return framed(body, width);
451
+ }
452
+ }
453
+ export class SetupWizard {
101
454
  manifest;
102
455
  installedPackageIds;
103
456
  systemTools;
104
457
  exaInput = new Input();
458
+ composioInput = new Input();
459
+ keyInput = new Input();
460
+ openingBindings;
461
+ keyDraft;
462
+ terminalRows;
105
463
  selectedPackageIds = new Set();
106
464
  selectedSystemTools = new Set();
107
- authSummary;
108
- reloadAuthSummary;
109
- notice;
110
- onOpenLogin;
465
+ providers;
111
466
  activeTab = 0;
112
467
  pluginIndex = 0;
113
468
  systemIndex = 0;
114
469
  expandedPluginId = null;
115
470
  confirming = false;
471
+ keyIndex = 0;
472
+ keyScroll = 0;
473
+ keyFilter = '';
474
+ filteringKeys = false;
475
+ editingKey = null;
476
+ keyEditError;
477
+ keyDiagnostics = [];
478
+ keyConfirm = null;
479
+ keyNotice;
116
480
  cachedWidth;
117
481
  cachedLines;
118
482
  _focused = false;
119
483
  onSubmit;
120
484
  onCancel;
121
- constructor(manifest, installedPackageIds, systemTools, authSummary, reloadAuthSummary) {
485
+ constructor(manifest, installedPackageIds, systemTools, tui, agentDir, openingBindings, rawKeybindings, terminalRows) {
122
486
  this.manifest = manifest;
487
+ this.providers = new ProviderPanel(tui, agentDir);
488
+ // The panel drives OAuth async; bust our line cache when it transitions so the panel's
489
+ // requestRender repaints the NEW frame (progress → success notice → roster) without a keypress.
490
+ this.providers.onChange = () => this.invalidate();
123
491
  this.installedPackageIds = installedPackageIds;
124
492
  this.systemTools = systemTools;
125
- this.authSummary = authSummary;
126
- this.reloadAuthSummary = reloadAuthSummary;
493
+ this.terminalRows = terminalRows;
494
+ this.openingBindings = openingBindings;
495
+ this.keyDraft = new SetupKeybindingsDraft(rawKeybindings);
496
+ this.keyDiagnostics = this.keyDraft.resolution().diagnostics;
127
497
  for (const entry of this.manifest)
128
498
  this.selectedPackageIds.add(entry.id);
129
499
  for (const tool of this.systemTools) {
@@ -134,10 +504,19 @@ class SetupWizard {
134
504
  this.systemIndex = firstMissingSystem >= 0 ? firstMissingSystem : 0;
135
505
  this.exaInput.onSubmit = () => this.finalize();
136
506
  this.exaInput.onEscape = () => this.cancel();
507
+ this.composioInput.onSubmit = () => this.finalize();
508
+ this.composioInput.onEscape = () => this.cancel();
509
+ // Edit-mode Enter/Escape are routed through the opening snapshot's
510
+ // `crtr.setup.keybindings.commit-edit` / `.cancel-edit` bindings in
511
+ // handleKeybindingsInput (before the pi Input sees them), so the pi Input's
512
+ // own submit/escape handlers stay unset — those catalog rows are honored
513
+ // even when remapped away from Enter/Escape.
137
514
  }
138
515
  set focused(value) {
139
516
  this._focused = value;
140
517
  this.exaInput.focused = value && this.activeTab === 3 && !this.confirming;
518
+ this.composioInput.focused = value && this.activeTab === 4 && !this.confirming;
519
+ this.keyInput.focused = value && this.activeTab === 5 && this.editingKey !== null;
141
520
  }
142
521
  get focused() {
143
522
  return this._focused;
@@ -146,23 +525,15 @@ class SetupWizard {
146
525
  this.cachedWidth = undefined;
147
526
  this.cachedLines = undefined;
148
527
  }
149
- /** Show a one-line notice on the login tab (login success/failure feedback). */
150
- setNotice(message) {
151
- this.notice = message;
152
- this.invalidate();
153
- }
154
- /** Recompute provider auth status after a login flow closes. */
155
- refreshAuth() {
156
- this.authSummary = this.reloadAuthSummary();
157
- this.invalidate();
158
- }
159
528
  setTab(next) {
160
529
  this.activeTab = next;
161
530
  this.exaInput.focused = this.focused && next === 3 && !this.confirming;
531
+ this.composioInput.focused = this.focused && next === 4 && !this.confirming;
532
+ this.keyInput.focused = this.focused && next === 5 && this.editingKey !== null;
162
533
  this.invalidate();
163
534
  }
164
535
  moveTab(delta) {
165
- const next = ((this.activeTab + delta + 4) % 4);
536
+ const next = ((this.activeTab + delta + 6) % 6);
166
537
  this.setTab(next);
167
538
  }
168
539
  cancel() {
@@ -171,14 +542,38 @@ class SetupWizard {
171
542
  this.invalidate();
172
543
  return;
173
544
  }
545
+ this.providers.dispose();
174
546
  this.onCancel?.();
175
547
  }
548
+ dispose() { this.providers.dispose(); }
549
+ matches(id, data) {
550
+ return matchesPiTuiInput(this.openingBindings, id, data, (input, gesture) => matchesKey(input, gesture));
551
+ }
552
+ /** Human hint label for an action, derived from the opening snapshot so footers
553
+ * reflect the effective (possibly remapped) gestures the wizard actually honors. */
554
+ label(id) {
555
+ return formatBinding(this.openingBindings, id);
556
+ }
557
+ /** Like `label`, but drops gestures a focused pi text Input consumes as data
558
+ * (single printable chars and the arrow keys), so the Exa/Composio footers do
559
+ * not claim keys that never reach the wizard while a field owns focus. */
560
+ fieldSafeLabel(id) {
561
+ const survives = this.openingBindings.gestures(id).filter((gesture) => {
562
+ if (gesture.includes(' > '))
563
+ return true;
564
+ const key = gesture.split('+').pop();
565
+ if (key === 'up' || key === 'down' || key === 'left' || key === 'right')
566
+ return false;
567
+ return key.length > 1 || gesture.includes('+');
568
+ });
569
+ return survives.length === 0 ? 'Disabled' : survives.map(formatGesture).join(' / ');
570
+ }
176
571
  handleGlobalTabSwitch(data) {
177
- if (matchesKey(data, 'tab') || matchesKey(data, 'right')) {
572
+ if (this.matches('crtr.setup.tab-next', data)) {
178
573
  this.moveTab(1);
179
574
  return true;
180
575
  }
181
- if (matchesKey(data, 'shift+tab') || matchesKey(data, 'left')) {
576
+ if (this.matches('crtr.setup.tab-previous', data)) {
182
577
  this.moveTab(-1);
183
578
  return true;
184
579
  }
@@ -246,6 +641,8 @@ class SetupWizard {
246
641
  systemDeps: this.systemTools.filter((tool) => !tool.installed && this.selectedSystemTools.has(tool.tool)).map((tool) => tool.plan),
247
642
  exaKey: this.exaInput.getValue().trim(),
248
643
  exaKeyPath: resolveExaKeyPath(),
644
+ composioKey: this.composioInput.getValue().trim(),
645
+ composioKeyPath: resolveComposioKeyPath(),
249
646
  };
250
647
  }
251
648
  finalize() {
@@ -260,81 +657,378 @@ class SetupWizard {
260
657
  }
261
658
  this.onSubmit?.(this.buildSubmission());
262
659
  }
660
+ filteredKeybindings() {
661
+ const resolution = this.keyDraft.resolution();
662
+ const needle = this.keyFilter.trim().toLowerCase();
663
+ if (needle === '')
664
+ return BINDING_CATALOG;
665
+ return BINDING_CATALOG.filter((definition) => {
666
+ const rendered = formatBinding(resolution, definition.id);
667
+ return [definition.name, definition.id, definition.group, definition.description, rendered]
668
+ .some((value) => value.toLowerCase().includes(needle));
669
+ });
670
+ }
671
+ selectedKeybinding() {
672
+ const rows = this.filteredKeybindings();
673
+ if (rows.length === 0)
674
+ return undefined;
675
+ this.keyIndex = Math.max(0, Math.min(this.keyIndex, rows.length - 1));
676
+ return rows[this.keyIndex];
677
+ }
678
+ moveKey(delta) {
679
+ const rows = this.filteredKeybindings();
680
+ if (rows.length === 0)
681
+ return;
682
+ this.keyIndex = Math.max(0, Math.min(rows.length - 1, this.keyIndex + delta));
683
+ const page = this.keyPageSize();
684
+ if (this.keyIndex < this.keyScroll)
685
+ this.keyScroll = this.keyIndex;
686
+ if (this.keyIndex >= this.keyScroll + page)
687
+ this.keyScroll = this.keyIndex - page + 1;
688
+ this.keyEditError = undefined;
689
+ this.invalidate();
690
+ }
691
+ // Max output lines the row region may emit before it starts clipping the
692
+ // confirm prompt / notice / footer. Budgets the fixed chrome around the list:
693
+ // header (3) + blank + tab header/count (2) + blank + footer (1) plus a small
694
+ // reserve for a diagnostic line and the notice/confirm block.
695
+ keyViewportLines() {
696
+ return Math.max(4, this.terminalRows() - 11);
697
+ }
698
+ // Rows moved per PageUp/PageDown and used for scroll clamping. Kept below the
699
+ // raw viewport so interleaved group-heading lines and the selected row's
700
+ // diagnostic line cannot push the selection off-screen.
701
+ keyPageSize() {
702
+ return Math.max(3, this.keyViewportLines() - 4);
703
+ }
704
+ beginKeyEdit() {
705
+ const row = this.selectedKeybinding();
706
+ if (!row)
707
+ return;
708
+ if (this.keyDraft.hasInvalidRoot()) {
709
+ this.keyNotice = `Saved keybindings root is invalid — ${this.label('crtr.setup.keybindings.reset-all')} (reset all) before editing.`;
710
+ this.invalidate();
711
+ return;
712
+ }
713
+ this.editingKey = row.id;
714
+ this.keyInput.setValue(this.keyDraft.expression(row.id));
715
+ // pi Input.setValue only clamps the cursor; seed it, then send ctrl+e
716
+ // (cursorLineEnd) so the cursor sits at end-of-value and the first keystroke
717
+ // appends after the seed instead of prepending before it.
718
+ this.keyInput.handleInput('\x05');
719
+ this.keyInput.focused = this.focused;
720
+ this.keyEditError = undefined;
721
+ this.invalidate();
722
+ }
723
+ commitKeyEdit() {
724
+ if (this.editingKey === null)
725
+ return;
726
+ const result = this.keyDraft.edit(this.editingKey, this.keyInput.getValue());
727
+ if (!result.ok) {
728
+ this.keyEditError = result.diagnostics[0]?.message ?? 'Invalid keybinding';
729
+ this.keyDiagnostics = result.diagnostics;
730
+ this.invalidate();
731
+ return;
732
+ }
733
+ this.editingKey = null;
734
+ this.keyInput.focused = false;
735
+ this.keyEditError = undefined;
736
+ this.keyDiagnostics = this.keyDraft.resolution().diagnostics;
737
+ this.keyNotice = undefined;
738
+ this.invalidate();
739
+ }
740
+ cancelKeyEdit() {
741
+ this.editingKey = null;
742
+ this.keyInput.focused = false;
743
+ this.keyEditError = undefined;
744
+ // Drop the rejected edit's collision diagnostics: the draft never took the
745
+ // offending value, so restore diagnostics from the committed draft state.
746
+ this.keyDiagnostics = this.keyDraft.resolution().diagnostics;
747
+ this.invalidate();
748
+ }
749
+ saveKeybindings() {
750
+ const resolution = this.keyDraft.resolution();
751
+ this.keyDiagnostics = resolution.diagnostics;
752
+ const errors = resolution.diagnostics.filter((diagnostic) => diagnostic.severity === 'error');
753
+ if (errors.length > 0) {
754
+ this.keyNotice = errors[0].message;
755
+ this.invalidate();
756
+ return;
757
+ }
758
+ try {
759
+ const saved = persistUserKeybindings(this.keyDraft.overrides(), {
760
+ expectedKeybindings: this.keyDraft.expectedKeybindings(),
761
+ });
762
+ this.keyDraft.markSaved(saved.overrides);
763
+ this.keyNotice = 'Saved. Reload attach and reopen active views to apply changes; refreshing tmux…';
764
+ // The config is already persisted; a tmux-refresh failure (installer throw)
765
+ // must land in the notice, never as an unhandled rejection that tears down
766
+ // the wizard mid-TUI right after a good save.
767
+ void this.refreshTmuxBindingsAfterSave().catch((error) => {
768
+ this.keyNotice = `Saved, but tmux refresh failed: ${error instanceof Error ? error.message : String(error)}`;
769
+ this.invalidate();
770
+ });
771
+ }
772
+ catch (error) {
773
+ if (error instanceof KeybindingConcurrentEditError) {
774
+ this.keyNotice = 'Keybindings changed on disk. Reopen setup before saving.';
775
+ }
776
+ else if (error instanceof KeybindingValidationError) {
777
+ this.keyDiagnostics = error.diagnostics;
778
+ this.keyNotice = error.message;
779
+ }
780
+ else {
781
+ this.keyNotice = error instanceof Error ? error.message : String(error);
782
+ }
783
+ }
784
+ this.invalidate();
785
+ }
786
+ async refreshTmuxBindingsAfterSave() {
787
+ // Unit 2's frozen tmux-chrome seam: no-arg install re-reads the
788
+ // just-persisted user config and reconciles the crouter-owned bindings.
789
+ const result = installTmuxBindings();
790
+ const occupied = result.diagnostics.filter((diagnostic) => diagnostic.code === 'occupied');
791
+ if (result.ok && occupied.length === 0) {
792
+ this.keyNotice = 'Saved and applied to tmux. Reload attach and reopen active views.';
793
+ }
794
+ else if (result.ok) {
795
+ this.keyNotice = `Saved; tmux kept occupied keys: ${occupied.map((diagnostic) => diagnostic.message).join('; ')}`;
796
+ }
797
+ else {
798
+ this.keyNotice = `Saved, but tmux refresh failed: ${result.diagnostics.find((diagnostic) => diagnostic.code === 'tmux-error')?.message ?? 'tmux unavailable'}`;
799
+ }
800
+ this.invalidate();
801
+ }
802
+ confirmKeyAction() {
803
+ if (this.keyConfirm === 'reset-all') {
804
+ this.keyDraft.resetAll();
805
+ this.keyDiagnostics = [];
806
+ this.keyNotice = 'All rows reset to catalog defaults. Press save to write.';
807
+ }
808
+ else if (this.keyConfirm === 'discard') {
809
+ this.keyDraft.discard();
810
+ this.keyDiagnostics = this.keyDraft.resolution().diagnostics;
811
+ this.keyNotice = 'Unsaved keybinding changes discarded.';
812
+ }
813
+ this.keyConfirm = null;
814
+ this.invalidate();
815
+ }
816
+ handleKeybindingsInput(data) {
817
+ if (this.keyConfirm !== null) {
818
+ if (this.matches('crtr.setup.confirm.accept', data))
819
+ this.confirmKeyAction();
820
+ else if (this.matches('crtr.setup.confirm.back', data)) {
821
+ this.keyConfirm = null;
822
+ this.invalidate();
823
+ }
824
+ return;
825
+ }
826
+ if (this.editingKey !== null) {
827
+ // Honor the catalog rows for commit/cancel BEFORE the pi Input sees the
828
+ // keystroke, so a remap of `commit-edit`/`cancel-edit` takes effect.
829
+ if (this.matches('crtr.setup.keybindings.commit-edit', data)) {
830
+ this.commitKeyEdit();
831
+ return;
832
+ }
833
+ if (this.matches('crtr.setup.keybindings.cancel-edit', data)) {
834
+ this.cancelKeyEdit();
835
+ return;
836
+ }
837
+ if (this.matches('crtr.setup.keybindings.page-up', data)) {
838
+ this.moveKey(-this.keyPageSize());
839
+ return;
840
+ }
841
+ if (this.matches('crtr.setup.keybindings.page-down', data)) {
842
+ this.moveKey(this.keyPageSize());
843
+ return;
844
+ }
845
+ this.keyInput.handleInput(data);
846
+ this.invalidate();
847
+ return;
848
+ }
849
+ if (this.filteringKeys) {
850
+ if (this.matches('crtr.setup.keybindings.filter-clear', data)) {
851
+ this.keyFilter = '';
852
+ this.filteringKeys = false;
853
+ this.keyIndex = 0;
854
+ this.keyScroll = 0;
855
+ }
856
+ else if (this.matches('crtr.setup.keybindings.edit', data)) {
857
+ // Enter commits the filter: leave filter-entry but KEEP keyFilter so the
858
+ // narrowed list can be navigated and its rows edited.
859
+ this.filteringKeys = false;
860
+ }
861
+ else if (this.matches('crtr.setup.keybindings.filter-delete', data)) {
862
+ this.keyFilter = this.keyFilter.slice(0, -1);
863
+ this.keyIndex = 0;
864
+ this.keyScroll = 0;
865
+ }
866
+ else if (data.length === 1 && data.charCodeAt(0) >= 32) {
867
+ this.keyFilter += data;
868
+ this.keyIndex = 0;
869
+ this.keyScroll = 0;
870
+ }
871
+ this.invalidate();
872
+ return;
873
+ }
874
+ if (this.matches('crtr.setup.keybindings.filter', data)) {
875
+ this.filteringKeys = true;
876
+ }
877
+ else if (this.matches('crtr.setup.list-down', data)) {
878
+ this.moveKey(1);
879
+ return;
880
+ }
881
+ else if (this.matches('crtr.setup.list-up', data)) {
882
+ this.moveKey(-1);
883
+ return;
884
+ }
885
+ else if (this.matches('crtr.setup.keybindings.page-up', data)) {
886
+ this.moveKey(-this.keyPageSize());
887
+ return;
888
+ }
889
+ else if (this.matches('crtr.setup.keybindings.page-down', data)) {
890
+ this.moveKey(this.keyPageSize());
891
+ return;
892
+ }
893
+ else if (this.matches('crtr.setup.keybindings.edit', data)) {
894
+ this.beginKeyEdit();
895
+ return;
896
+ }
897
+ else if (this.matches('crtr.setup.keybindings.unbind', data)) {
898
+ const row = this.selectedKeybinding();
899
+ if (row) {
900
+ const result = this.keyDraft.unbind(row.id);
901
+ this.keyDiagnostics = result.ok ? this.keyDraft.resolution().diagnostics : result.diagnostics;
902
+ this.keyEditError = result.ok ? undefined : result.diagnostics[0]?.message;
903
+ }
904
+ }
905
+ else if (this.matches('crtr.setup.keybindings.reset-row', data)) {
906
+ const row = this.selectedKeybinding();
907
+ if (row)
908
+ this.keyDraft.resetRow(row.id);
909
+ this.keyDiagnostics = this.keyDraft.resolution().diagnostics;
910
+ }
911
+ else if (this.matches('crtr.setup.keybindings.reset-all', data)) {
912
+ this.keyConfirm = 'reset-all';
913
+ }
914
+ else if (this.matches('crtr.setup.keybindings.save', data)) {
915
+ this.saveKeybindings();
916
+ return;
917
+ }
918
+ else if (this.matches('crtr.setup.keybindings.discard', data)) {
919
+ // A committed (kept) filter is cleared first, before discard is considered.
920
+ if (this.keyFilter !== '') {
921
+ this.keyFilter = '';
922
+ this.keyIndex = 0;
923
+ this.keyScroll = 0;
924
+ this.keyNotice = undefined;
925
+ }
926
+ else if (this.keyDraft.dirty)
927
+ this.keyConfirm = 'discard';
928
+ else
929
+ this.keyNotice = 'No unsaved keybinding changes.';
930
+ }
931
+ else {
932
+ return;
933
+ }
934
+ this.invalidate();
935
+ }
263
936
  handleInput(data) {
937
+ // A nested provider login owns all input until it settles. In particular, do not let
938
+ // wizard-wide q/Tab navigation abandon the panel while its OAuth promise can still resolve.
939
+ if (this.activeTab === 0 && this.providers.isNestedFlowActive()) {
940
+ if (this.providers.handleInput(data))
941
+ this.invalidate();
942
+ return;
943
+ }
264
944
  if (this.confirming) {
265
- if (matchesKey(data, 'enter') || matchesKey(data, 'y')) {
945
+ if (this.matches('crtr.setup.confirm.accept', data)) {
266
946
  this.onSubmit?.(this.buildSubmission());
267
947
  return;
268
948
  }
269
- if (matchesKey(data, 'n') || matchesKey(data, 'escape') || matchesKey(data, 'q')) {
949
+ if (this.matches('crtr.setup.confirm.back', data)) {
270
950
  this.confirming = false;
271
951
  this.invalidate();
272
- return;
273
952
  }
274
953
  return;
275
954
  }
276
- if (matchesKey(data, 'ctrl+c') || matchesKey(data, 'ctrl+d') || matchesKey(data, 'q')) {
955
+ // Keybindings sub-states (editing / filtering / confirm) own input before the
956
+ // wizard-level cancel and tab gestures can steal it.
957
+ if (this.activeTab === 5 && (this.editingKey !== null || this.filteringKeys || this.keyConfirm !== null)) {
958
+ this.handleKeybindingsInput(data);
959
+ return;
960
+ }
961
+ // Printable chars and arrows on the API-key tabs go to the focused field.
962
+ if ((this.activeTab === 3 || this.activeTab === 4)
963
+ && ((data.length === 1 && data.charCodeAt(0) >= 32)
964
+ || matchesKey(data, 'left') || matchesKey(data, 'right') || matchesKey(data, 'up') || matchesKey(data, 'down'))) {
965
+ (this.activeTab === 3 ? this.exaInput : this.composioInput).handleInput(data);
966
+ this.invalidate();
967
+ return;
968
+ }
969
+ // On the keybindings tab in list mode, a dirty draft must not be dropped
970
+ // silently by the wizard-level cancel gesture — route it through the same
971
+ // discard-confirm the discard path uses.
972
+ if (this.activeTab === 5 && this.keyDraft.dirty && this.matches('crtr.setup.cancel', data)) {
973
+ this.keyConfirm = 'discard';
974
+ this.invalidate();
975
+ return;
976
+ }
977
+ if (this.matches('crtr.setup.cancel', data)) {
277
978
  this.cancel();
278
979
  return;
279
980
  }
280
981
  if (this.handleGlobalTabSwitch(data))
281
982
  return;
282
983
  if (this.activeTab === 0) {
283
- if (matchesKey(data, 'enter')) {
284
- this.onOpenLogin?.();
285
- return;
286
- }
984
+ if (this.providers.handleInput(data))
985
+ this.invalidate();
287
986
  return;
288
987
  }
289
988
  if (this.activeTab === 1) {
290
- if (matchesKey(data, 'j') || matchesKey(data, 'down')) {
989
+ if (this.matches('crtr.setup.list-down', data))
291
990
  this.movePlugin(1);
292
- return;
293
- }
294
- if (matchesKey(data, 'k') || matchesKey(data, 'up')) {
991
+ else if (this.matches('crtr.setup.list-up', data))
295
992
  this.movePlugin(-1);
296
- return;
297
- }
298
- if (matchesKey(data, 'space')) {
993
+ else if (this.matches('crtr.setup.toggle', data))
299
994
  this.togglePackage();
300
- return;
301
- }
302
- if (matchesKey(data, '?')) {
995
+ else if (this.matches('crtr.setup.details', data))
303
996
  this.toggleDetails();
304
- return;
305
- }
306
- if (matchesKey(data, 'enter')) {
997
+ else if (this.matches('crtr.setup.advance', data))
307
998
  this.moveTab(1);
308
- return;
309
- }
310
999
  return;
311
1000
  }
312
1001
  if (this.activeTab === 2) {
313
- if (matchesKey(data, 'j') || matchesKey(data, 'down')) {
1002
+ if (this.matches('crtr.setup.list-down', data))
314
1003
  this.moveSystem(1);
315
- return;
316
- }
317
- if (matchesKey(data, 'k') || matchesKey(data, 'up')) {
1004
+ else if (this.matches('crtr.setup.list-up', data))
318
1005
  this.moveSystem(-1);
319
- return;
320
- }
321
- if (matchesKey(data, 'space')) {
1006
+ else if (this.matches('crtr.setup.toggle', data))
322
1007
  this.toggleSystem();
323
- return;
324
- }
325
- if (matchesKey(data, 'enter')) {
1008
+ else if (this.matches('crtr.setup.advance', data))
326
1009
  this.moveTab(1);
327
- return;
328
- }
329
1010
  return;
330
1011
  }
331
- this.exaInput.handleInput(data);
1012
+ if (this.activeTab === 3) {
1013
+ this.exaInput.handleInput(data);
1014
+ this.invalidate();
1015
+ return;
1016
+ }
1017
+ if (this.activeTab === 4) {
1018
+ this.composioInput.handleInput(data);
1019
+ this.invalidate();
1020
+ return;
1021
+ }
1022
+ this.handleKeybindingsInput(data);
332
1023
  }
333
1024
  renderHeader(width) {
334
- const tabs = ['Login', 'Plugins', 'System deps', 'Exa key'];
335
- const tabLine = tabs
336
- .map((label, index) => tabPill(`${index + 1}. ${label}`, index === this.activeTab))
1025
+ const tabs = ['Providers', 'Plugins', 'System', 'Exa', 'Composio', 'Keybindings'];
1026
+ const fullTabLine = tabs
1027
+ .map((label, index) => tabPill(label, index === this.activeTab))
337
1028
  .join(theme.fg('dim', ' · '));
1029
+ const tabLine = visibleWidth(fullTabLine) <= width
1030
+ ? fullTabLine
1031
+ : `${theme.fg('dim', `${this.activeTab + 1}/${tabs.length}`)} ${tabPill(tabs[this.activeTab], true)}`;
338
1032
  return [
339
1033
  truncateToWidth(theme.fg('accent', theme.bold('crtr sys setup')), width),
340
1034
  truncateToWidth(tabLine, width),
@@ -342,41 +1036,32 @@ class SetupWizard {
342
1036
  ];
343
1037
  }
344
1038
  renderFooter(width) {
1039
+ const tabs = `${this.label('crtr.setup.tab-next')} next · ${this.label('crtr.setup.tab-previous')} prev`;
345
1040
  const line = this.confirming
346
- ? 'y/enter confirm · n/esc go back · q cancel'
347
- : this.activeTab === 0
348
- ? 'enter log in · Tab/→ next · q cancel'
349
- : this.activeTab === 1
350
- ? 'j/k or ↑/↓ move · space toggle · ? details · enter/Tab advance · ←/→ switch tabs · q cancel'
351
- : this.activeTab === 2
352
- ? 'j/k or ↑/↓ move · space toggle · enter/Tab advance · ←/→ switch tabs · q cancel'
353
- : 'type EXA_API_KEY · enter finish · Tab/Shift-Tab or ←/→ switch tabs · q cancel';
1041
+ ? `${this.label('crtr.setup.confirm.accept')} confirm · ${this.label('crtr.setup.confirm.back')} go back`
1042
+ : this.activeTab === 0 && this.providers.isNestedFlowActive()
1043
+ ? 'OAuth in progress · esc cancel'
1044
+ : this.activeTab === 0
1045
+ ? `↑/↓ select · a add · p prefer · r rename · l re-login · x remove · ${this.label('crtr.setup.tab-next')} next · ${this.label('crtr.setup.cancel')} cancel`
1046
+ : this.activeTab === 1
1047
+ ? `${this.label('crtr.setup.list-down')}/${this.label('crtr.setup.list-up')} move · ${this.label('crtr.setup.toggle')} toggle · ${this.label('crtr.setup.details')} details · ${this.label('crtr.setup.advance')} advance · ${tabs} · ${this.label('crtr.setup.cancel')} cancel`
1048
+ : this.activeTab === 2
1049
+ ? `${this.label('crtr.setup.list-down')}/${this.label('crtr.setup.list-up')} move · ${this.label('crtr.setup.toggle')} toggle · ${this.label('crtr.setup.advance')} advance · ${tabs} · ${this.label('crtr.setup.cancel')} cancel`
1050
+ : this.activeTab === 3
1051
+ ? `type EXA_API_KEY · Enter finish · ${this.fieldSafeLabel('crtr.setup.tab-next')}/${this.fieldSafeLabel('crtr.setup.tab-previous')} switch tabs · Esc or ${this.fieldSafeLabel('crtr.setup.cancel')} cancel`
1052
+ : this.activeTab === 4
1053
+ ? `type COMPOSIO_API_KEY · Enter finish · ${this.fieldSafeLabel('crtr.setup.tab-next')}/${this.fieldSafeLabel('crtr.setup.tab-previous')} switch tabs · Esc or ${this.fieldSafeLabel('crtr.setup.cancel')} cancel`
1054
+ : this.keyConfirm !== null
1055
+ ? `${this.label('crtr.setup.confirm.accept')} confirm · ${this.label('crtr.setup.confirm.back')} go back`
1056
+ : this.editingKey !== null
1057
+ ? `type gestures separated by commas · ${this.label('crtr.setup.keybindings.commit-edit')} commit · ${this.label('crtr.setup.keybindings.cancel-edit')} cancel · ${this.label('crtr.setup.keybindings.page-up')}/${this.label('crtr.setup.keybindings.page-down')} scroll`
1058
+ : this.filteringKeys
1059
+ ? `filter: ${this.keyFilter || 'type to search'} · ${this.label('crtr.setup.keybindings.edit')} keep · ${this.label('crtr.setup.keybindings.filter-delete')} delete · ${this.label('crtr.setup.keybindings.filter-clear')} clear`
1060
+ : `${this.label('crtr.setup.list-down')}/${this.label('crtr.setup.list-up')} move · ${this.label('crtr.setup.keybindings.edit')} edit · ${this.label('crtr.setup.keybindings.unbind')} disable · ${this.label('crtr.setup.keybindings.reset-row')} reset · ${this.label('crtr.setup.keybindings.reset-all')} reset all · ${this.label('crtr.setup.keybindings.filter')} filter · ${this.label('crtr.setup.keybindings.save')} save · ${this.label('crtr.setup.keybindings.discard')} ${this.keyFilter !== '' ? 'clear filter' : 'discard'}`;
354
1061
  return [truncateToWidth(theme.fg('dim', line), width)];
355
1062
  }
356
- renderLogin(width) {
357
- const out = [];
358
- out.push(truncateToWidth(`${theme.fg('accent', theme.bold('Login'))}${theme.fg('dim', ' optional · same providers as /login')}`, width));
359
- for (const line of wrapText('Log in to a model provider so crtr can run turns. With no provider configured, nodes fail to start when you first use the app.', Math.max(1, width))) {
360
- out.push(truncateToWidth(theme.fg('muted', line), width));
361
- }
362
- out.push('');
363
- const configured = this.authSummary.filter((p) => p.configured);
364
- if (configured.length > 0) {
365
- out.push(truncateToWidth(theme.fg('success', `✓ Configured: ${configured.map((p) => p.name).join(', ')}`), width));
366
- }
367
- else {
368
- out.push(truncateToWidth(theme.fg('warning', 'No provider configured yet.'), width));
369
- }
370
- out.push('');
371
- out.push(...framed([
372
- theme.fg('accent', theme.bold('Press Enter to open the provider picker')),
373
- theme.fg('dim', 'Pick a provider, then complete its OAuth or API-key login.'),
374
- ], width));
375
- if (this.notice !== undefined) {
376
- out.push('');
377
- out.push(truncateToWidth(theme.fg('success', this.notice), width));
378
- }
379
- return out;
1063
+ renderProviders(width) {
1064
+ return this.providers.render(width);
380
1065
  }
381
1066
  renderPlugins(width) {
382
1067
  const out = [];
@@ -420,21 +1105,33 @@ class SetupWizard {
420
1105
  }
421
1106
  renderExa(width) {
422
1107
  const out = [];
423
- out.push(truncateToWidth(`${theme.fg('accent', theme.bold('Exa key'))}${theme.fg('dim', ' optional')}`, width));
1108
+ out.push(truncateToWidth(`${theme.fg('accent', theme.bold('Exa key'))}${theme.fg('dim', ' optional')}${keyStatusBadge(detectExaKey(), 'EXA_API_KEY')}`, width));
424
1109
  out.push(truncateToWidth(`${theme.fg('mdLink', 'Create a key: ')}${theme.fg('mdLinkUrl', 'https://dashboard.exa.ai/api-keys')}`, width));
425
1110
  out.push(truncateToWidth(theme.fg('dim', `Will write to: ${resolveExaKeyPath()}`), width));
426
1111
  out.push(...framed([
427
1112
  theme.fg('accent', theme.bold('Paste EXA_API_KEY here')),
428
- theme.fg('dim', 'Leave blank to skip.'),
1113
+ theme.fg('dim', detectExaKey().present ? 'Leave blank to keep the existing key; paste to replace it.' : 'Leave blank to skip.'),
429
1114
  ...this.exaInput.render(Math.max(1, width - 4)),
430
1115
  ], width));
431
1116
  return out;
432
1117
  }
1118
+ renderComposio(width) {
1119
+ const out = [];
1120
+ out.push(truncateToWidth(`${theme.fg('accent', theme.bold('Composio key'))}${theme.fg('dim', ' optional · powers /v1/connectors third-party service access')}${keyStatusBadge(detectComposioKey(), 'COMPOSIO_API_KEY')}`, width));
1121
+ out.push(truncateToWidth(`${theme.fg('mdLink', 'Create a key: ')}${theme.fg('mdLinkUrl', 'https://platform.composio.dev/developers')}`, width));
1122
+ out.push(truncateToWidth(theme.fg('dim', `Will write to: ${resolveComposioKeyPath()}`), width));
1123
+ out.push(...framed([
1124
+ theme.fg('accent', theme.bold('Paste COMPOSIO_API_KEY here')),
1125
+ theme.fg('dim', detectComposioKey().present ? 'Leave blank to keep the existing key; paste to replace it.' : 'Leave blank to skip.'),
1126
+ ...this.composioInput.render(Math.max(1, width - 4)),
1127
+ ], width));
1128
+ return out;
1129
+ }
433
1130
  renderConfirm(width) {
434
1131
  const out = [truncateToWidth(theme.fg('warning', theme.bold('Confirm installs')), width)];
435
1132
  const submission = this.buildSubmission();
436
- if (submission.packages.length === 0 && submission.systemDeps.length === 0 && submission.exaKey === '') {
437
- out.push(truncateToWidth(theme.fg('success', 'Nothing to install; press enter to finish.'), width));
1133
+ if (submission.packages.length === 0 && submission.systemDeps.length === 0 && submission.exaKey === '' && submission.composioKey === '') {
1134
+ out.push(truncateToWidth(theme.fg('success', `Nothing to install; ${this.label('crtr.setup.confirm.accept')} finish.`), width));
438
1135
  return out;
439
1136
  }
440
1137
  if (submission.packages.length > 0) {
@@ -455,7 +1152,122 @@ class SetupWizard {
455
1152
  if (submission.exaKey !== '') {
456
1153
  out.push(truncateToWidth(`${theme.fg('accent', 'Exa key')} ${theme.fg('dim', '→')} ${theme.fg('warning', `write ${submission.exaKeyPath}`)}`, width));
457
1154
  }
458
- out.push(truncateToWidth(theme.fg('dim', 'y/enter confirm · n/esc go back'), width));
1155
+ if (submission.composioKey !== '') {
1156
+ out.push(truncateToWidth(`${theme.fg('accent', 'Composio key')} ${theme.fg('dim', '→')} ${theme.fg('warning', `write ${submission.composioKeyPath}`)}`, width));
1157
+ }
1158
+ out.push(truncateToWidth(theme.fg('dim', `${this.label('crtr.setup.confirm.accept')} confirm · ${this.label('crtr.setup.confirm.back')} go back`), width));
1159
+ return out;
1160
+ }
1161
+ // Does the row at `index` contribute a diagnostic line in the current state?
1162
+ // Mirrors the emit loop's `message` computation so the line-cost accounting
1163
+ // and the render stay in lockstep.
1164
+ keyRowHasMessage(rows, index) {
1165
+ const definition = rows[index];
1166
+ const selected = index === this.keyIndex;
1167
+ const editing = this.editingKey === definition.id;
1168
+ if (!selected && !editing)
1169
+ return false;
1170
+ if (editing && this.keyEditError)
1171
+ return true;
1172
+ const diagnostic = this.keyDiagnostics.find((entry) => entry.bindingId === definition.id || entry.relatedBindingIds?.includes(definition.id));
1173
+ return diagnostic !== undefined;
1174
+ }
1175
+ // Output lines the visible rows [start..end] cost, counting the per-row group
1176
+ // heading (the first visible row always re-emits its heading) and any
1177
+ // diagnostic line. Used to clamp the scroll so the anchor row always renders.
1178
+ keyLineCost(rows, start, end) {
1179
+ let lines = 0;
1180
+ let previousGroup;
1181
+ for (let index = start; index <= end; index += 1) {
1182
+ const definition = rows[index];
1183
+ lines += (definition.group !== previousGroup ? 1 : 0) + 1 + (this.keyRowHasMessage(rows, index) ? 1 : 0);
1184
+ previousGroup = definition.group;
1185
+ }
1186
+ return lines;
1187
+ }
1188
+ renderKeybindings(width) {
1189
+ const out = [];
1190
+ const rows = this.filteredKeybindings();
1191
+ const resolution = this.keyDraft.resolution();
1192
+ const viewport = this.keyViewportLines();
1193
+ const pageSize = this.keyPageSize();
1194
+ const maxScroll = Math.max(0, rows.length - pageSize);
1195
+ this.keyScroll = Math.max(0, Math.min(this.keyScroll, maxScroll));
1196
+ if (this.keyIndex < this.keyScroll)
1197
+ this.keyScroll = this.keyIndex;
1198
+ if (this.keyIndex >= this.keyScroll + pageSize)
1199
+ this.keyScroll = Math.max(0, this.keyIndex - pageSize + 1);
1200
+ // Anchor that must stay on screen: the row being edited (so the editor never
1201
+ // vanishes while paging moves the selection — m3), else the selection itself.
1202
+ // Injected group-heading and diagnostic lines can make a pageSize-row window
1203
+ // exceed the raw viewport, so instead of a fixed reserve, clamp the scroll
1204
+ // dynamically until the anchor is guaranteed to render within `viewport`.
1205
+ const editIndex = this.editingKey !== null ? rows.findIndex((row) => row.id === this.editingKey) : -1;
1206
+ const anchor = editIndex >= 0 ? editIndex : this.keyIndex;
1207
+ if (anchor >= 0) {
1208
+ if (anchor < this.keyScroll)
1209
+ this.keyScroll = anchor;
1210
+ while (this.keyScroll < anchor && this.keyLineCost(rows, this.keyScroll, anchor) > viewport) {
1211
+ this.keyScroll += 1;
1212
+ }
1213
+ }
1214
+ const dirty = this.keyDraft.dirty ? theme.fg('warning', 'unsaved') : theme.fg('success', 'saved');
1215
+ out.push(truncateToWidth(`${theme.fg('accent', theme.bold('Keybindings'))}${theme.fg('dim', ' crouter-owned only · ')}${dirty}`, width));
1216
+ out.push(truncateToWidth(theme.fg('dim', this.keyFilter === ''
1217
+ ? `${rows.length} actions`
1218
+ : `${rows.length} matches for “${this.keyFilter}”${this.filteringKeys ? '' : ` · ${this.label('crtr.setup.keybindings.discard')} clears filter`}`), width));
1219
+ if (resolution.diagnostics.some((diagnostic) => diagnostic.code === 'invalid-keybindings')) {
1220
+ out.push(truncateToWidth(theme.fg('warning', theme.bold(`Saved keybindings root is invalid — ${this.label('crtr.setup.keybindings.reset-all')} (reset all) to clear it before editing.`)), width));
1221
+ }
1222
+ if (rows.length === 0) {
1223
+ out.push('');
1224
+ out.push(truncateToWidth(theme.fg('warning', 'No matching keybindings.'), width));
1225
+ return out;
1226
+ }
1227
+ const nameWidth = Math.max(16, Math.min(28, Math.floor(width * 0.28)));
1228
+ const keyWidth = Math.max(18, Math.min(34, Math.floor(width * 0.34)));
1229
+ const showDescription = width >= 72;
1230
+ let emitted = 0;
1231
+ let previousGroup;
1232
+ for (let index = this.keyScroll; index < rows.length; index += 1) {
1233
+ const definition = rows[index];
1234
+ const selected = index === this.keyIndex;
1235
+ const editing = this.editingKey === definition.id;
1236
+ const diagnostics = (selected || editing)
1237
+ ? this.keyDiagnostics.filter((diagnostic) => diagnostic.bindingId === definition.id || diagnostic.relatedBindingIds?.includes(definition.id))
1238
+ : [];
1239
+ const message = editing ? (this.keyEditError ?? diagnostics[0]?.message) : (selected ? diagnostics[0]?.message : undefined);
1240
+ // Budget output lines (heading + row + optional diagnostic) so the list
1241
+ // never clips the confirm prompt / notice / footer below it.
1242
+ const cost = (definition.group !== previousGroup ? 1 : 0) + 1 + (message ? 1 : 0);
1243
+ if (emitted > 0 && emitted + cost > viewport)
1244
+ break;
1245
+ if (definition.group !== previousGroup) {
1246
+ out.push(truncateToWidth(theme.fg('accent', definition.group), width));
1247
+ previousGroup = definition.group;
1248
+ }
1249
+ const marker = selected ? theme.fg('accent', '▶') : ' ';
1250
+ const sourceMark = resolution.get(definition.id).source === 'user' ? theme.fg('warning', '•') : ' ';
1251
+ const name = padAnsi(selected ? theme.fg('accent', definition.name) : definition.name, nameWidth);
1252
+ // Render the edit field whenever THIS row is the one being edited — even if
1253
+ // paging has moved the selection elsewhere — so the active editor stays
1254
+ // visible while it captures input.
1255
+ const keyText = editing ? (this.keyInput.render(keyWidth)[0] ?? '') : formatBinding(resolution, definition.id);
1256
+ const key = padAnsi(keyText, keyWidth);
1257
+ const description = showDescription ? ` ${theme.fg('dim', definition.description)}` : '';
1258
+ out.push(truncateToWidth(`${marker}${sourceMark} ${name} ${key}${description}`, width));
1259
+ if (message)
1260
+ out.push(truncateToWidth(theme.fg('warning', ` ${message}`), width));
1261
+ emitted += cost;
1262
+ }
1263
+ if (this.keyConfirm !== null) {
1264
+ out.push('');
1265
+ out.push(truncateToWidth(theme.fg('warning', theme.bold(this.keyConfirm === 'reset-all'
1266
+ ? 'Reset every keybinding to its catalog default?'
1267
+ : 'Discard every unsaved keybinding change?')), width));
1268
+ }
1269
+ if (this.keyNotice)
1270
+ out.push(truncateToWidth(theme.fg('muted', this.keyNotice), width));
459
1271
  return out;
460
1272
  }
461
1273
  render(width) {
@@ -467,12 +1279,16 @@ class SetupWizard {
467
1279
  ...(this.confirming
468
1280
  ? this.renderConfirm(width)
469
1281
  : this.activeTab === 0
470
- ? this.renderLogin(width)
1282
+ ? this.renderProviders(width)
471
1283
  : this.activeTab === 1
472
1284
  ? this.renderPlugins(width)
473
1285
  : this.activeTab === 2
474
1286
  ? this.renderSystems(width)
475
- : this.renderExa(width)),
1287
+ : this.activeTab === 3
1288
+ ? this.renderExa(width)
1289
+ : this.activeTab === 4
1290
+ ? this.renderComposio(width)
1291
+ : this.renderKeybindings(width)),
476
1292
  '',
477
1293
  ...this.renderFooter(width),
478
1294
  ];
@@ -481,52 +1297,6 @@ class SetupWizard {
481
1297
  return this.cachedLines;
482
1298
  }
483
1299
  }
484
- const LOGIN_OVERLAY_OPTIONS = { anchor: 'center', width: '80%', maxHeight: '85%' };
485
- /** Mount pi's real `/login` provider picker + login dialog as a centered overlay
486
- * over the setup wizard, driving the SAME OAuth/API-key flow the in-app /login
487
- * uses (`buildLoginPicker`). Credentials write to `agentDir`'s auth.json; on
488
- * close, focus returns to the wizard and its auth summary refreshes. */
489
- function openLoginOverlay(tui, wizard, agentDir) {
490
- let handle;
491
- let current;
492
- let done = false;
493
- const disposeCurrent = () => {
494
- const disposable = current?.component;
495
- try {
496
- disposable?.dispose?.();
497
- }
498
- catch {
499
- /* ignore dispose errors during teardown */
500
- }
501
- };
502
- const mount = (p) => {
503
- handle = tui.showOverlay(p.component, LOGIN_OVERLAY_OPTIONS);
504
- tui.setFocus(p.focus);
505
- };
506
- const unmount = () => handle?.hide();
507
- const replace = (component, focus) => {
508
- if (done)
509
- return;
510
- disposeCurrent();
511
- unmount();
512
- current = { component, focus };
513
- mount(current);
514
- tui.requestRender();
515
- };
516
- const close = () => {
517
- if (done)
518
- return;
519
- done = true;
520
- disposeCurrent();
521
- unmount();
522
- wizard.refreshAuth();
523
- tui.setFocus(wizard);
524
- tui.requestRender();
525
- };
526
- current = buildLoginPicker(tui, agentDir, () => wizard.refreshAuth(), (msg) => wizard.setNotice(msg), { close, replace });
527
- mount(current);
528
- tui.requestRender();
529
- }
530
1300
  async function runSetup() {
531
1301
  const state = buildSetupInitialState();
532
1302
  if (!process.stdout.isTTY) {
@@ -535,18 +1305,12 @@ async function runSetup() {
535
1305
  }
536
1306
  applyTheme({ cwd: process.cwd() });
537
1307
  const agentDir = defaultAgentDir();
538
- const loadAuthSummary = () => {
539
- try {
540
- return summarizeProviderAuth(agentDir);
541
- }
542
- catch {
543
- return [];
544
- }
545
- };
1308
+ const rawUserConfig = readRawScopeConfig('user');
1309
+ const rawKeybindings = rawUserConfig?.keybindings;
1310
+ const openingBindings = resolveKeybindings(rawKeybindings);
546
1311
  const terminal = new ProcessTerminal();
547
1312
  const tui = new TUI(terminal, true);
548
- const wizard = new SetupWizard(state.manifest, state.installedPackageIds, state.systemTools, loadAuthSummary(), loadAuthSummary);
549
- wizard.onOpenLogin = () => openLoginOverlay(tui, wizard, agentDir);
1313
+ const wizard = new SetupWizard(state.manifest, state.installedPackageIds, state.systemTools, tui, agentDir, openingBindings, rawKeybindings, () => terminal.rows);
550
1314
  tui.addChild(wizard);
551
1315
  tui.setFocus(wizard);
552
1316
  await new Promise((resolve, reject) => {
@@ -555,6 +1319,7 @@ async function runSetup() {
555
1319
  if (restored)
556
1320
  return;
557
1321
  restored = true;
1322
+ wizard.dispose();
558
1323
  try {
559
1324
  tui.stop();
560
1325
  }
@@ -582,6 +1347,21 @@ async function runSetup() {
582
1347
  process.once('SIGTERM', onSignal);
583
1348
  process.once('SIGHUP', onSignal);
584
1349
  process.once('SIGINT', onSignal);
1350
+ // Emergency cancel: the control gestures of `crtr.setup.cancel` (ctrl+c /
1351
+ // ctrl+d in the shipped catalog) must always exit setup — from filter entry,
1352
+ // the confirm prompts, or while a nested ProviderPanel login flow owns focus —
1353
+ // where the focused component would otherwise swallow them. Matching is driven
1354
+ // by the opening snapshot; the control-char guard keeps printable cancel gestures
1355
+ // (e.g. q) as data for text fields and the filter buffer, which the wizard
1356
+ // handles as cancel only in its own non-text states.
1357
+ tui.addInputListener((data) => {
1358
+ if (data.length > 0 && data.charCodeAt(0) < 32
1359
+ && matchesPiTuiInput(openingBindings, 'crtr.setup.cancel', data, (input, gesture) => matchesKey(input, gesture))) {
1360
+ finish();
1361
+ return { consume: true };
1362
+ }
1363
+ return undefined;
1364
+ });
585
1365
  wizard.onCancel = finish;
586
1366
  wizard.onSubmit = (submission) => {
587
1367
  restore();
@@ -594,13 +1374,6 @@ async function runSetup() {
594
1374
  resolve();
595
1375
  }, fail);
596
1376
  };
597
- tui.addInputListener((data) => {
598
- if (matchesKey(data, 'ctrl+c') || matchesKey(data, 'ctrl+d')) {
599
- finish();
600
- return { consume: true };
601
- }
602
- return undefined;
603
- });
604
1377
  try {
605
1378
  tui.start();
606
1379
  }
@@ -611,18 +1384,19 @@ async function runSetup() {
611
1384
  }
612
1385
  export const sysSetupLeaf = defineLeaf({
613
1386
  name: 'setup',
614
- description: 'log in to a model provider, install the bundled pi plugins, system deps, and Exa key setup',
615
- whenToUse: 'you want to bootstrap this machine for crtr in one pass: log in to a model provider (same flow as /login), install the vendored pi plugins, confirm tmux and nvim, and save your Exa API key.',
1387
+ description: 'manage model providers, install bundled pi plugins and system deps, save Exa + Composio keys, and edit crouter keybindings',
1388
+ whenToUse: 'you want to bootstrap this machine in one pass: inspect and manage configured model providers, install vendored pi plugins, confirm tmux and nvim, save Exa and Composio API keys, and edit crouter-owned keybindings.',
616
1389
  help: {
617
1390
  name: 'sys setup',
618
- summary: 'open the multi-tab setup wizard for provider login, bundled pi plugins, system deps, and Exa key',
1391
+ summary: 'open the multi-tab setup wizard for provider management, bundled pi plugins, system deps, Exa + Composio keys, and crouter-owned keybindings',
619
1392
  inputNote: 'No input parameters.',
620
1393
  output: [],
621
1394
  outputKind: 'object',
622
1395
  effects: [
623
1396
  'Opens a pi-tui wizard whose first tab logs in to a model provider (pi\'s real OAuth/API-key flow, same as in-app /login) so a fresh install has credentials before running any node.',
624
- 'Collects companion package installs, system dependency installs, and an Exa API key across the remaining tabs.',
625
- 'Runs the bundled pi CLI to install the selected companion packages, writes EXA_API_KEY to ~/.crouter/exa.key when provided, and prints a summary.',
1397
+ 'Collects companion package installs, system dependency installs, API keys, and sparse user overrides for crouter-owned keybindings across the remaining tabs.',
1398
+ 'Saves validated keybinding changes atomically in the user config and refreshes crouter-owned tmux bindings without overwriting occupied user keys.',
1399
+ 'Runs the bundled pi CLI to install the selected companion packages, writes EXA_API_KEY to ~/.crouter/exa.key and COMPOSIO_API_KEY to ~/.crouter/composio.key when provided, and prints a summary.',
626
1400
  ],
627
1401
  },
628
1402
  run: async () => {