@crouton-kit/crouter 0.3.78 → 0.3.80
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.
- package/dist/build-root.d.ts +12 -4
- package/dist/build-root.js +25 -6
- package/dist/builtin-memory/crouter-development/plugins.md +82 -5
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +1228 -12
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +3 -3
- package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +2 -787
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +8 -3
- package/dist/clients/attach/__tests__/attach-keybindings.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/attach-keybindings.test.js +113 -0
- package/dist/clients/attach/__tests__/mermaid-render.test.js +9 -1
- package/dist/clients/attach/attach-cmd.d.ts +9 -1
- package/dist/clients/attach/attach-cmd.js +847 -801
- package/dist/clients/attach/auth-pickers.d.ts +0 -12
- package/dist/clients/attach/auth-pickers.js +64 -15
- package/dist/clients/attach/chat-view.d.ts +4 -0
- package/dist/clients/attach/chat-view.js +10 -0
- package/dist/clients/attach/graph-overlay.d.ts +12 -2
- package/dist/clients/attach/graph-overlay.js +83 -33
- package/dist/clients/attach/input-controller.d.ts +19 -0
- package/dist/clients/attach/input-controller.js +55 -11
- package/dist/clients/attach/titled-editor.d.ts +21 -0
- package/dist/clients/attach/titled-editor.js +101 -0
- package/dist/commands/human/queue.js +3 -4
- package/dist/commands/pkg/plugin-inspect.js +22 -1
- package/dist/commands/pkg/plugin-manage.js +31 -9
- package/dist/commands/sys/__tests__/setup-core.test.js +158 -1
- package/dist/commands/sys/doctor.js +42 -4
- package/dist/commands/sys/setup-core.d.ts +37 -0
- package/dist/commands/sys/setup-core.js +138 -1
- package/dist/commands/sys/setup.d.ts +88 -0
- package/dist/commands/sys/setup.js +915 -171
- package/dist/commands/view-pick.d.ts +4 -0
- package/dist/commands/view-pick.js +17 -7
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +34 -9
- package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +1 -0
- package/dist/core/__tests__/command-plugins-surfaces.test.js +298 -0
- package/dist/core/__tests__/command-plugins.test.d.ts +1 -0
- package/dist/core/__tests__/command-plugins.test.js +444 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +9 -1
- package/dist/core/__tests__/preview-registry-sync.test.js +30 -1
- package/dist/core/__tests__/scope-crouter-home-fence.test.d.ts +1 -0
- package/dist/core/__tests__/scope-crouter-home-fence.test.js +55 -0
- package/dist/core/canvas/browse/app.d.ts +6 -0
- package/dist/core/canvas/browse/app.js +71 -41
- package/dist/core/command-plugins/adapter.d.ts +15 -0
- package/dist/core/command-plugins/adapter.js +145 -0
- package/dist/core/command-plugins/compose.d.ts +5 -0
- package/dist/core/command-plugins/compose.js +56 -0
- package/dist/core/command-plugins/discovery.d.ts +104 -0
- package/dist/core/command-plugins/discovery.js +565 -0
- package/dist/core/config.d.ts +2 -5
- package/dist/core/keybindings/__tests__/bespoke-consumers.test.d.ts +1 -0
- package/dist/core/keybindings/__tests__/bespoke-consumers.test.js +40 -0
- package/dist/core/keybindings/__tests__/resolve.test.js +2 -2
- package/dist/core/keybindings/catalog.d.ts +3 -3
- package/dist/core/keybindings/catalog.js +2 -1
- package/dist/core/profiles/select.d.ts +6 -0
- package/dist/core/profiles/select.js +90 -59
- package/dist/core/provider-management.d.ts +12 -0
- package/dist/core/provider-management.js +24 -0
- package/dist/core/runtime/banner.d.ts +13 -0
- package/dist/core/runtime/banner.js +51 -0
- package/dist/core/runtime/broker.js +7 -6
- package/dist/core/runtime/pi-vendored.d.ts +8 -0
- package/dist/core/runtime/pi-vendored.js +14 -0
- package/dist/core/runtime/recap.d.ts +1 -1
- package/dist/core/runtime/recap.js +50 -25
- package/dist/core/runtime/session-list-cache.d.ts +10 -0
- package/dist/core/runtime/session-list-cache.js +94 -26
- package/dist/core/runtime/spawn.js +5 -17
- package/dist/core/runtime/tmux.js +2 -1
- package/dist/core/scope.js +27 -4
- package/dist/core/subscription-state.d.ts +90 -0
- package/dist/core/subscription-state.js +762 -0
- package/dist/daemon/crtrd.js +253 -12
- package/dist/pi-extensions/canvas-recap.js +43 -17
- package/dist/types.d.ts +6 -13
- package/dist/types.js +0 -3
- 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 {
|
|
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
|
|
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
|
-
|
|
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,
|
|
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.
|
|
127
|
-
this.
|
|
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 +
|
|
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 (
|
|
572
|
+
if (this.matches('crtr.setup.tab-next', data)) {
|
|
183
573
|
this.moveTab(1);
|
|
184
574
|
return true;
|
|
185
575
|
}
|
|
186
|
-
if (
|
|
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 (
|
|
945
|
+
if (this.matches('crtr.setup.confirm.accept', data)) {
|
|
273
946
|
this.onSubmit?.(this.buildSubmission());
|
|
274
947
|
return;
|
|
275
948
|
}
|
|
276
|
-
if (
|
|
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
|
-
|
|
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 (
|
|
291
|
-
this.
|
|
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 (
|
|
989
|
+
if (this.matches('crtr.setup.list-down', data))
|
|
298
990
|
this.movePlugin(1);
|
|
299
|
-
|
|
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
|
-
|
|
304
|
-
}
|
|
305
|
-
if (matchesKey(data, 'space')) {
|
|
993
|
+
else if (this.matches('crtr.setup.toggle', data))
|
|
306
994
|
this.togglePackage();
|
|
307
|
-
|
|
308
|
-
}
|
|
309
|
-
if (matchesKey(data, '?')) {
|
|
995
|
+
else if (this.matches('crtr.setup.details', data))
|
|
310
996
|
this.toggleDetails();
|
|
311
|
-
|
|
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 (
|
|
1002
|
+
if (this.matches('crtr.setup.list-down', data))
|
|
321
1003
|
this.moveSystem(1);
|
|
322
|
-
|
|
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
|
-
|
|
327
|
-
}
|
|
328
|
-
if (matchesKey(data, 'space')) {
|
|
1006
|
+
else if (this.matches('crtr.setup.toggle', data))
|
|
329
1007
|
this.toggleSystem();
|
|
330
|
-
|
|
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.
|
|
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 = ['
|
|
346
|
-
const
|
|
347
|
-
.map((label, index) => tabPill(
|
|
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
|
-
? '
|
|
358
|
-
: this.activeTab === 0
|
|
359
|
-
? '
|
|
360
|
-
: this.activeTab ===
|
|
361
|
-
?
|
|
362
|
-
: this.activeTab ===
|
|
363
|
-
? '
|
|
364
|
-
: this.activeTab ===
|
|
365
|
-
? '
|
|
366
|
-
:
|
|
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
|
-
|
|
370
|
-
|
|
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',
|
|
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', '
|
|
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.
|
|
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.
|
|
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
|
|
569
|
-
|
|
570
|
-
|
|
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,
|
|
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: '
|
|
645
|
-
whenToUse: 'you want to bootstrap this machine
|
|
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
|
|
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,
|
|
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
|
},
|