@tianmucreations/jeeves 0.3.0 → 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 (47) hide show
  1. package/LICENSE +37 -17
  2. package/README.md +5 -2
  3. package/dist/agent/errors.js +1 -1
  4. package/dist/agent/loop.js +40 -1
  5. package/dist/agent/permissions.js +19 -3
  6. package/dist/agent/systemPrompt.js +19 -5
  7. package/dist/agent/trust.js +29 -0
  8. package/dist/app.js +31 -4
  9. package/dist/checkpoints/index.js +34 -4
  10. package/dist/commands/clear.js +2 -0
  11. package/dist/commands/help.js +16 -13
  12. package/dist/commands/keys.js +2 -1
  13. package/dist/components/AddressPrompt.js +21 -6
  14. package/dist/components/Footer.js +6 -1
  15. package/dist/components/Header.js +5 -1
  16. package/dist/components/HelpView.js +1 -1
  17. package/dist/components/Input.js +181 -24
  18. package/dist/components/KeysManager.js +33 -18
  19. package/dist/components/ModelPicker.js +27 -8
  20. package/dist/components/OpenRouterConnect.js +114 -0
  21. package/dist/components/ProjectPicker.js +55 -16
  22. package/dist/components/Transcript.js +40 -3
  23. package/dist/components/input-layout.js +161 -0
  24. package/dist/components/markdown.js +185 -0
  25. package/dist/components/transcript-layout.js +64 -4
  26. package/dist/index.js +2 -1
  27. package/dist/ink/mouse.js +39 -6
  28. package/dist/ink/quit.js +21 -0
  29. package/dist/ink/selection.js +128 -0
  30. package/dist/keys/store.js +72 -2
  31. package/dist/platform/address.js +16 -0
  32. package/dist/platform/chat-folder.js +24 -0
  33. package/dist/platform/config.js +11 -0
  34. package/dist/platform/wording.js +10 -0
  35. package/dist/providers/direct.js +8 -2
  36. package/dist/providers/index.js +21 -3
  37. package/dist/providers/ollama.js +8 -2
  38. package/dist/providers/openrouter-signin.js +102 -0
  39. package/dist/providers/openrouter.js +17 -2
  40. package/dist/providers/silence.js +39 -0
  41. package/dist/providers/zai.js +17 -13
  42. package/dist/state/session.js +61 -2
  43. package/dist/tools/index.js +27 -6
  44. package/dist/tools/runBash.js +17 -5
  45. package/dist/tools/web/research.js +119 -21
  46. package/dist/tools/web/zai-search.js +79 -0
  47. package/package.json +3 -2
@@ -1,6 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { TrafficLight } from './TrafficLight.js';
4
+ // The name in Tianmu Creations gold (tianmucreations.com's --gold), as in the desktop
5
+ // window. A terminal keeps its own font, so only the colour can match; Terminal.app
6
+ // shows the nearest of its 256 colours.
7
+ export const BRAND_GOLD = '#c9a96a';
4
8
  export function Header() {
5
- return (_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: "cyan", children: "Jeeves" }), _jsx(TrafficLight, {})] }));
9
+ return (_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: BRAND_GOLD, bold: true, children: "Jeeves" }), _jsx(TrafficLight, {})] }));
6
10
  }
@@ -10,5 +10,5 @@ export function HelpView({ rows }) {
10
10
  s.closeHelp();
11
11
  }
12
12
  });
13
- return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsx(Text, { dimColor: true, children: "Help - what you can type" }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, justifyContent: "center", children: [_jsx(Text, { children: "Commands" }), COMMANDS.map((entry) => (_jsxs(Text, { children: [_jsx(Text, { children: ' ' + entry.command.padEnd(9) }), _jsx(Text, { dimColor: true, children: entry.description })] }, entry.command))), _jsx(Text, { children: " " }), _jsx(Text, { children: "Keys" }), KEY_BINDINGS.map((entry) => (_jsxs(Text, { children: [_jsx(Text, { children: ' ' + entry.command.padEnd(9) }), _jsx(Text, { dimColor: true, children: entry.description })] }, entry.command))), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "Type anything else in plain English and press Enter - that's all you need." })] }), _jsx(Text, { dimColor: true, children: "Esc close" })] }));
13
+ return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsx(Text, { dimColor: true, children: "Help - what you can type" }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, justifyContent: "center", children: [_jsx(Text, { children: "Commands" }), COMMANDS.map((entry) => (_jsxs(Box, { children: [_jsx(Box, { width: 11, flexShrink: 0, children: _jsx(Text, { children: ' ' + entry.command }) }), _jsx(Text, { dimColor: true, wrap: "wrap", children: entry.description })] }, entry.command))), _jsx(Text, { children: " " }), _jsx(Text, { children: "Keys" }), KEY_BINDINGS.map((entry) => (_jsxs(Box, { children: [_jsx(Box, { width: 11, flexShrink: 0, children: _jsx(Text, { children: ' ' + entry.command }) }), _jsx(Text, { dimColor: true, wrap: "wrap", children: entry.description })] }, entry.command))), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "Type anything else in plain English and press Enter - that's all you need." })] }), _jsx(Text, { dimColor: true, children: "Esc close" })] }));
14
14
  }
