@crouton-kit/crouter 0.3.78 → 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 (72) hide show
  1. package/dist/build-root.d.ts +12 -4
  2. package/dist/build-root.js +25 -6
  3. package/dist/builtin-memory/crouter-development/plugins.md +82 -5
  4. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +1228 -12
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +3 -3
  6. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +2 -787
  7. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +8 -3
  8. package/dist/clients/attach/__tests__/attach-keybindings.test.d.ts +1 -0
  9. package/dist/clients/attach/__tests__/attach-keybindings.test.js +113 -0
  10. package/dist/clients/attach/__tests__/mermaid-render.test.js +9 -1
  11. package/dist/clients/attach/attach-cmd.d.ts +9 -1
  12. package/dist/clients/attach/attach-cmd.js +849 -803
  13. package/dist/clients/attach/auth-pickers.d.ts +0 -12
  14. package/dist/clients/attach/auth-pickers.js +64 -15
  15. package/dist/clients/attach/graph-overlay.d.ts +12 -2
  16. package/dist/clients/attach/graph-overlay.js +83 -33
  17. package/dist/commands/human/queue.js +3 -4
  18. package/dist/commands/pkg/plugin-inspect.js +22 -1
  19. package/dist/commands/pkg/plugin-manage.js +31 -9
  20. package/dist/commands/sys/__tests__/setup-core.test.js +158 -1
  21. package/dist/commands/sys/doctor.js +42 -4
  22. package/dist/commands/sys/setup-core.d.ts +37 -0
  23. package/dist/commands/sys/setup-core.js +138 -1
  24. package/dist/commands/sys/setup.d.ts +88 -0
  25. package/dist/commands/sys/setup.js +915 -171
  26. package/dist/commands/view-pick.d.ts +4 -0
  27. package/dist/commands/view-pick.js +17 -7
  28. package/dist/core/__tests__/canvas-inbox-watcher.test.js +34 -9
  29. package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +1 -0
  30. package/dist/core/__tests__/command-plugins-surfaces.test.js +298 -0
  31. package/dist/core/__tests__/command-plugins.test.d.ts +1 -0
  32. package/dist/core/__tests__/command-plugins.test.js +444 -0
  33. package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
  34. package/dist/core/__tests__/fixtures/fake-engine.js +9 -1
  35. package/dist/core/__tests__/preview-registry-sync.test.js +30 -1
  36. package/dist/core/__tests__/scope-crouter-home-fence.test.d.ts +1 -0
  37. package/dist/core/__tests__/scope-crouter-home-fence.test.js +55 -0
  38. package/dist/core/canvas/browse/app.d.ts +6 -0
  39. package/dist/core/canvas/browse/app.js +71 -41
  40. package/dist/core/command-plugins/adapter.d.ts +15 -0
  41. package/dist/core/command-plugins/adapter.js +145 -0
  42. package/dist/core/command-plugins/compose.d.ts +5 -0
  43. package/dist/core/command-plugins/compose.js +56 -0
  44. package/dist/core/command-plugins/discovery.d.ts +104 -0
  45. package/dist/core/command-plugins/discovery.js +565 -0
  46. package/dist/core/config.d.ts +2 -5
  47. package/dist/core/keybindings/__tests__/bespoke-consumers.test.d.ts +1 -0
  48. package/dist/core/keybindings/__tests__/bespoke-consumers.test.js +40 -0
  49. package/dist/core/keybindings/__tests__/resolve.test.js +2 -2
  50. package/dist/core/keybindings/catalog.d.ts +3 -3
  51. package/dist/core/keybindings/catalog.js +2 -1
  52. package/dist/core/profiles/select.d.ts +6 -0
  53. package/dist/core/profiles/select.js +86 -52
  54. package/dist/core/provider-management.d.ts +12 -0
  55. package/dist/core/provider-management.js +24 -0
  56. package/dist/core/runtime/broker.js +7 -6
  57. package/dist/core/runtime/pi-vendored.d.ts +8 -0
  58. package/dist/core/runtime/pi-vendored.js +14 -0
  59. package/dist/core/runtime/recap.d.ts +1 -1
  60. package/dist/core/runtime/recap.js +50 -25
  61. package/dist/core/runtime/session-list-cache.d.ts +10 -0
  62. package/dist/core/runtime/session-list-cache.js +94 -26
  63. package/dist/core/runtime/spawn.js +1 -13
  64. package/dist/core/runtime/tmux.js +2 -1
  65. package/dist/core/scope.js +27 -4
  66. package/dist/core/subscription-state.d.ts +90 -0
  67. package/dist/core/subscription-state.js +762 -0
  68. package/dist/daemon/crtrd.js +253 -12
  69. package/dist/pi-extensions/canvas-recap.js +43 -17
  70. package/dist/types.d.ts +6 -13
  71. package/dist/types.js +0 -3
  72. package/package.json +7 -3
