@tianmucreations/jeeves 0.3.1 → 0.3.2

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 (43) hide show
  1. package/dist/agent/errors.js +1 -1
  2. package/dist/agent/loop.js +34 -1
  3. package/dist/agent/systemPrompt.js +19 -5
  4. package/dist/app.js +23 -1
  5. package/dist/checkpoints/index.js +34 -4
  6. package/dist/commands/clear.js +2 -0
  7. package/dist/commands/help.js +16 -14
  8. package/dist/commands/keys.js +2 -1
  9. package/dist/components/AddressPrompt.js +21 -6
  10. package/dist/components/Footer.js +6 -1
  11. package/dist/components/Header.js +5 -1
  12. package/dist/components/HelpView.js +1 -1
  13. package/dist/components/Input.js +135 -13
  14. package/dist/components/KeysManager.js +33 -18
  15. package/dist/components/ModelPicker.js +27 -8
  16. package/dist/components/OpenRouterConnect.js +114 -0
  17. package/dist/components/ProjectPicker.js +55 -16
  18. package/dist/components/Transcript.js +39 -2
  19. package/dist/components/input-layout.js +129 -26
  20. package/dist/components/markdown.js +185 -0
  21. package/dist/components/transcript-layout.js +50 -2
  22. package/dist/index.js +2 -1
  23. package/dist/ink/mouse.js +39 -6
  24. package/dist/ink/quit.js +21 -0
  25. package/dist/ink/selection.js +128 -0
  26. package/dist/keys/store.js +72 -2
  27. package/dist/platform/address.js +16 -0
  28. package/dist/platform/chat-folder.js +24 -0
  29. package/dist/platform/config.js +3 -0
  30. package/dist/platform/wording.js +10 -0
  31. package/dist/providers/direct.js +8 -2
  32. package/dist/providers/index.js +21 -3
  33. package/dist/providers/ollama.js +8 -2
  34. package/dist/providers/openrouter-signin.js +102 -0
  35. package/dist/providers/openrouter.js +17 -2
  36. package/dist/providers/silence.js +39 -0
  37. package/dist/providers/zai.js +17 -13
  38. package/dist/state/session.js +41 -2
  39. package/dist/tools/index.js +22 -5
  40. package/dist/tools/runBash.js +17 -5
  41. package/dist/tools/web/research.js +119 -21
  42. package/dist/tools/web/zai-search.js +79 -0
  43. package/package.json +2 -1
@@ -1,12 +1,15 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useRef } from 'react';
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import stringWidth from 'string-width';
3
4
  import { Box, Text, useCursor, useInput, usePaste, useStdout } from 'ink';
4
- import { runTurn } from '../agent/loop.js';
5
+ import { runTurn, stopTurn } from '../agent/loop.js';
6
+ import { copySelection } from '../ink/selection.js';
7
+ import { pressCtrlCToQuit } from '../ink/quit.js';
5
8
  import { answerApproval, currentApprovalTrustable } from '../agent/permissions.js';
6
9
  import { session, useSession } from '../state/session.js';
7
10
  import { BLOCK_CURSOR, inputFrameRow } from '../ink/cursor.js';
8
11
  import { isMouseSequence, handleMouseInput } from '../ink/mouse.js';
9
- import { inputLayout, dropLastChar, splitTypedBurst, cleanPaste } from './input-layout.js';
12
+ import { inputLayout, splitTypedBurst, cleanPaste, scrollToShowCursor, previousWordStart, nextWordEnd } from './input-layout.js';
10
13
  // The rows the input box needs for the text being typed (the window makes room).
11
14
  export const MAX_INPUT_ROWS = 6;