@@ -1,23 +1,93 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useRef, useState } from 'react';
3
- import { Text, useCursor, useInput, useStdout } from 'ink';
4
- import { runTurn } from '../agent/loop.js';
5
- import { answerApproval } from '../agent/permissions.js';
6
- import { useSession } from '../state/session.js';
3
+ import stringWidth from 'string-width';
4
+ import { Box, Text, useCursor, useInput, usePaste, useStdout } from 'ink';
5
+ import { runTurn, stopTurn } from '../agent/loop.js';
6
+ import { copySelection } from '../ink/selection.js';
7
+ import { pressCtrlCToQuit } from '../ink/quit.js';
8
+ import { answerApproval, currentApprovalTrustable } from '../agent/permissions.js';
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 { inputView, dropLastChar } from './input-layout.js';
12
+ import { inputLayout, splitTypedBurst, cleanPaste, scrollToShowCursor, previousWordStart, nextWordEnd } from './input-layout.js';
13
+ // The rows the input box needs for the text being typed (the window makes room).
14
+ export const MAX_INPUT_ROWS = 6;
15
+ export function inputRowsFor(text, width) {
16
+ return text ? inputLayout(text, width, MAX_INPUT_ROWS).rows.length : 1;
17
+ }
18
+ // Sends a message, then any sent while Jeeves was busy, one after another.
19
+ async function sendAndDrain(text) {
20
+ await runTurn(text);
21
+ for (let next = session.takeQueued(); next !== undefined; next = session.takeQueued()) {
22
+ await runTurn(next);
23
+ }
24
+ }
10
25
  export function Input({ scrollPage = 10, width = 76 }) {
11
- const [value, setValue] = useState('');
12
26
  // The text lives in a ref as well as state: the keystroke handler reads and
13
27
  // writes the ref, so two keys arriving before React re-renders never build on
14
28
  // a stale value.
15
- const valueRef = useRef('');
29
+ const valueRef = useRef(session.inputText);
16
30
  const s = useSession();
17
31
  const { stdout } = useStdout();
18
32
  const { setCursorPosition } = useCursor();
19
- const view = inputView(valueRef.current, width);
20
- const showingText = !s.approvalPending && s.transcriptScrollUp === 0;
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) => {
59
+ valueRef.current = text;
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);
90
+ };
21
91
  // The block cursor sits at the text insertion point: two columns in (the
22
92
  // border's │ and its padding space) plus the visible text's width, measured
23
93
  // with stringWidth so wide characters count. y is the input row, third from
@@ -26,8 +96,11 @@ export function Input({ scrollPage = 10, width = 76 }) {
26
96
  // hands the position to Ink in its own useInsertionEffect, which runs before
27
97
  // this commit's frame is written - a useLayoutEffect call runs after that and
28
98
  // only lands a frame late (measured: the cursor stayed hidden when the window
29
- // opened). Hidden while the row shows a hint instead of the text.
30
- setCursorPosition(showingText ? { x: 2 + view.width, y: inputFrameRow(stdout.rows ?? 24) } : undefined);
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).
101
+ // The last row of the box stays on the same terminal row however tall the box
102
+ // grows (it grows upwards), so the cursor's row is unchanged.
103
+ setCursorPosition(showingText ? { x: 2 + layout.cursorX, y: inputFrameRow(stdout.rows ?? 24) } : undefined);
31
104
  // The terminal's real cursor becomes a steady block for the whole session; it is
32
105
  // restored to the shell's default shape by the AlternateScreen exit paths.