@@ -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, resolveComposioKeyPath, } 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,34 +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();
105
458
  composioInput = new Input();
459
+ keyInput = new Input();
460
+ openingBindings;
461
+ keyDraft;
462
+ terminalRows;
106
463
  selectedPackageIds = new Set();
107
464
  selectedSystemTools = new Set();
108
- authSummary;
109
- reloadAuthSummary;
110
- notice;
111
- onOpenLogin;
465
+ providers;
112
466
  activeTab = 0;
113
467
  pluginIndex = 0;
114
468
  systemIndex = 0;
115
469
  expandedPluginId = null;
116
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;
117
480
  cachedWidth;
118
481
  cachedLines;
119
482
  _focused = false;
120
483
  onSubmit;
121
484
  onCancel;
122
- constructor(manifest, installedPackageIds, systemTools, authSummary, reloadAuthSummary) {
485
+ constructor(manifest, installedPackageIds, systemTools, tui, agentDir, openingBindings, rawKeybindings, terminalRows) {
123
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();
124
491
  this.installedPackageIds = installedPackageIds;
125
492
  this.systemTools = systemTools;
126
- this.authSummary = authSummary;
127
- this.reloadAuthSummary = reloadAuthSummary;
493
+ this.terminalRows = terminalRows;
494
+ this.openingBindings = openingBindings;
495
+ this.keyDraft = new SetupKeybindingsDraft(rawKeybindings);
496
+ this.keyDiagnostics = this.keyDraft.resolution().diagnostics;
128
497
  for (const entry of this.manifest)
129
498
  this.selectedPackageIds.add(entry.id);
130
499
  for (const tool of this.systemTools) {
@@ -137,11 +506,17 @@ class SetupWizard {
137
506
  this.exaInput.onEscape = () => this.cancel();
138
507
  this.composioInput.onSubmit = () => this.finalize();
139
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.
140
514
  }
141
515
  set focused(value) {
142
516
  this._focused = value;
143
517
  this.exaInput.focused = value && this.activeTab === 3 && !this.confirming;
144
518
  this.composioInput.focused = value && this.activeTab === 4 && !this.confirming;
519
+ this.keyInput.focused = value && this.activeTab === 5 && this.editingKey !== null;
145
520
  }
146
521
  get focused() {
147
522
  return this._focused;
@@ -150,24 +525,15 @@ class SetupWizard {
150
525
  this.cachedWidth = undefined;
151
526
  this.cachedLines = undefined;
152
527
  }
153
- /** Show a one-line notice on the login tab (login success/failure feedback). */
154
- setNotice(message) {
155
- this.notice = message;
156
- this.invalidate();
157
- }
158
- /** Recompute provider auth status after a login flow closes. */
159
- refreshAuth() {
160
- this.authSummary = this.reloadAuthSummary();
161
- this.invalidate();
162
- }
163
528
  setTab(next) {
164
529
  this.activeTab = next;
165
530
  this.exaInput.focused = this.focused && next === 3 && !this.confirming;
166
531
  this.composioInput.focused = this.focused && next === 4 && !this.confirming;
532
+ this.keyInput.focused = this.focused && next === 5 && this.editingKey !== null;
167
533
  this.invalidate();
168
534
  }
169
535
  moveTab(delta) {
170
- const next = ((this.activeTab + delta + 5) % 5);
536
+ const next = ((this.activeTab + delta + 6) % 6);
171
537
  this.setTab(next);
172
538
  }
173
539
  cancel() {
@@ -176,14 +542,38 @@ class SetupWizard {
176
542
  this.invalidate();
177
543
  return;
178
544
  }
545
+ this.providers.dispose();
179
546
  this.onCancel?.();
180
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
+ }
181
571
  handleGlobalTabSwitch(data) {
182
- if (matchesKey(data, 'tab') || matchesKey(data, 'right')) {
572
+ if (this.matches('crtr.setup.tab-next', data)) {
183
573
  this.moveTab(1);
184
574
  return true;
185
575
  }
186
- if (matchesKey(data, 'shift+tab') || matchesKey(data, 'left')) {
576
+ if (this.matches('crtr.setup.tab-previous', data)) {
187
577
  this.moveTab(-1);
188
578
  return true;
189
579
  }
@@ -267,85 +657,378 @@ class SetupWizard {
267
657
  }
268
658
  this.onSubmit?.(this.buildSubmission());
269
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
+ }
270
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
+ }
271
944
  if (this.confirming) {
272
- if (matchesKey(data, 'enter') || matchesKey(data, 'y')) {
945
+ if (this.matches('crtr.setup.confirm.accept', data)) {
273
946
  this.onSubmit?.(this.buildSubmission());
274
947
  return;
275
948
  }
276
- if (matchesKey(data, 'n') || matchesKey(data, 'escape') || matchesKey(data, 'q')) {
949
+ if (this.matches('crtr.setup.confirm.back', data)) {
277
950
  this.confirming = false;
278
951
  this.invalidate();
279
- return;
280
952
  }
281
953
  return;
282
954
  }
283
- 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)) {
284
978
  this.cancel();
285
979
  return;
286
980
  }
287
981
  if (this.handleGlobalTabSwitch(data))
288
982
  return;
289
983
  if (this.activeTab === 0) {
290
- if (matchesKey(data, 'enter')) {
291
- this.onOpenLogin?.();
292
- return;
293
- }
984
+ if (this.providers.handleInput(data))
985
+ this.invalidate();
294
986
  return;
295
987
  }
296
988
  if (this.activeTab === 1) {
297
- if (matchesKey(data, 'j') || matchesKey(data, 'down')) {
989
+ if (this.matches('crtr.setup.list-down', data))
298
990
  this.movePlugin(1);
299
- return;
300
- }
301
- if (matchesKey(data, 'k') || matchesKey(data, 'up')) {
991
+ else if (this.matches('crtr.setup.list-up', data))
302
992
  this.movePlugin(-1);
303
- return;
304
- }
305
- if (matchesKey(data, 'space')) {
993
+ else if (this.matches('crtr.setup.toggle', data))
306
994
  this.togglePackage();
307
- return;
308
- }
309
- if (matchesKey(data, '?')) {
995
+ else if (this.matches('crtr.setup.details', data))
310
996
  this.toggleDetails();
311
- return;
312
- }
313
- if (matchesKey(data, 'enter')) {
997
+ else if (this.matches('crtr.setup.advance', data))
314
998
  this.moveTab(1);
315
- return;
316
- }
317
999
  return;
318
1000
  }
319
1001
  if (this.activeTab === 2) {
320
- if (matchesKey(data, 'j') || matchesKey(data, 'down')) {
1002
+ if (this.matches('crtr.setup.list-down', data))
321
1003
  this.moveSystem(1);
322
- return;
323
- }
324
- if (matchesKey(data, 'k') || matchesKey(data, 'up')) {
1004
+ else if (this.matches('crtr.setup.list-up', data))
325
1005
  this.moveSystem(-1);
326
- return;
327
- }
328
- if (matchesKey(data, 'space')) {
1006
+ else if (this.matches('crtr.setup.toggle', data))
329
1007
  this.toggleSystem();
330
- return;
331
- }
332
- if (matchesKey(data, 'enter')) {
1008
+ else if (this.matches('crtr.setup.advance', data))
333
1009
  this.moveTab(1);
334
- return;
335
- }
336
1010
  return;
337
1011
  }
338
1012
  if (this.activeTab === 3) {
339
1013
  this.exaInput.handleInput(data);
1014
+ this.invalidate();
340
1015
  return;
341
1016
  }
342
- this.composioInput.handleInput(data);
1017
+ if (this.activeTab === 4) {
1018
+ this.composioInput.handleInput(data);
1019
+ this.invalidate();
1020
+ return;
1021
+ }
1022
+ this.handleKeybindingsInput(data);
343
1023
  }
344
1024
  renderHeader(width) {
345
- const tabs = ['Login', 'Plugins', 'System deps', 'Exa key', 'Composio key'];
346
- const tabLine = tabs
347
- .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))
348
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)}`;
349
1032
  return [
350
1033
  truncateToWidth(theme.fg('accent', theme.bold('crtr sys setup')), width),
351
1034
  truncateToWidth(tabLine, width),
@@ -353,43 +1036,32 @@ class SetupWizard {
353
1036
  ];
354
1037
  }
355
1038
  renderFooter(width) {
1039
+ const tabs = `${this.label('crtr.setup.tab-next')} next · ${this.label('crtr.setup.tab-previous')} prev`;
356
1040
  const line = this.confirming
357
- ? 'y/enter confirm · n/esc go back · q cancel'
358
- : this.activeTab === 0
359
- ? 'enter log in · Tab/→ next · q cancel'
360
- : this.activeTab === 1
361
- ? 'j/k or ↑/↓ move · space toggle · ? details · enter/Tab advance · ←/→ switch tabs · q cancel'
362
- : this.activeTab === 2
363
- ? 'j/k or ↑/↓ move · space toggle · enter/Tab advance · ←/→ switch tabs · q cancel'
364
- : this.activeTab === 3
365
- ? 'type EXA_API_KEY · enter finish · Tab/Shift-Tab or ←/→ switch tabs · q cancel'
366
- : 'type COMPOSIO_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'}`;
367
1061
  return [truncateToWidth(theme.fg('dim', line), width)];
368
1062
  }
