@tianmucreations/jeeves 0.2.1 → 0.3.0

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 (55) hide show
  1. package/README.md +79 -18
  2. package/bin/jeeves +8 -1
  3. package/dist/agent/auto-ids.js +66 -0
  4. package/dist/agent/auto.js +178 -0
  5. package/dist/agent/context.js +55 -13
  6. package/dist/agent/errors.js +83 -22
  7. package/dist/agent/expert-chat.js +33 -0
  8. package/dist/agent/housekeeping.js +55 -0
  9. package/dist/agent/loop.js +168 -12
  10. package/dist/agent/permissions.js +167 -0
  11. package/dist/agent/research-gate.js +267 -0
  12. package/dist/agent/review.js +135 -0
  13. package/dist/agent/spending.js +73 -0
  14. package/dist/agent/systemPrompt.js +112 -0
  15. package/dist/app.js +25 -10
  16. package/dist/checkpoints/index.js +103 -0
  17. package/dist/checkpoints/store.js +239 -0
  18. package/dist/commands/address.js +5 -0
  19. package/dist/commands/clear.js +2 -0
  20. package/dist/commands/help.js +7 -4
  21. package/dist/commands/keys.js +1 -1
  22. package/dist/commands/verbose.js +1 -1
  23. package/dist/components/AddressPrompt.js +31 -0
  24. package/dist/components/Footer.js +74 -102
  25. package/dist/components/Input.js +76 -25
  26. package/dist/components/KeysManager.js +65 -20
  27. package/dist/components/ModelPicker.js +348 -75
  28. package/dist/components/ProjectPicker.js +4 -1
  29. package/dist/components/Transcript.js +29 -14
  30. package/dist/components/input-layout.js +34 -0
  31. package/dist/components/transcript-layout.js +13 -17
  32. package/dist/index.js +25 -7
  33. package/dist/ink/AlternateScreen.js +33 -16
  34. package/dist/ink/cursor.js +18 -0
  35. package/dist/ink/mouse.js +48 -0
  36. package/dist/keys/store.js +2 -1
  37. package/dist/models/registry.js +18 -2
  38. package/dist/platform/config.js +63 -7
  39. package/dist/providers/catalogue.js +293 -0
  40. package/dist/providers/direct-services.js +65 -0
  41. package/dist/providers/direct.js +145 -0
  42. package/dist/providers/index.js +123 -13
  43. package/dist/providers/models-snapshot.js +1037 -0
  44. package/dist/providers/ollama.js +21 -4
  45. package/dist/providers/openrouter.js +39 -4
  46. package/dist/providers/step-control.js +28 -0
  47. package/dist/providers/zai.js +31 -11
  48. package/dist/state/session.js +90 -36
  49. package/dist/state/today-spend.js +26 -0
  50. package/dist/tools/index.js +118 -10
  51. package/dist/tools/runBash.js +58 -11
  52. package/dist/tools/web/htmlToText.js +32 -0
  53. package/dist/tools/web/openrouterChat.js +31 -0
  54. package/dist/tools/web/research.js +191 -0
  55. package/package.json +32 -6
@@ -1,114 +1,86 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React from 'react';
3
- import { Box, Text } from 'ink';
4
- import { useSession, DEFAULT_CONTEXT_TOKENS } from '../state/session.js';
5
- import { UsageBar, usageFraction, usageColor, benefitColor } from './UsageBar.js';
3
+ import { Box, Text, useStdout } from 'ink';
4
+ import { useSession } from '../state/session.js';
5
+ import { allowanceToday } from '../agent/spending.js';
6
+ import { isAuto, workerModel } from '../agent/auto.js';
7
+ import { isDirectService, CUSTOM_SERVICE_ID } from '../providers/direct-services.js';
6
8
  export function shortModelName(model) {
7
9
  const short = model.split('/').pop();
8
10
  return short && short.length > 0 ? short : model;
9
11
  }