33
106
  useEffect(() => {
@@ -48,10 +121,39 @@ export function Input({ scrollPage = 10, width = 76 }) {
48
121
  }
49
122
  if (s.pickerOpen || s.keysOpen || s.wizardActive || s.helpOpen)
50
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);
51
151
  if (s.approvalPending) {
52
152
  const answer = input.toLowerCase();
53
153
  if (answer === 'y')
54
154
  answerApproval(true);
155
+ else if (answer === 'a' && currentApprovalTrustable())
156
+ answerApproval(true, true);
55
157
  else if (answer === 'n')
56
158
  answerApproval(false);
57
159
  return;
@@ -64,6 +166,31 @@ export function Input({ scrollPage = 10, width = 76 }) {
64
166
  s.toggleShowLastReasoning();
65
167
  return;
66
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
+ }
67
194
  // The alternate screen has no native scrollback, so these keys scroll the
68
195
  // transcript region itself (Claude Code's bindings): arrows move 3 rows,
69
196
  // Page Up/Down a full page, End jumps back to the newest and re-follows.
@@ -87,32 +214,52 @@ export function Input({ scrollPage = 10, width = 76 }) {
87
214
  s.followTranscript();
88
215
  return;
89
216
  }
90
- // Anything typed while reading history returns to the newest first.
91
- if (key.return) {
217
+ // A burst of typing that ends with Enter is typing plus Enter (see splitTypedBurst).
218
+ const burst = key.return ? { typed: '', enter: true, rest: '' } : splitTypedBurst(input);
219
+ if (burst.enter) {
220
+ if (burst.typed)
221
+ insert(burst.typed);
92
222
  const text = valueRef.current.trim();
223
+ setValue(burst.rest);
224
+ if (!text)
225
+ return;
226
+ s.followTranscript();
93
227
  // /exit is honoured even mid-turn so a wedged request can never trap the user.
94
- if (text === '/exit' || (text && s.status !== 'working')) {
95
- s.followTranscript();
96
- valueRef.current = '';
97
- setValue('');
98
- void runTurn(text);
228
+ if (text === '/exit' || s.status !== 'working') {
229
+ void sendAndDrain(text);
230
+ }
231
+ else {
232
+ // Busy: the message waits its turn and is sent as soon as this job finishes.
233
+ session.queueMessage(text);
234
+ session.addNotice(`Noted - I'll read this as soon as I've finished: "${text.length > 80 ? text.slice(0, 79) + '…' : text}"`);
99
235
  }
100
236
  return;
101
237
  }
102
238
  if (key.backspace || key.delete) {
103
239
  s.followTranscript();
104
- valueRef.current = dropLastChar(valueRef.current);
105
- setValue(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);
106
246
  return;
107
247
  }
108
248
  if (!input || key.ctrl || key.meta)
109
249
  return;
110
250
  s.followTranscript();
111
- valueRef.current += input;
112
- setValue(valueRef.current);
251
+ insert(input);
252
+ });
253
+ // Pasted text arrives whole (bracketed paste), keeps its line breaks, and never
254
+ // sends by itself - only Enter does.
255
+ usePaste((text) => {
256
+ if (s.pickerOpen || s.keysOpen || s.wizardActive || s.helpOpen || s.approvalPending)
257
+ return;
258
+ s.followTranscript();
259
+ insert(cleanPaste(text));
113
260
  });
114
261
  if (s.approvalPending) {
115
- return _jsx(Text, { color: "yellow", children: "y = allow \u00B7 n = deny" });
262
+ return (_jsx(Text, { color: "yellow", children: currentApprovalTrustable() ? 'y = allow · a = always allow in this project · n = deny' : 'y = allow · n = deny' }));
116
263
  }
117
264
  // While the transcript is scrolled away from the newest, the prompt is replaced
118
265
  // by Claude Code's reading-history hint; End (or any typing) returns to the live
@@ -123,5 +270,15 @@ export function Input({ scrollPage = 10, width = 76 }) {
123
270
  // Trailing spaces are dimmed: invisible on screen, but it makes the frame's
124
271
  // bytes differ from the same text without them, keeping Ink on its full-frame
125
272
  // path (see input-layout.ts).
126
- return (_jsx(Text, { children: value ? (_jsxs(Text, { children: [view.text, view.trailingSpaces ? _jsx(Text, { dimColor: true, children: view.trailingSpaces }) : null] })) : (_jsx(Text, { dimColor: true, children: "ask anything" })) }));
273
+ if (!valueRef.current) {
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' });
275
+ }
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
+ }) }));
127
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);