369
- renderLogin(width) {
370
- const out = [];
371
- out.push(truncateToWidth(`${theme.fg('accent', theme.bold('Login'))}${theme.fg('dim', ' optional · same providers as /login')}`, width));
372
- 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))) {
373
- out.push(truncateToWidth(theme.fg('muted', line), width));
374
- }
375
- out.push('');
376
- const configured = this.authSummary.filter((p) => p.configured);
377
- if (configured.length > 0) {
378
- out.push(truncateToWidth(theme.fg('success', `✓ Configured: ${configured.map((p) => p.name).join(', ')}`), width));
379
- }
380
- else {
381
- out.push(truncateToWidth(theme.fg('warning', 'No provider configured yet.'), width));
382
- }
383
- out.push('');
384
- out.push(...framed([
385
- theme.fg('accent', theme.bold('Press Enter to open the provider picker')),
386
- theme.fg('dim', 'Pick a provider, then complete its OAuth or API-key login.'),
387
- ], width));
388
- if (this.notice !== undefined) {
389
- out.push('');
390
- out.push(truncateToWidth(theme.fg('success', this.notice), width));
391
- }
392
- return out;
1063
+ renderProviders(width) {
1064
+ return this.providers.render(width);
393
1065
  }
394
1066
  renderPlugins(width) {
395
1067
  const out = [];
@@ -433,24 +1105,24 @@ class SetupWizard {
433
1105
  }
434
1106
  renderExa(width) {
435
1107
  const out = [];
436
- 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));
437
1109
  out.push(truncateToWidth(`${theme.fg('mdLink', 'Create a key: ')}${theme.fg('mdLinkUrl', 'https://dashboard.exa.ai/api-keys')}`, width));