12
15
  export function inputRowsFor(text, width) {
@@ -27,11 +30,63 @@ export function Input({ scrollPage = 10, width = 76 }) {
27
30
  const s = useSession();
28
31
  const { stdout } = useStdout();
29
32
  const { setCursorPosition } = useCursor();
30
- const layout = inputLayout(valueRef.current, width, MAX_INPUT_ROWS);
31
- const showingText = !s.approvalPending && s.transcriptScrollUp === 0;
32
- const setValue = (text) => {
33
+ // How far the box is scrolled back through a message taller than it (0 = the
34
+ // end, where the typing is). A ref for the same reason as the text.
35
+ const draftUpRef = useRef(0);
36
+ const [, setDraftUp] = useState(0);
37
+ // Where the cursor is in the message, in characters; null means at the end (as
38
+ // Claude Code: arrows, Option+arrows for words, Ctrl+A / Ctrl+E, and a click
39
+ // move it, and typing goes in where it is - owner, 19 Sept).
40
+ const cursorRef = useRef(null);
41
+ const [, setCursorTick] = useState(0);
42
+ const layout = inputLayout(valueRef.current, width, MAX_INPUT_ROWS, draftUpRef.current, cursorRef.current);
43
+ // The real cursor only at the end of the message; inside it, the highlighted
44
+ // character is the cursor.
45
+ const showingText = !s.approvalPending && s.transcriptScrollUp === 0 && layout.scrollUp === 0 && cursorRef.current === null;
46
+ const scrollDraft = (up) => {
47
+ draftUpRef.current = up;
48
+ setDraftUp(up);
49
+ };
50
+ const chars = () => Array.from(valueRef.current);
51
+ const moveCursor = (to) => {
52
+ const length = chars().length;
53
+ const next = to === null || to >= length ? null : Math.max(0, to);
54
+ cursorRef.current = next;
55
+ scrollDraft(scrollToShowCursor(valueRef.current, width, MAX_INPUT_ROWS, next === null ? 0 : draftUpRef.current, next));
56
+ setCursorTick((tick) => tick + 1);
57
+ };
58
+ const setValue = (text, cursor = null) => {
33
59
  valueRef.current = text;
34
60
  session.setInputText(text);
61
+ cursorRef.current = cursor !== null && cursor < Array.from(text).length ? cursor : null;
62
+ // A change shows where it was made: at the end, or at the cursor inside the text.
63
+ const up = cursorRef.current === null ? 0 : scrollToShowCursor(text, width, MAX_INPUT_ROWS, draftUpRef.current, cursorRef.current);
64
+ if (up !== draftUpRef.current)
65
+ scrollDraft(up);
66
+ };
67
+ // Typed or pasted text goes in at the cursor.
68
+ const insert = (text) => {
69
+ const all = chars();
70
+ const at = cursorRef.current ?? all.length;
71
+ const added = Array.from(text);
72
+ setValue([...all.slice(0, at), ...added, ...all.slice(at)].join(''), cursorRef.current === null ? null : at + added.length);
73
+ };
74
+ // Clicking in the typing box puts the cursor there.
75
+ session.inputClick = (col, row) => {
76
+ const rows = stdout.rows ?? 24;
77
+ const first = rows - 2 - (layout.rows.length - 1);
78
+ const target = layout.rows[row - first];
79
+ if (!valueRef.current || !target || target.hint || target.start === undefined)
80
+ return;
81
+ let width = 0;
82
+ let offset = 0;
83
+ for (const ch of Array.from(target.text)) {
84
+ if (width >= col - 3)
85
+ break;
86
+ width += stringWidth(ch);
87
+ offset += 1;
88
+ }
89
+ moveCursor(target.start + offset);
35
90
  };
36
91
  // The block cursor sits at the text insertion point: two columns in (the
37
92
  // border's │ and its padding space) plus the visible text's width, measured
@@ -41,7 +96,8 @@ export function Input({ scrollPage = 10, width = 76 }) {
41
96
  // hands the position to Ink in its own useInsertionEffect, which runs before
42
97
  // this commit's frame is written - a useLayoutEffect call runs after that and
43
98
  // only lands a frame late (measured: the cursor stayed hidden when the window
44
- // opened). Hidden while the row shows a hint instead of the text.
99
+ // opened). Hidden while the row shows a hint instead of the text, and while the
100
+ // box is scrolled back through a long message (the typing point is out of view).
45
101
  // The last row of the box stays on the same terminal row however tall the box
46
102
  // grows (it grows upwards), so the cursor's row is unchanged.
47
103
  setCursorPosition(showingText ? { x: 2 + layout.cursorX, y: inputFrameRow(stdout.rows ?? 24) } : undefined);
@@ -65,6 +121,33 @@ export function Input({ scrollPage = 10, width = 76 }) {
65
121
  }
66
122
  if (s.pickerOpen || s.keysOpen || s.wizardActive || s.helpOpen)
67
123
  return;
124
+ // Ctrl+C, as Claude Code: copy a selection, else clear the typing, else stop the
125
+ // job, else quit only when pressed twice. It used to quit at once, losing the
126
+ // conversation - and Windows and Linux people press Ctrl+C to copy (audit, 19 Sept).
127
+ if (key.ctrl && input === 'c') {
128
+ if (session.selection) {
129
+ void copySelection();
130
+ session.setSelection(null);
131
+ }
132
+ else if (valueRef.current) {
133
+ setValue('');
134
+ }
135
+ else if (s.status === 'working' || s.approvalPending) {
136
+ stopTurn();
137
+ }
138
+ else {
139
+ pressCtrlCToQuit();
140
+ }
141
+ return;
142
+ }
143
+ // Esc stops the job, as in Claude Code and the window's Stop button.
144
+ if (key.escape && (s.status === 'working' || s.approvalPending)) {
145
+ stopTurn();
146
+ return;
147
+ }
148
+ // Any key clears a selection, as in Claude Code.
149
+ if (session.selection)
150
+ session.setSelection(null);
68
151
  if (s.approvalPending) {
69
152
  const answer = input.toLowerCase();
70
153
  if (answer === 'y')
@@ -83,6 +166,31 @@ export function Input({ scrollPage = 10, width = 76 }) {
83
166
  s.toggleShowLastReasoning();
84
167
  return;
85
168
  }
169
+ // A message taller than the box: the arrows move through the message itself
170
+ // (as Claude Code's do when the input spans more than one line). Page Up/Down
171
+ // and the mouse wheel still scroll the conversation.
172
+ if ((key.upArrow || key.downArrow) && layout.maxScrollUp > 0 && s.transcriptScrollUp === 0) {
173
+ const next = Math.min(draftUpRef.current, layout.maxScrollUp) + (key.upArrow ? 3 : -3);
174
+ scrollDraft(Math.max(0, Math.min(next, layout.maxScrollUp)));
175
+ return;
176
+ }
177
+ // Moving the cursor within the message, as in Claude Code.
178
+ if (valueRef.current && s.transcriptScrollUp === 0) {
179
+ const here = cursorRef.current ?? chars().length;
180
+ if (key.leftArrow && !key.meta && !key.ctrl)
181
+ return moveCursor(Math.max(0, here - 1));
182
+ if (key.rightArrow && !key.meta && !key.ctrl)
183
+ return moveCursor(here + 1);
184
+ // Option+Left / Option+Right arrive from Terminal.app as Esc-b / Esc-f.
185
+ if ((key.leftArrow && (key.meta || key.ctrl)) || (key.meta && input === 'b'))
186
+ return moveCursor(previousWordStart(valueRef.current, here));
187
+ if ((key.rightArrow && (key.meta || key.ctrl)) || (key.meta && input === 'f'))
188
+ return moveCursor(nextWordEnd(valueRef.current, here));
189
+ if (key.ctrl && input === 'a')
190
+ return moveCursor(0);
191
+ if (key.ctrl && input === 'e')
192
+ return moveCursor(null);
193
+ }
86
194
  // The alternate screen has no native scrollback, so these keys scroll the
87
195
  // transcript region itself (Claude Code's bindings): arrows move 3 rows,
88
196
  // Page Up/Down a full page, End jumps back to the newest and re-follows.
@@ -109,7 +217,9 @@ export function Input({ scrollPage = 10, width = 76 }) {
109
217
  // A burst of typing that ends with Enter is typing plus Enter (see splitTypedBurst).
110
218
  const burst = key.return ? { typed: '', enter: true, rest: '' } : splitTypedBurst(input);
111
219
  if (burst.enter) {
112
- const text = (valueRef.current + burst.typed).trim();
220
+ if (burst.typed)
221
+ insert(burst.typed);
222
+ const text = valueRef.current.trim();
113
223
  setValue(burst.rest);
114
224
  if (!text)
115
225
  return;
@@ -127,13 +237,18 @@ export function Input({ scrollPage = 10, width = 76 }) {
127
237
  }
128
238
  if (key.backspace || key.delete) {
129
239
  s.followTranscript();
130
- setValue(dropLastChar(valueRef.current));
240
+ // The character before the cursor goes (one whole character, never half of one).
241
+ const all = chars();
242
+ const at = cursorRef.current ?? all.length;
243
+ if (at === 0)
244
+ return;
245
+ setValue([...all.slice(0, at - 1), ...all.slice(at)].join(''), cursorRef.current === null ? null : at - 1);
131
246
  return;
132
247
  }
133
248
  if (!input || key.ctrl || key.meta)
134
249
  return;
135
250
  s.followTranscript();
136
- setValue(valueRef.current + input);
251
+ insert(input);
137
252
  });
138
253
  // Pasted text arrives whole (bracketed paste), keeps its line breaks, and never
139
254
  // sends by itself - only Enter does.
@@ -141,7 +256,7 @@ export function Input({ scrollPage = 10, width = 76 }) {
141
256
  if (s.pickerOpen || s.keysOpen || s.wizardActive || s.helpOpen || s.approvalPending)
142
257
  return;
143
258
  s.followTranscript();
144
- setValue(valueRef.current + cleanPaste(text));
259
+ insert(cleanPaste(text));
145
260
  });
146
261
  if (s.approvalPending) {
147
262
  return (_jsx(Text, { color: "yellow", children: currentApprovalTrustable() ? 'y = allow · a = always allow in this project · n = deny' : 'y = allow · n = deny' }));
@@ -156,7 +271,14 @@ export function Input({ scrollPage = 10, width = 76 }) {
156
271
  // bytes differ from the same text without them, keeping Ink on its full-frame
157
272
  // path (see input-layout.ts).
158
273
  if (!valueRef.current) {
159
- return _jsx(Text, { dimColor: true, children: s.status === 'working' ? 'type your next message - it will be sent when I finish' : 'ask anything' });
274
+ return _jsx(Text, { dimColor: true, children: s.status === 'working' ? 'type your next message - it will be sent when I finish · Esc stops' : 'ask anything' });
160
275
  }
161
- return (_jsx(Box, { flexDirection: "column", children: layout.rows.map((row, index) => (_jsxs(Text, { children: [row.text, row.trailingSpaces ? _jsx(Text, { dimColor: true, children: row.trailingSpaces }) : null] }, index))) }));
276
+ return (_jsx(Box, { flexDirection: "column", children: layout.rows.map((row, index) => {
277
+ if (row.cursorAt !== undefined) {
278
+ // The cursor inside the text: the character under it drawn reversed.
279
+ const rowChars = Array.from(row.text);
280
+ return (_jsxs(Text, { children: [rowChars.slice(0, row.cursorAt).join(''), _jsx(Text, { inverse: true, children: rowChars[row.cursorAt] ?? ' ' }), rowChars.slice(row.cursorAt + 1).join('')] }, index));
281
+ }
282
+ return (_jsxs(Text, { dimColor: row.hint, children: [row.text, row.trailingSpaces ? _jsx(Text, { dimColor: true, children: row.trailingSpaces }) : null] }, index));
283
+ }) }));
162
284
  }
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useState } from 'react';
3
3
  import { Box, Text, useInput } from 'ink';
4
4
  import { session, useSession } from '../state/session.js';
@@ -9,6 +9,8 @@ import { setDefaultModel, setDefaultProvider } from '../platform/config.js';
9
9
  import { setKey, deleteKey } from '../keys/store.js';
10
10
  import { keyLooksValid } from '../commands/keys.js';
11
11
  import { isMouseSequence } from '../ink/mouse.js';
12
+ import { OpenRouterConnect } from './OpenRouterConnect.js';
13
+ import { COPY_KEYS, KEY_STORE, KEY_STORE_SUBJECT } from '../platform/wording.js';
12
14
  // Every service Jeeves connects to. The optional OpenRouter management key (it unlocks
13
15
  // the real account balance) is for /keys only, and the compatible service needs its
14
16
  // address too, so it is set up in /model - the first-run wizard keeps to the essentials.
@@ -34,10 +36,10 @@ export function KeysManager({ mode, rows, columns }) {
34
36
  function statusFor(rowId) {
35
37
  if (rowId === 'openrouter') {
36
38
  if (getKeySource() === 'keychain')
37
- return 'key stored in your Mac keychain';
39
+ return `key stored in ${KEY_STORE}`;
38
40
  if (getKeySource() === 'env')
39
41
  return 'key in a local file - add it with /keys to store it safely';
40
- return stored.includes('openrouter') ? 'key stored in your Mac keychain' : 'no key';
42
+ return stored.includes('openrouter') ? `key stored in ${KEY_STORE}` : 'no key';
41
43
  }
42
44
  if (rowId === 'openrouter-management') {
43
45
  return stored.includes('openrouter-management')
@@ -54,7 +56,7 @@ export function KeysManager({ mode, rows, columns }) {
54
56
  }
55
57
  return stored.includes(rowId) ? 'key stored - direct connection ready' : 'no key';
56
58
  }
57
- function finishWizard(message, connected) {
59
+ function finishWizard(message, connected, skipped = false) {
58
60
  if (connected) {
59
61
  session.addNotice(message);
60
62
  if (session.status === 'disconnected')
@@ -64,17 +66,17 @@ export function KeysManager({ mode, rows, columns }) {
64
66
  else {
65
67
  session.addNotice(message);
66
68
  }
67
- session.endWizard();
69
+ session.endWizard(skipped);
68
70
  }
69
71
  async function saveKey(provider, key) {
70
72
  if (provider === 'openrouter') {
71
73
  const saved = await storeOpenRouterKey(key);
72
74
  if (!saved) {
73
- setNote('The Mac keychain was not reachable - press Enter and try again.');
75
+ setNote(`${KEY_STORE_SUBJECT} was not reachable - press Enter and try again.`);
74
76
  return;
75
77
  }
76
78
  refreshStored();
77
- const message = 'Your key is saved securely in your Mac keychain. You will not be asked for it again.';
79
+ const message = `Your key is saved securely in ${KEY_STORE}. You will not be asked for it again.`;
78
80
  if (mode === 'wizard') {
79
81
  finishWizard(message, true);
80
82
  }
@@ -86,7 +88,7 @@ export function KeysManager({ mode, rows, columns }) {
86
88
  if (provider === 'zai') {
87
89
  const saved = await storeZaiKey(key);
88
90
  if (!saved) {
89
- setNote('The Mac keychain was not reachable - press Enter and try again.');
91
+ setNote(`${KEY_STORE_SUBJECT} was not reachable - press Enter and try again.`);
90
92
  return;
91
93
  }
92
94
  refreshStored();
@@ -109,7 +111,7 @@ export function KeysManager({ mode, rows, columns }) {
109
111
  return;
110
112
  }
111
113
  if (result === 'keychain') {
112
- setNote('The Mac keychain was not reachable - press Enter and try again.');
114
+ setNote(`${KEY_STORE_SUBJECT} was not reachable - press Enter and try again.`);
113
115
  return;
114
116
  }
115
117
  refreshStored();
@@ -132,7 +134,7 @@ export function KeysManager({ mode, rows, columns }) {
132
134
  }
133
135
  const saved = await setKey(provider, key);
134
136
  if (!saved) {
135
- setNote('The Mac keychain was not reachable - press Enter and try again.');
137
+ setNote(`${KEY_STORE_SUBJECT} was not reachable - press Enter and try again.`);
136
138
  return;
137
139
  }
138
140
  refreshStored();
@@ -189,6 +191,9 @@ export function KeysManager({ mode, rows, columns }) {
189
191
  useInput((input, key) => {
190
192
  if (isMouseSequence(input))
191
193
  return;
194
+ // The OpenRouter screen handles its own keys.
195
+ if (phase.kind === 'openrouter')
196
+ return;
192
197
  if (phase.kind === 'ask') {
193
198
  const answer = input.toLowerCase();
194
199
  if (answer === 'y') {
@@ -196,7 +201,7 @@ export function KeysManager({ mode, rows, columns }) {
196
201
  setPhase({ kind: 'list' });
197
202
  }
198
203
  else if (answer === 'n') {
199
- finishWizard('No key for now - you can add one any time with /keys.', false);
204
+ finishWizard('Not connected yet - type /keys any time to connect an AI service. It takes about a minute.', false, true);
200
205
  }
201
206
  return;
202
207
  }
@@ -255,7 +260,7 @@ export function KeysManager({ mode, rows, columns }) {
255
260
  // phase: list
256
261
  if (key.escape) {
257
262
  if (mode === 'wizard') {
258
- finishWizard('Setup skipped - you can add a key any time with /keys.', false);
263
+ finishWizard('Not connected yet - type /keys any time to connect an AI service. It takes about a minute.', false, true);
259
264
  }
260
265
  else {
261
266
  session.closeKeys();
@@ -282,9 +287,10 @@ export function KeysManager({ mode, rows, columns }) {
282
287
  }
283
288
  return;
284
289
  }
285
- setPhase({ kind: 'enter-key', provider: row.id, label: row.label });
286
290
  setNote('');
287
291
  setHidden('');
292
+ // OpenRouter gets the explained choice: sign in through the browser, or paste a key.
293
+ setPhase(row.id === 'openrouter' ? { kind: 'openrouter' } : { kind: 'enter-key', provider: row.id, label: row.label });
288
294
  return;
289
295
  }
290
296
  const answer = input.toLowerCase();
@@ -300,7 +306,7 @@ export function KeysManager({ mode, rows, columns }) {
300
306
  }
301
307
  });
302
308
  const title = phase.kind === 'ask'
303
- ? 'Welcome - one quick setup question'
309
+ ? 'Welcome - one quick setup step'
304
310
  : phase.kind === 'enter-key'
305
311
  ? `Paste the ${phase.label} key`
306
312
  : phase.kind === 'confirm-remove'
@@ -309,9 +315,9 @@ export function KeysManager({ mode, rows, columns }) {
309
315
  ? 'Saved'
310
316
  : mode === 'wizard'
311
317
  ? 'Which AI service should do the thinking?'
312
- : 'Keys - stored in your Mac keychain';
318
+ : `Keys - stored in ${KEY_STORE}`;
313
319
  const hint = phase.kind === 'ask'
314
- ? 'y = yes · n = not now'
320
+ ? 'y = yes, set it up · n = not now'
315
321
  : phase.kind === 'enter-key'
316
322
  ? 'paste the key · Enter save · Esc back'
317
323
  : phase.kind === 'confirm-remove'
@@ -321,6 +327,15 @@ export function KeysManager({ mode, rows, columns }) {
321
327
  : mode === 'wizard'
322
328
  ? '↑↓ move · Enter choose · Esc back'
323
329
  : '↑↓ move · Enter add or replace · d remove · Esc close';
324
- return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsx(Text, { dimColor: true, children: title }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, justifyContent: "center", children: [phase.kind === 'ask' && _jsx(Text, { children: ' Add an API key?' }), phase.kind === 'list' &&
325
- visibleRows.map((row, index) => (_jsxs(Text, { inverse: index === cursor, children: [` ${row.label}`.padEnd(21), _jsx(Text, { dimColor: true, children: statusFor(row.id) })] }, row.id))), phase.kind === 'enter-key' && (_jsxs(Text, { children: [_jsxs(Text, { children: ["Paste the ", phase.label, " key (it stays hidden): "] }), _jsx(Text, { inverse: true, children: " " })] })), phase.kind === 'confirm-remove' && (_jsxs(Text, { children: ["Remove the ", phase.label, " key from your Mac keychain?"] })), phase.kind === 'saved' && _jsx(Text, { children: phase.message })] }), note ? (_jsx(Text, { color: "yellow", children: note })) : (_jsx(Text, { dimColor: true, children: hint }))] }));
330
+ if (phase.kind === 'openrouter') {
331
+ return (_jsx(Box, { flexDirection: "column", height: rows, children: _jsx(OpenRouterConnect, { hasKey: stored.includes('openrouter') || getKeySource() !== null, onBack: () => setPhase({ kind: 'list' }), onDone: (message) => {
332
+ refreshStored();
333
+ if (mode === 'wizard')
334
+ finishWizard(message, true);
335
+ else
336
+ setPhase({ kind: 'saved', message });
337
+ } }) }));
338
+ }
339
+ return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsx(Text, { dimColor: true, children: title }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, justifyContent: "center", children: [phase.kind === 'ask' && (_jsxs(_Fragment, { children: [_jsx(Text, { children: "Jeeves needs an AI service to think with - the company that runs the AI models." }), _jsx(Text, { children: "You connect one account once, and pay that service only for what you use." }), _jsx(Text, { children: " " }), _jsx(Text, { children: "Set one up now? (y/n)" })] })), phase.kind === 'list' &&
340
+ visibleRows.map((row, index) => (_jsxs(Text, { inverse: index === cursor, children: [` ${row.label}`.padEnd(21), _jsx(Text, { dimColor: true, children: mode === 'wizard' && 'description' in row && !stored.includes(row.id) ? row.description : statusFor(row.id) })] }, row.id))), phase.kind === 'enter-key' && (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [_jsxs(Text, { children: ["Paste the ", phase.label, " key (it stays hidden): "] }), _jsx(Text, { dimColor: true, children: hidden ? `${Array.from(hidden).length} characters ` : '' }), _jsx(Text, { inverse: true, children: " " })] }), isDirectService(phase.provider) ? (_jsx(Text, { dimColor: true, children: `Get one at ${directService(phase.provider).keyPage} - select the address with the mouse and press ${COPY_KEYS} to copy it.` })) : phase.provider === 'zai' ? (_jsx(Text, { dimColor: true, children: `Get one at z.ai/manage-apikey/apikey-list - select the address with the mouse and press ${COPY_KEYS} to copy it.` })) : null] })), phase.kind === 'confirm-remove' && (_jsxs(Text, { children: ["Remove the ", phase.label, " key from ", KEY_STORE, "?"] })), phase.kind === 'saved' && _jsx(Text, { children: phase.message })] }), note ? (_jsx(Text, { color: "yellow", children: note })) : (_jsx(Text, { dimColor: true, children: hint }))] }));
326
341
  }
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React, { useEffect, useMemo, useState } from 'react';
2
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { Box, Text, useInput } from 'ink';
4
4
  import Spinner from 'ink-spinner';
5
5
  import Fuse from 'fuse.js';
@@ -12,10 +12,12 @@ import { directService, isDirectService, CUSTOM_SERVICE_ID } from '../providers/
12
12
  import { loadDirectModels, checkCustomService } from '../providers/catalogue.js';
13
13
  import { autoRowFor, noAutoNote } from '../agent/auto.js';
14
14
  import { getCustomService } from '../platform/config.js';
15
+ import { OpenRouterConnect } from './OpenRouterConnect.js';
15
16
  import { keyLooksValid } from '../commands/keys.js';
16
17
  import { listLocalOllamaModels, isOllamaOnline } from '../providers/ollama.js';
17
18
  import { ZAI_MODELS } from '../providers/zai.js';
18
19
  import { isMouseSequence } from '../ink/mouse.js';
20
+ import { COPY_KEYS, KEY_STORE, KEY_STORE_SUBJECT } from '../platform/wording.js';
19
21
  const TABS = ['favorites', 'recent', 'all', 'tools', 'free'];
20
22
  const TAB_LABELS = {
21
23
  favorites: 'Favorites',
@@ -134,6 +136,8 @@ export function ModelPicker({ rows, columns }) {
134
136
  const [addressValue, setAddressValue] = useState('');
135
137
  // True while a key is being checked with its company.
136
138
  const [checking, setChecking] = useState(false);
139
+ // The browser sign-in in progress, so Esc can cancel it.
140
+ const signInAbort = useRef(null);
137
141
  const [limitValue, setLimitValue] = useState('');
138
142
  const [limitNote, setLimitNote] = useState('');
139
143
  const [limitReturn, setLimitReturn] = useState('providers');
@@ -431,9 +435,24 @@ export function ModelPicker({ rows, columns }) {
431
435
  setAddressValue((v) => v + input);
432
436
  return;
433
437
  }
438
+ // OpenRouter's key step is its own explained screen, which handles its own keys.
439
+ if (step === 'key' && keyFor === 'openrouter')
440
+ return;
441
+ if (step === 'key' && keyFor === 'openrouter') {
442
+ return (_jsx(Box, { flexDirection: "column", height: rows, children: _jsx(OpenRouterConnect, { hasKey: false, onBack: () => setStep('providers'), onDone: (message) => {
443
+ // Said in the conversation too - including "no credit yet" for a new account.
444
+ s.addNotice(message);
445
+ if (s.status === 'disconnected')
446
+ s.setStatus('idle');
447
+ enterModelStep('openrouter');
448
+ } }) }));
449
+ }
434
450
  if (step === 'key') {
435
- if (checking)
451
+ if (checking) {
452
+ if (key.escape && signInAbort.current)
453
+ signInAbort.current.abort();
436
454
  return;
455
+ }
437
456
  if (key.escape) {
438
457
  goBack();
439
458
  return;
@@ -456,7 +475,7 @@ export function ModelPicker({ rows, columns }) {
456
475
  setKeyNote("The service didn't accept that key - paste it again, or Esc");
457
476
  }
458
477
  else if (result === 'keychain') {
459
- setKeyNote('The Mac keychain was not reachable - press Enter and try again.');
478
+ setKeyNote(`${KEY_STORE_SUBJECT} was not reachable - press Enter and try again.`);
460
479
  }
461
480
  else {
462
481
  setStep('address');
@@ -483,7 +502,7 @@ export function ModelPicker({ rows, columns }) {
483
502
  return;
484
503
  }
485
504
  if (result === 'keychain') {
486
- setKeyNote('The Mac keychain was not reachable - press Enter and try again.');
505
+ setKeyNote(`${KEY_STORE_SUBJECT} was not reachable - press Enter and try again.`);
487
506
  return;
488
507
  }
489
508
  if (result === 'saved-unchecked') {
@@ -508,7 +527,7 @@ export function ModelPicker({ rows, columns }) {
508
527
  const store = keyFor === 'openrouter' ? storeOpenRouterKey : storeZaiKey;
509
528
  void store(trimmed).then((saved) => {
510
529
  if (!saved) {
511
- setKeyNote('The Mac keychain was not reachable - press Enter and try again.');
530
+ setKeyNote(`${KEY_STORE_SUBJECT} was not reachable - press Enter and try again.`);
512
531
  return;
513
532
  }
514
533
  if (keyFor === 'zai') {
@@ -648,13 +667,13 @@ export function ModelPicker({ rows, columns }) {
648
667
  ? `Other service — ${addressValue.trim()}`
649
668
  : `${service} — a direct connection with your own key`;
650
669
  const where = keyFor === 'openrouter'
651
- ? 'Get one at openrouter.ai/settings/keys. '
670
+ ? ''
652
671
  : direct
653
- ? `Get one at ${direct.keyPage}. `
672
+ ? `Get one at ${direct.keyPage} (select it with the mouse, ${COPY_KEYS} to copy). `
654
673
  : keyFor === CUSTOM_SERVICE_ID
655
674
  ? 'No key needed for a service on this computer - just press Enter. '
656
675
  : '';
657
- return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsx(Text, { dimColor: true, children: title }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, justifyContent: "center", children: [_jsxs(Text, { children: [_jsxs(Text, { children: ["Paste your ", service, " API key (it stays hidden): "] }), _jsx(Text, { dimColor: true, children: keyValue ? `${keyValue.length} characters ` : '' }), _jsx(Text, { inverse: true, children: " " })] }), _jsxs(Text, { dimColor: true, children: [where, "It is stored in your Mac keychain and never shown again."] })] }), keyNote ? (_jsx(Text, { color: "yellow", children: keyNote })) : (_jsx(Text, { dimColor: true, children: "paste the key \u00B7 Enter save \u00B7 Esc back" }))] }));
676
+ return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsx(Text, { dimColor: true, children: title }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, justifyContent: "center", children: [_jsxs(Text, { children: [_jsxs(Text, { children: ["Paste your ", service, " API key (it stays hidden): "] }), _jsx(Text, { dimColor: true, children: keyValue ? `${keyValue.length} characters ` : '' }), _jsx(Text, { inverse: true, children: " " })] }), _jsxs(Text, { dimColor: true, children: [where, "It is stored in ", KEY_STORE, " and never shown again."] })] }), keyNote ? (_jsx(Text, { color: "yellow", children: keyNote })) : (_jsx(Text, { dimColor: true, children: "paste the key \u00B7 Enter save \u00B7 Esc back" }))] }));
658
677
  }
659
678
  if (step === 'curated') {
660
679
  const picks = resolveCurated(catalog);
@@ -0,0 +1,114 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import { signInWithOpenRouter, WAITING_STEPS } from '../providers/openrouter-signin.js';
5
+ import { noCreditNote } from '../providers/openrouter.js';
6
+ import { storeOpenRouterKey, refreshCredit } from '../providers/index.js';
7
+ import { keyLooksValid } from '../commands/keys.js';
8
+ import { isMouseSequence } from '../ink/mouse.js';
9
+ import { KEY_STORE, KEY_STORE_SUBJECT } from '../platform/wording.js';
10
+ const CHOICES = [
11
+ { title: 'Sign in with OpenRouter (recommended)', detail: 'Your browser opens. Log in, or make a free account, then click Authorize and come back. No key to copy.' },
12
+ { title: 'Paste a key I already have', detail: 'For people who already made a key at openrouter.ai/keys.' },
13
+ ];
14
+ export function OpenRouterConnect({ hasKey, onDone, onBack }) {
15
+ const [step, setStep] = useState('choose');
16
+ const [cursor, setCursor] = useState(0);
17
+ const [pasted, setPasted] = useState('');
18
+ const [note, setNote] = useState('');
19
+ const [address, setAddress] = useState('');
20
+ const signingIn = useRef(null);
21
+ useEffect(() => () => signingIn.current?.abort(), []);
22
+ async function saveKey(key, how) {
23
+ if (!(await storeOpenRouterKey(key))) {
24
+ setStep('choose');
25
+ setNote(`${KEY_STORE_SUBJECT} was not reachable - please try again.`);
26
+ return;
27
+ }
28
+ void refreshCredit();
29
+ const credit = await noCreditNote(key);
30
+ const saved = how === 'signed-in'
31
+ ? `Connected - Jeeves is signed in to OpenRouter, and the key is saved securely in ${KEY_STORE}.`
32
+ : `Your OpenRouter key is saved securely in ${KEY_STORE}. You will not be asked for it again.`;
33
+ onDone(credit ? `${saved} ${credit}` : saved);
34
+ }
35
+ function signIn() {
36
+ const controller = new AbortController();
37
+ signingIn.current = controller;
38
+ setStep('waiting');
39
+ setNote('');
40
+ setAddress('');
41
+ void signInWithOpenRouter({ signal: controller.signal, onUrl: setAddress }).then((result) => {
42
+ signingIn.current = null;
43
+ if (result.ok) {
44
+ void saveKey(result.key, 'signed-in');
45
+ return;
46
+ }
47
+ setStep('choose');
48
+ if (result.reason === 'cancelled')
49
+ return;
50
+ setNote(result.reason === 'timeout'
51
+ ? 'No approval arrived after fifteen minutes, so I stopped waiting. Choose Sign in to try again.'
52
+ : "OpenRouter didn't complete the sign-in. Choose Sign in to try again, or paste a key.");
53
+ });
54
+ }
55
+ useInput((input, key) => {
56
+ if (isMouseSequence(input))
57
+ return;
58
+ if (step === 'waiting') {
59
+ if (key.escape)
60
+ signingIn.current?.abort();
61
+ return;
62
+ }
63
+ if (step === 'paste') {
64
+ if (key.escape) {
65
+ setStep('choose');
66
+ setPasted('');
67
+ setNote('');
68
+ return;
69
+ }
70
+ if (key.return) {
71
+ const value = pasted.trim();
72
+ if (!value) {
73
+ setNote('Paste your key first - or press Esc to go back and choose Sign in instead.');
74
+ return;
75
+ }
76
+ if (!keyLooksValid(value, 'openrouter')) {
77
+ setPasted('');
78
+ setNote('That does not look like an OpenRouter key (they start with sk-or-) - paste it again.');
79
+ return;
80
+ }
81
+ void saveKey(value, 'pasted');
82
+ return;
83
+ }
84
+ if (key.backspace || key.delete) {
85
+ setPasted((current) => current.slice(0, -1));
86
+ return;
87
+ }
88
+ if (!input || key.ctrl || key.meta)
89
+ return;
90
+ setNote('');
91
+ setPasted((current) => current + input.replace(/[\r\n]/g, ''));
92
+ return;
93
+ }
94
+ // step: choose
95
+ if (key.escape)
96
+ return onBack();
97
+ if (key.upArrow || key.downArrow) {
98
+ setCursor((current) => (current === 0 ? 1 : 0));
99
+ return;
100
+ }
101
+ if (input === '1' || input === '2') {
102
+ setCursor(Number(input) - 1);
103
+ }
104
+ if (key.return || input === '1' || input === '2') {
105
+ const choice = input === '1' ? 0 : input === '2' ? 1 : cursor;
106
+ setNote('');
107
+ if (choice === 0)
108
+ signIn();
109
+ else
110
+ setStep('paste');
111
+ }
112
+ });
113
+ return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(Text, { dimColor: true, children: "Connect Jeeves to OpenRouter" }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, justifyContent: "center", children: [step === 'choose' && (_jsxs(_Fragment, { children: [_jsx(Text, { children: "OpenRouter runs the AI models Jeeves thinks with. One account gives you hundreds of models, and you pay only for what you use." }), hasKey ? _jsx(Text, { color: "yellow", children: "You already have an OpenRouter key saved. A new one replaces it - same account, same credit." }) : null, _jsx(Text, { children: " " }), CHOICES.map((choice, index) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { inverse: index === cursor, children: ` ${index + 1}. ${choice.title} ` }), _jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, children: choice.detail }) })] }, choice.title)))] })), step === 'waiting' && (_jsxs(_Fragment, { children: [WAITING_STEPS.map((line) => (_jsx(Text, { children: line }, line))), _jsx(Text, { children: " " }), address ? _jsx(Text, { dimColor: true, children: `Browser didn't open? Go to: ${address}` }) : null] })), step === 'paste' && (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [_jsx(Text, { children: "Paste your OpenRouter key (it stays hidden): " }), _jsx(Text, { dimColor: true, children: pasted ? `${pasted.length} characters ` : '' }), _jsx(Text, { inverse: true, children: " " })] }), _jsx(Text, { dimColor: true, children: "Keys start with sk-or- and are made at openrouter.ai/keys." })] }))] }), note ? (_jsx(Text, { color: "yellow", children: note })) : (_jsx(Text, { dimColor: true, children: step === 'choose' ? '↑↓ or 1 / 2 choose · Enter continue · Esc back' : step === 'waiting' ? 'Esc stop waiting' : 'paste the key · Enter save · Esc back' }))] }));
114
+ }