10
- export function compactNumber(value) {
11
- if (value >= 1_000_000)
12
- return `${(value / 1_000_000).toFixed(1)}M`;
13
- if (value >= 1000)
14
- return `${(value / 1000).toFixed(1)}k`;
15
- return `${Math.round(value)}`;
16
- }
17
- function pct(fraction) {
18
- return `${Math.round(fraction * 100)}%`;
19
- }
20
- function forecastContext(contextTokens, tokensPerMinute) {
21
- if (tokensPerMinute <= 0 || contextTokens <= 0)
22
- return '';
23
- const minutes = (DEFAULT_CONTEXT_TOKENS - contextTokens) / tokensPerMinute;
24
- if (minutes <= 0 || minutes > 600)
25
- return '';
26
- return `~${Math.max(1, Math.round(minutes))}m left`;
27
- }
28
- function forecastRateReset(resetEpochSeconds) {
29
- if (!resetEpochSeconds)
30
- return '';
31
- const seconds = resetEpochSeconds - Date.now() / 1000;
32
- if (seconds <= 0 || seconds > 3600)
33
- return '';
34
- return `resets in ${Math.max(1, Math.round(seconds / 60))}m`;
35
- }
36
- export function Footer() {
37
- const s = useSession();
38
- const contextTokens = s.estimateContextTokens();
39
- const tpm = s.tokensPerMinute();
40
- const cacheRate = s.cacheHitRate();
41
- if (s.footerExpanded !== null) {
42
- let bar = {
43
- label: 'context',
44
- value: contextTokens,
45
- max: DEFAULT_CONTEXT_TOKENS,
46
- suffix: forecastContext(contextTokens, tpm),
47
- };
48
- if (s.footerExpanded === 'session') {
49
- bar = { label: 'session', value: s.tokensIn + s.tokensOut, max: DEFAULT_CONTEXT_TOKENS, suffix: '' };
50
- }
51
- else if (s.footerExpanded === 'cache' && cacheRate !== null) {
52
- bar = {
53
- label: 'cache',
54
- value: s.tokensCached,
55
- max: s.tokensIn,
56
- unit: 'of input read from cache',
57
- suffix: '',
58
- goodWhenFull: true,
59
- };
60
- }
61
- else if (s.footerExpanded === 'today' && s.spend > 0 && s.creditLimit) {
62
- bar = { label: 'today', value: s.spend, max: s.creditLimit, suffix: '' };
63
- }
64
- else if (s.footerExpanded === 'credit' && s.creditRemaining !== null && s.creditLimit) {
65
- bar = {
66
- label: 'credit',
67
- value: s.creditLimit - s.creditRemaining,
68
- max: s.creditLimit,
69
- unit: 'used',
70
- suffix: `· $${s.creditRemaining.toFixed(2)} ${s.creditIsAccount ? 'left' : 'key cap'}`,
71
- };
72
- }
73
- else if (s.footerExpanded === 'speed' && s.rateLimit && s.rateLimit.limit > 0) {
74
- bar = {
75
- label: 'speed',
76
- value: s.rateLimit.limit - s.rateLimit.remaining,
77
- max: s.rateLimit.limit,
78
- suffix: forecastRateReset(s.rateLimit.reset),
79
- };
80
- }
81
- return (_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { dimColor: true, children: shortModelName(s.model) }), _jsxs(Text, { dimColor: true, children: [_jsx(UsageBar, { label: bar.label, value: bar.value, max: bar.max, unit: bar.unit, width: 20, goodWhenFull: bar.goodWhenFull }), bar.suffix ? _jsxs(Text, { dimColor: true, children: [" ", bar.suffix] }) : null] })] }));
12
+ const money = (value) => `$${value.toFixed(2)}`;
13
+ // The info bar says only what is worth a glance: which model is working (left),
14
+ // and on the right what today has cost and what is left - or, for a flat-rate
15
+ // plan, whether it has allowance. Warnings appear only when they matter.
16
+ export function footerSegments(info) {
17
+ const busy = info.busyNote ?? (info.tidying ? 'tidying up…' : null);
18
+ const direct = isDirectService(info.providerId);
19
+ if (busy && info.providerId !== 'openrouter' && !direct)
20
+ return [{ text: busy }];
21
+ if (info.providerId === 'zai') {
22
+ if (info.planResetAt === null)
23
+ return [{ text: 'flat-rate plan' }];
24
+ const when = info.planResetAt ? ` · resets @ ${info.planResetAt}` : '';
25
+ return [{ text: `plan used up${when}`, color: 'red' }];
82
26
  }
83
- // The compact view shows every metric at all times - nothing needs a key press.
84
- const sessionFraction = usageFraction(s.tokensIn + s.tokensOut, DEFAULT_CONTEXT_TOKENS);
85
- const contextFraction = usageFraction(contextTokens, DEFAULT_CONTEXT_TOKENS);
27
+ if (info.providerId === 'ollama') {
28
+ return [{ text: 'on this computer · free' }];
29
+ }
30
+ // A compatible service lists no prices, so its spending can't be worked out.
31
+ if (info.providerId === CUSTOM_SERVICE_ID) {
32
+ return [{ text: 'cost not tracked' }];
33
+ }
34
+ const spent = info.todaySpend ?? 0;
35
+ // Compared in whole cents, so $2.40 of $3.00 is exactly 80%.
36
+ const cents = Math.round(spent * 100);
37
+ const limitCents = Math.round(info.allowance * 100);
86
38
  const segments = [
87
39
  {
88
- key: 'session',
89
- render: _jsxs(Text, { color: usageColor(sessionFraction), children: ["sess ", pct(sessionFraction)] }),
90
- },
91
- {
92
- key: 'context',
93
- render: _jsxs(Text, { color: usageColor(contextFraction), children: ["ctx ", pct(contextFraction)] }),
94
- },
95
- {
96
- key: 'cache',
97
- render: cacheRate === null ? (_jsx(Text, { children: "cache \u2014" })) : (_jsxs(Text, { color: benefitColor(cacheRate), children: ["cache ", pct(cacheRate)] })),
98
- },
99
- {
100
- key: 'today',
101
- render: _jsxs(Text, { children: ["today $", s.spend.toFixed(2)] }),
102
- },
103
- {
104
- key: 'credit',
105
- render: (_jsx(Text, { children: s.creditRemaining !== null
106
- ? s.creditIsAccount
107
- ? `$${s.creditRemaining.toFixed(2)} left`
108
- : `$${s.creditRemaining.toFixed(2)} key cap`
109
- : '$— left' })),
40
+ // "~": worked out from the company's price list, not reported by it.
41
+ text: `today ${direct ? '~' : ''}${info.todaySpend === null ? '$—' : money(spent)} of ${money(info.allowance)}`,
42
+ color: cents >= limitCents ? 'red' : cents * 10 >= limitCents * 8 ? 'yellow' : undefined,
110
43
  },
111
44
  ];
112
- const visible = segments.filter((segment) => !s.hiddenMetrics.includes(segment.key));
113
- return (_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { dimColor: true, children: shortModelName(s.model) }), _jsxs(Text, { dimColor: true, children: [visible.map((segment, index) => (_jsxs(React.Fragment, { children: [index > 0 ? ' · ' : null, segment.render] }, segment.key))), visible.length > 0 ? ' · ' : null, _jsxs(Text, { children: [compactNumber(tpm), " tpm"] })] })] }));
45
+ if (busy)
46
+ segments.unshift({ text: busy });
47
+ // The companies don't tell a key what credit is left.
48
+ if (direct)
49
+ return segments;
50
+ if (info.creditRemaining === null) {
51
+ segments.push({ text: '$— left' });
52
+ return segments;
53
+ }
54
+ const remaining = info.creditRemaining;
55
+ const color = remaining < 1 ? 'red' : remaining < 5 ? 'yellow' : undefined;
56
+ segments.push({ text: `${money(remaining)} ${info.creditIsAccount ? 'left' : 'key limit'}`, color });
57
+ if (remaining < 1)
58
+ segments.push({ text: 'credit low - top up', color: 'red' });
59
+ return segments;
60
+ }
61
+ // The model name gives way first so the right side never wraps the bar.
62
+ export function fitModelName(name, available) {
63
+ if (available <= 1)
64
+ return '';
65
+ return name.length <= available ? name : name.slice(0, available - 1) + '…';
66
+ }
67
+ export function Footer() {
68
+ const s = useSession();
69
+ const { stdout } = useStdout();
70
+ const columns = Math.max(stdout.columns ?? 80, 40);
71
+ const segments = footerSegments({
72
+ providerId: s.providerId,
73
+ allowance: allowanceToday(),
74
+ tidying: s.tidying,
75
+ busyNote: s.busyNote,
76
+ todaySpend: s.todaySpend,
77
+ creditRemaining: s.creditRemaining,
78
+ creditIsAccount: s.creditIsAccount,
79
+ planResetAt: s.planResetAt,
80
+ });
81
+ const rightWidth = segments.reduce((sum, segment) => sum + segment.text.length, 0) + 3 * (segments.length - 1);
82
+ // In Auto mode the bar names the model actually working: "auto · deepseek-v4-flash-0731".
83
+ const name = isAuto(s.model) ? `auto · ${shortModelName(s.activeModel ?? workerModel())}` : shortModelName(s.model);
84
+ const model = fitModelName(name, columns - rightWidth - 2);
85
+ return (_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { children: model }), _jsx(Text, { children: segments.map((segment, index) => (_jsxs(React.Fragment, { children: [index > 0 ? _jsx(Text, { dimColor: true, children: " \u00B7 " }) : null, _jsx(Text, { color: segment.color, dimColor: !segment.color, children: segment.text })] }, segment.text))) })] }));
114
86
  }