438
1110
  out.push(truncateToWidth(theme.fg('dim', `Will write to: ${resolveExaKeyPath()}`), width));
439
1111
  out.push(...framed([
440
1112
  theme.fg('accent', theme.bold('Paste EXA_API_KEY here')),
441
- 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.'),
442
1114
  ...this.exaInput.render(Math.max(1, width - 4)),
443
1115
  ], width));
444
1116
  return out;
445
1117
  }
446
1118
  renderComposio(width) {
447
1119
  const out = [];
448
- out.push(truncateToWidth(`${theme.fg('accent', theme.bold('Composio key'))}${theme.fg('dim', ' optional · powers /v1/connectors third-party service access')}`, width));
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));
449
1121
  out.push(truncateToWidth(`${theme.fg('mdLink', 'Create a key: ')}${theme.fg('mdLinkUrl', 'https://platform.composio.dev/developers')}`, width));
450
1122
  out.push(truncateToWidth(theme.fg('dim', `Will write to: ${resolveComposioKeyPath()}`), width));
451
1123
  out.push(...framed([
452
1124
  theme.fg('accent', theme.bold('Paste COMPOSIO_API_KEY here')),
453
- theme.fg('dim', 'Leave blank to skip.'),
1125
+ theme.fg('dim', detectComposioKey().present ? 'Leave blank to keep the existing key; paste to replace it.' : 'Leave blank to skip.'),
454
1126
  ...this.composioInput.render(Math.max(1, width - 4)),
455
1127
  ], width));
456
1128
  return out;
@@ -459,7 +1131,7 @@ class SetupWizard {
459
1131
  const out = [truncateToWidth(theme.fg('warning', theme.bold('Confirm installs')), width)];
460
1132
  const submission = this.buildSubmission();
461
1133
  if (submission.packages.length === 0 && submission.systemDeps.length === 0 && submission.exaKey === '' && submission.composioKey === '') {
462
- out.push(truncateToWidth(theme.fg('success', 'Nothing to install; press enter to finish.'), width));
1134
+ out.push(truncateToWidth(theme.fg('success', `Nothing to install; ${this.label('crtr.setup.confirm.accept')} finish.`), width));
463
1135
  return out;
464
1136
  }
465
1137
  if (submission.packages.length > 0) {
@@ -483,7 +1155,119 @@ class SetupWizard {
483
1155
  if (submission.composioKey !== '') {
484
1156
  out.push(truncateToWidth(`${theme.fg('accent', 'Composio key')} ${theme.fg('dim', '→')} ${theme.fg('warning', `write ${submission.composioKeyPath}`)}`, width));
485
1157
  }
486
- out.push(truncateToWidth(theme.fg('dim', 'y/enter confirm · n/esc go back'), width));
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));
487
1271
  return out;
488
1272
  }
489
1273
  render(width) {
@@ -495,14 +1279,16 @@ class SetupWizard {
495
1279
  ...(this.confirming
496
1280
  ? this.renderConfirm(width)
497
1281
  : this.activeTab === 0
498
- ? this.renderLogin(width)
1282
+ ? this.renderProviders(width)
499
1283
  : this.activeTab === 1
500
1284
  ? this.renderPlugins(width)
501
1285
  : this.activeTab === 2
502
1286
  ? this.renderSystems(width)
503
1287
  : this.activeTab === 3
504
1288
  ? this.renderExa(width)
505
- : this.renderComposio(width)),
1289
+ : this.activeTab === 4
1290
+ ? this.renderComposio(width)
1291
+ : this.renderKeybindings(width)),
506
1292
  '',
507
1293
  ...this.renderFooter(width),
508
1294
  ];
@@ -511,52 +1297,6 @@ class SetupWizard {
511
1297
  return this.cachedLines;
512
1298
  }
513
1299
  }