@@ -1,13 +1,51 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState } from 'react';
3
- import { Text, useInput } from 'ink';
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { Text, useCursor, useInput, useStdout } from 'ink';
4
4
  import { runTurn } from '../agent/loop.js';
5
5
  import { answerApproval } from '../agent/permissions.js';
6
6
  import { useSession } from '../state/session.js';
7
- export function Input({ scrollPage = 10 }) {
7
+ import { BLOCK_CURSOR, inputFrameRow } from '../ink/cursor.js';
8
+ import { isMouseSequence, handleMouseInput } from '../ink/mouse.js';
9
+ import { inputView, dropLastChar } from './input-layout.js';
10
+ export function Input({ scrollPage = 10, width = 76 }) {
8
11
  const [value, setValue] = useState('');
12
+ // The text lives in a ref as well as state: the keystroke handler reads and
13
+ // writes the ref, so two keys arriving before React re-renders never build on
14
+ // a stale value.
15
+ const valueRef = useRef('');
9
16
  const s = useSession();
17
+ const { stdout } = useStdout();
18
+ const { setCursorPosition } = useCursor();
19
+ const view = inputView(valueRef.current, width);
20
+ const showingText = !s.approvalPending && s.transcriptScrollUp === 0;
21
+ // The block cursor sits at the text insertion point: two columns in (the
22
+ // border's │ and its padding space) plus the visible text's width, measured
23
+ // with stringWidth so wide characters count. y is the input row, third from
24
+ // the bottom (info bar, bottom border, input); inputFrameRow carries the +1
25
+ // Ink's fullscreen frames need. Set during render, as Ink documents: useCursor
26
+ // hands the position to Ink in its own useInsertionEffect, which runs before
27
+ // this commit's frame is written - a useLayoutEffect call runs after that and
28
+ // 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);
31
+ // The terminal's real cursor becomes a steady block for the whole session; it is
32
+ // restored to the shell's default shape by the AlternateScreen exit paths.
33
+ useEffect(() => {
34
+ try {
35
+ process.stdout.write(BLOCK_CURSOR);
36
+ }
37
+ catch {
38
+ // A closed stream must never crash the app.
39
+ }
40
+ }, []);
10
41
  useInput((input, key) => {
42
+ // SGR mouse events arrive as CSI chunks Ink cannot resolve; the wheel
43
+ // scrolls the transcript and every other mouse event is consumed here so
44
+ // none of it ever lands in the text.
45
+ if (isMouseSequence(input)) {
46
+ handleMouseInput(input);
47
+ return;
48
+ }
11
49
  if (s.pickerOpen || s.keysOpen || s.wizardActive || s.helpOpen)
12
50
  return;
13
51
  if (s.approvalPending) {
@@ -26,25 +64,9 @@ export function Input({ scrollPage = 10 }) {
26
64
  s.toggleShowLastReasoning();
27
65
  return;
28
66
  }
29
- if (key.tab) {
30
- s.tabFooter();
31
- return;
32
- }
33
- if (key.escape) {
34
- s.escapeFooter();
35
- return;
36
- }
37
- if (key.return) {
38
- const text = value.trim();
39
- if (text && s.status !== 'working') {
40
- setValue('');
41
- s.followTranscript();
42
- void runTurn(text);
43
- }
44
- return;
45
- }
46
- // The terminal's own scrollback is off (alternate screen), so the arrow and
47
- // page keys scroll the transcript region instead, three lines per press.
67
+ // The alternate screen has no native scrollback, so these keys scroll the
68
+ // transcript region itself (Claude Code's bindings): arrows move 3 rows,
69
+ // Page Up/Down a full page, End jumps back to the newest and re-follows.
48
70
  if (key.upArrow) {
49
71
  s.scrollTranscript(3);
50
72
  return;
@@ -61,16 +83,45 @@ export function Input({ scrollPage = 10 }) {
61
83
  s.scrollTranscript(-scrollPage);
62
84
  return;
63
85
  }
86
+ if (key.end) {
87
+ s.followTranscript();
88
+ return;
89
+ }
90
+ // Anything typed while reading history returns to the newest first.
91
+ if (key.return) {
92
+ const text = valueRef.current.trim();
93
+ // /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);
99
+ }
100
+ return;
101
+ }
64
102
  if (key.backspace || key.delete) {
65
- setValue((v) => v.slice(0, -1));
103
+ s.followTranscript();
104
+ valueRef.current = dropLastChar(valueRef.current);
105
+ setValue(valueRef.current);
66
106
  return;
67
107
  }
68
108
  if (!input || key.ctrl || key.meta)
69
109
  return;
70
- setValue((v) => v + input);
110
+ s.followTranscript();
111
+ valueRef.current += input;
112
+ setValue(valueRef.current);
71
113
  });
72
114
  if (s.approvalPending) {
73
115
  return _jsx(Text, { color: "yellow", children: "y = allow \u00B7 n = deny" });
74
116
  }
75
- return (_jsxs(Text, { children: [_jsx(Text, { bold: true, children: '> ' }), value ? _jsx(Text, { children: value }) : _jsx(Text, { dimColor: true, children: "ask anything" })] }));
117
+ // While the transcript is scrolled away from the newest, the prompt is replaced
118
+ // by Claude Code's reading-history hint; End (or any typing) returns to the live
119
+ // conversation.
120
+ if (s.transcriptScrollUp > 0) {
121
+ return _jsx(Text, { dimColor: true, children: "reading history \u2014 press End to return" });
122
+ }
123
+ // Trailing spaces are dimmed: invisible on screen, but it makes the frame's
124
+ // bytes differ from the same text without them, keeping Ink on its full-frame
125
+ // 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" })) }));
76
127
  }
@@ -2,16 +2,18 @@ import { 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';
5
- import { PROVIDER_ROWS, storeOpenRouterKey, removeOpenRouterKey, storeZaiKey, removeZaiKey, storedKeyProviders, getKeySource, refreshCredit, } from '../providers/index.js';
5
+ import { PROVIDER_ROWS, storeOpenRouterKey, removeOpenRouterKey, storeZaiKey, removeZaiKey, storedKeyProviders, getKeySource, refreshCredit, storeDirectKey, removeServiceKey, customServiceName, } from '../providers/index.js';
6
+ import { directService, isDirectService, CUSTOM_SERVICE_ID } from '../providers/direct-services.js';
7
+ import { everydayModel } from '../providers/catalogue.js';
8
+ import { setDefaultModel, setDefaultProvider } from '../platform/config.js';
6
9
  import { setKey, deleteKey } from '../keys/store.js';
7
10
  import { keyLooksValid } from '../commands/keys.js';
8
- // The keys screen shows one extra row the model picker does not: the optional
9
- // OpenRouter management key, which unlocks the real account balance.
10
- const KEY_ROWS = [
11
- PROVIDER_ROWS[0],
12
- { id: 'openrouter-management', label: 'OpenRouter (account)' },
13
- ...PROVIDER_ROWS.slice(1),
14
- ];
11
+ import { isMouseSequence } from '../ink/mouse.js';
12
+ // Every service Jeeves connects to. The optional OpenRouter management key (it unlocks
13
+ // the real account balance) is for /keys only, and the compatible service needs its
14
+ // address too, so it is set up in /model - the first-run wizard keeps to the essentials.
15
+ const KEY_ROWS = [PROVIDER_ROWS[0], { id: 'openrouter-management', label: 'OpenRouter account' }, ...PROVIDER_ROWS.slice(1)];
16
+ const WIZARD_ROWS = KEY_ROWS.filter((row) => row.id !== 'openrouter-management' && row.id !== CUSTOM_SERVICE_ID);
15
17
  function rowLabel(id) {
16
18
  return KEY_ROWS.find((row) => row.id === id)?.label ?? id;
17
19
  }
@@ -19,6 +21,7 @@ export function KeysManager({ mode, rows, columns }) {
19
21
  const s = useSession();
20
22
  const [phase, setPhase] = useState(mode === 'wizard' ? { kind: 'ask' } : { kind: 'list' });
21
23
  const [cursor, setCursor] = useState(0);
24
+ const visibleRows = mode === 'wizard' ? WIZARD_ROWS : KEY_ROWS;
22
25
  const [hidden, setHidden] = useState('');
23
26
  const [stored, setStored] = useState([]);
24
27
  const [note, setNote] = useState('');
@@ -33,7 +36,7 @@ export function KeysManager({ mode, rows, columns }) {
33
36
  if (getKeySource() === 'keychain')
34
37
  return 'key stored in your Mac keychain';
35
38
  if (getKeySource() === 'env')
36
- return 'key in the .env development file';
39
+ return 'key in a local file - add it with /keys to store it safely';
37
40
  return stored.includes('openrouter') ? 'key stored in your Mac keychain' : 'no key';
38
41
  }
39
42
  if (rowId === 'openrouter-management') {
@@ -45,8 +48,11 @@ export function KeysManager({ mode, rows, columns }) {
45
48
  return stored.includes('zai') ? 'key stored - GLM Coding Plan ready' : 'no key - add one to use the flat plan';
46
49
  }
47
50
  if (rowId === 'ollama')
48
- return 'local - no key needed';
49
- return stored.includes(rowId) ? 'key saved - direct connection coming' : 'add key with /keys';
51
+ return 'runs on this computer - no key needed';
52
+ if (rowId === CUSTOM_SERVICE_ID) {
53
+ return stored.includes(rowId) ? `${customServiceName()} - address and key stored` : 'add it in /model, under Other service';
54
+ }
55
+ return stored.includes(rowId) ? 'key stored - direct connection ready' : 'no key';
50
56
  }
51
57
  function finishWizard(message, connected) {
52
58
  if (connected) {
@@ -93,6 +99,37 @@ export function KeysManager({ mode, rows, columns }) {
93
99
  }
94
100
  return;
95
101
  }
102
+ if (isDirectService(provider)) {
103
+ const label = directService(provider).label;
104
+ setNote(`Checking the key with ${label}…`);
105
+ const result = await storeDirectKey(provider, key);
106
+ setNote('');
107
+ if (result === 'rejected') {
108
+ setNote(`${label} didn't accept that key - press Enter to paste it again.`);
109
+ return;
110
+ }
111
+ if (result === 'keychain') {
112
+ setNote('The Mac keychain was not reachable - press Enter and try again.');
113
+ return;
114
+ }
115
+ refreshStored();
116
+ const unchecked = result === 'saved-unchecked' ? ` ${label} couldn't be reached to check it just now.` : '';
117
+ if (mode === 'wizard') {
118
+ // First launch: start straight away on the company's everyday model.
119
+ const model = await everydayModel(provider, key);
120
+ if (model) {
121
+ session.setProvider(provider);
122
+ session.setModel(model);
123
+ setDefaultProvider(provider);
124
+ setDefaultModel(model);
125
+ }
126
+ finishWizard(`Your ${label} key is saved.${unchecked} Jeeves will use ${model ?? 'its everyday model'} - type /model to change.`, true);
127
+ }
128
+ else {
129
+ setPhase({ kind: 'saved', message: `Your ${label} key is saved.${unchecked} Choose ${label} in /model to use it.` });
130
+ }
131
+ return;
132
+ }
96
133
  const saved = await setKey(provider, key);
97
134
  if (!saved) {
98
135
  setNote('The Mac keychain was not reachable - press Enter and try again.');
@@ -123,7 +160,7 @@ export function KeysManager({ mode, rows, columns }) {
123
160
  const fallback = await removeOpenRouterKey();
124
161
  refreshStored();
125
162
  const message = fallback === 'env'
126
- ? 'The key was removed from your keychain - the .env development key is still active.'
163
+ ? 'The key was removed from your keychain - a key in a local file is still being used.'
127
164
  : 'The key was removed. You are signed out until a new key is added.';
128
165
  if (fallback !== 'env')
129
166
  session.setStatus('disconnected');
@@ -140,14 +177,18 @@ export function KeysManager({ mode, rows, columns }) {
140
177
  await deleteKey(provider);
141
178
  refreshStored();
142
179
  void refreshCredit();
143
- setPhase({ kind: 'saved', message: 'The management key was removed - the info bar shows the key cap again.' });
180
+ setPhase({ kind: 'saved', message: "The management key was removed - the info bar shows the key's spending limit again." });
144
181
  return;
145
182
  }
146
- await deleteKey(provider);
183
+ await removeServiceKey(provider);
147
184
  refreshStored();
185
+ if (session.providerId === provider)
186
+ session.setStatus('disconnected');
148
187
  setPhase({ kind: 'saved', message: `The saved key for ${rowLabel(provider)} was removed.` });
149
188
  }
150
189
  useInput((input, key) => {
190
+ if (isMouseSequence(input))
191
+ return;
151
192
  if (phase.kind === 'ask') {
152
193
  const answer = input.toLowerCase();
153
194
  if (answer === 'y') {
@@ -226,14 +267,18 @@ export function KeysManager({ mode, rows, columns }) {
226
267
  return;
227
268
  }
228
269
  if (key.downArrow) {
229
- setCursor((current) => Math.min(KEY_ROWS.length - 1, current + 1));
270
+ setCursor((current) => Math.min(visibleRows.length - 1, current + 1));
230
271
  return;
231
272
  }
232
273
  if (key.return) {
233
- const row = KEY_ROWS[cursor];
274
+ const row = visibleRows[cursor];
275
+ if (row?.id === CUSTOM_SERVICE_ID) {
276
+ setNote('Add it in /model, under Other service - it needs a web address as well as a key.');
277
+ return;
278
+ }
234
279
  if (!row || row.id === 'ollama') {
235
280
  if (mode === 'wizard' && row?.id === 'ollama') {
236
- finishWizard('Ollama runs locally - no key needed.', false);
281
+ finishWizard('Ollama runs on this computer - no key needed.', false);
237
282
  }
238
283
  return;
239
284
  }
@@ -244,7 +289,7 @@ export function KeysManager({ mode, rows, columns }) {
244
289
  }
245
290
  const answer = input.toLowerCase();
246
291
  if (answer === 'd') {
247
- const row = KEY_ROWS[cursor];
292
+ const row = visibleRows[cursor];
248
293
  if (!row)
249
294
  return;
250
295
  const hasStored = stored.includes(row.id) || (row.id === 'openrouter' && getKeySource() !== null);
@@ -263,7 +308,7 @@ export function KeysManager({ mode, rows, columns }) {
263
308
  : phase.kind === 'saved'
264
309
  ? 'Saved'
265
310
  : mode === 'wizard'
266
- ? 'Which provider should provide your AI?'
311
+ ? 'Which AI service should do the thinking?'
267
312
  : 'Keys - stored in your Mac keychain';
268
313
  const hint = phase.kind === 'ask'
269
314
  ? 'y = yes · n = not now'
@@ -277,5 +322,5 @@ export function KeysManager({ mode, rows, columns }) {
277
322
  ? '↑↓ move · Enter choose · Esc back'
278
323
  : '↑↓ move · Enter add or replace · d remove · Esc close';
279
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' &&
280
- KEY_ROWS.map((row, index) => (_jsxs(Text, { inverse: index === cursor, children: [`${row.label}`.padEnd(14), _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? ", _jsxs(Text, { dimColor: true, children: ["(", hint, ")"] })] })), phase.kind === 'saved' && _jsx(Text, { children: phase.message })] }), note ? (_jsx(Text, { color: "yellow", children: note })) : (_jsx(Text, { dimColor: true, children: hint }))] }));
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 }))] }));
281
326
  }