514
- const LOGIN_OVERLAY_OPTIONS = { anchor: 'center', width: '80%', maxHeight: '85%' };
515
- /** Mount pi's real `/login` provider picker + login dialog as a centered overlay
516
- * over the setup wizard, driving the SAME OAuth/API-key flow the in-app /login
517
- * uses (`buildLoginPicker`). Credentials write to `agentDir`'s auth.json; on
518
- * close, focus returns to the wizard and its auth summary refreshes. */
519
- function openLoginOverlay(tui, wizard, agentDir) {
520
- let handle;
521
- let current;
522
- let done = false;
523
- const disposeCurrent = () => {
524
- const disposable = current?.component;
525
- try {
526
- disposable?.dispose?.();
527
- }
528
- catch {
529
- /* ignore dispose errors during teardown */
530
- }
531
- };
532
- const mount = (p) => {
533
- handle = tui.showOverlay(p.component, LOGIN_OVERLAY_OPTIONS);
534
- tui.setFocus(p.focus);
535
- };
536
- const unmount = () => handle?.hide();
537
- const replace = (component, focus) => {
538
- if (done)
539
- return;
540
- disposeCurrent();
541
- unmount();
542
- current = { component, focus };
543
- mount(current);
544
- tui.requestRender();
545
- };
546
- const close = () => {
547
- if (done)
548
- return;
549
- done = true;
550
- disposeCurrent();
551
- unmount();
552
- wizard.refreshAuth();
553
- tui.setFocus(wizard);
554
- tui.requestRender();
555
- };
556
- current = buildLoginPicker(tui, agentDir, () => wizard.refreshAuth(), (msg) => wizard.setNotice(msg), { close, replace });
557
- mount(current);
558
- tui.requestRender();
559
- }
560
1300
  async function runSetup() {
561
1301
  const state = buildSetupInitialState();
562
1302
  if (!process.stdout.isTTY) {
@@ -565,18 +1305,12 @@ async function runSetup() {
565
1305
  }
566
1306
  applyTheme({ cwd: process.cwd() });
567
1307
  const agentDir = defaultAgentDir();
568
- const loadAuthSummary = () => {
569
- try {
570
- return summarizeProviderAuth(agentDir);
571
- }
572
- catch {
573
- return [];
574
- }
575
- };
1308
+ const rawUserConfig = readRawScopeConfig('user');
1309
+ const rawKeybindings = rawUserConfig?.keybindings;
1310
+ const openingBindings = resolveKeybindings(rawKeybindings);
576
1311
  const terminal = new ProcessTerminal();
577
1312
  const tui = new TUI(terminal, true);
578
- const wizard = new SetupWizard(state.manifest, state.installedPackageIds, state.systemTools, loadAuthSummary(), loadAuthSummary);
579
- wizard.onOpenLogin = () => openLoginOverlay(tui, wizard, agentDir);
1313
+ const wizard = new SetupWizard(state.manifest, state.installedPackageIds, state.systemTools, tui, agentDir, openingBindings, rawKeybindings, () => terminal.rows);
580
1314
  tui.addChild(wizard);
581
1315
  tui.setFocus(wizard);
582
1316
  await new Promise((resolve, reject) => {
@@ -585,6 +1319,7 @@ async function runSetup() {
585
1319
  if (restored)
586
1320
  return;
587
1321
  restored = true;
1322
+ wizard.dispose();
588
1323
  try {
589
1324
  tui.stop();
590
1325
  }
@@ -612,6 +1347,21 @@ async function runSetup() {
612
1347
  process.once('SIGTERM', onSignal);
613
1348
  process.once('SIGHUP', onSignal);
614
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
+ });
615
1365
  wizard.onCancel = finish;
616
1366
  wizard.onSubmit = (submission) => {
617
1367
  restore();
@@ -624,13 +1374,6 @@ async function runSetup() {
624
1374
  resolve();
625
1375
  }, fail);
626
1376
  };
627
- tui.addInputListener((data) => {
628
- if (matchesKey(data, 'ctrl+c') || matchesKey(data, 'ctrl+d')) {
629
- finish();
630
- return { consume: true };
631
- }
632
- return undefined;
633
- });
634
1377
  try {
635
1378
  tui.start();
636
1379
  }
@@ -641,17 +1384,18 @@ async function runSetup() {
641
1384
  }
642
1385
  export const sysSetupLeaf = defineLeaf({
643
1386
  name: 'setup',
644
- description: 'log in to a model provider, install the bundled pi plugins, system deps, and Exa + Composio key setup',
645
- 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 and Composio API keys.',
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.',
646
1389
  help: {
647
1390
  name: 'sys setup',
648
- summary: 'open the multi-tab setup wizard for provider login, bundled pi plugins, system deps, and Exa + Composio keys',
1391
+ summary: 'open the multi-tab setup wizard for provider management, bundled pi plugins, system deps, Exa + Composio keys, and crouter-owned keybindings',
649
1392
  inputNote: 'No input parameters.',
650
1393
  output: [],
651
1394
  outputKind: 'object',
652
1395
  effects: [
653
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.',
654
- 'Collects companion package installs, system dependency installs, an Exa API key, and a Composio API key across the remaining tabs.',
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.',
655
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.',
656
1400
  ],
657
1401
  },