@tianmucreations/jeeves 0.2.1 → 0.3.1

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 (57) hide show
  1. package/LICENSE +37 -17
  2. package/README.md +82 -18
  3. package/bin/jeeves +8 -1
  4. package/dist/agent/auto-ids.js +66 -0
  5. package/dist/agent/auto.js +178 -0
  6. package/dist/agent/context.js +55 -13
  7. package/dist/agent/errors.js +83 -22
  8. package/dist/agent/expert-chat.js +33 -0
  9. package/dist/agent/housekeeping.js +55 -0
  10. package/dist/agent/loop.js +174 -12
  11. package/dist/agent/permissions.js +186 -3
  12. package/dist/agent/research-gate.js +267 -0
  13. package/dist/agent/review.js +135 -0
  14. package/dist/agent/spending.js +73 -0
  15. package/dist/agent/systemPrompt.js +112 -0
  16. package/dist/agent/trust.js +29 -0
  17. package/dist/app.js +31 -11
  18. package/dist/checkpoints/index.js +103 -0
  19. package/dist/checkpoints/store.js +239 -0
  20. package/dist/commands/address.js +5 -0
  21. package/dist/commands/clear.js +2 -0
  22. package/dist/commands/help.js +8 -4
  23. package/dist/commands/keys.js +1 -1
  24. package/dist/commands/verbose.js +1 -1
  25. package/dist/components/AddressPrompt.js +31 -0
  26. package/dist/components/Footer.js +74 -102
  27. package/dist/components/Input.js +115 -29
  28. package/dist/components/KeysManager.js +65 -20
  29. package/dist/components/ModelPicker.js +348 -75
  30. package/dist/components/ProjectPicker.js +4 -1
  31. package/dist/components/Transcript.js +29 -14
  32. package/dist/components/input-layout.js +92 -0
  33. package/dist/components/transcript-layout.js +27 -19
  34. package/dist/index.js +25 -7
  35. package/dist/ink/AlternateScreen.js +33 -16
  36. package/dist/ink/cursor.js +18 -0
  37. package/dist/ink/mouse.js +48 -0
  38. package/dist/keys/store.js +2 -1
  39. package/dist/models/registry.js +18 -2
  40. package/dist/platform/config.js +71 -7
  41. package/dist/providers/catalogue.js +293 -0
  42. package/dist/providers/direct-services.js +65 -0
  43. package/dist/providers/direct.js +145 -0
  44. package/dist/providers/index.js +123 -13
  45. package/dist/providers/models-snapshot.js +1037 -0
  46. package/dist/providers/ollama.js +21 -4
  47. package/dist/providers/openrouter.js +39 -4
  48. package/dist/providers/step-control.js +28 -0
  49. package/dist/providers/zai.js +31 -11
  50. package/dist/state/session.js +110 -36
  51. package/dist/state/today-spend.js +26 -0
  52. package/dist/tools/index.js +123 -11
  53. package/dist/tools/runBash.js +58 -11
  54. package/dist/tools/web/htmlToText.js +32 -0
  55. package/dist/tools/web/openrouterChat.js +31 -0
  56. package/dist/tools/web/research.js +191 -0
  57. package/package.json +33 -7
@@ -1,9 +1,11 @@
1
1
  import { session } from '../state/session.js';
2
2
  import { resetStickySession } from '../providers/openrouter.js';
3
+ import { resetResearchGate } from '../agent/research-gate.js';
3
4
  // Starts fresh: wipes the screen and the conversation the model remembers.
4
5
  export function clearConversation() {
5
6
  session.clearTranscript();
6
7
  session.setHistory([]);
7
8
  session.setLastReasoning('');
8
9
  resetStickySession();
10
+ resetResearchGate();
9
11
  }
@@ -1,8 +1,11 @@
1
1
  export const COMMANDS = [
2
2
  { command: '/help', description: 'show this list' },
3
3
  { command: '/model', description: 'pick a different AI model' },
4
- { command: '/keys', description: 'add or remove API keys' },
5
- { command: '/verbose', description: "show the model's thinking on screen" },
4
+ { command: '/keys', description: 'add or remove keys' },
5
+ { command: '/address', description: 'change how Jeeves addresses you' },
6
+ { command: '/verbose', description: "show the model's thinking and technical details" },
7
+ { command: '/undo', description: 'put the project folder back to how it was before the last change' },
8
+ { command: '/ask', description: 'ask before every change in this project folder again (after "always allow")' },
6
9
  { command: '/clear', description: 'start a fresh conversation and clear the screen' },
7
10
  { command: '/exit', description: 'quit' },
8
11
  ];
@@ -10,8 +13,9 @@ export const KEY_BINDINGS = [
10
13
  { command: 'up down', description: 'move in lists' },
11
14
  { command: 'Enter', description: 'select' },
12
15
  { command: 'Esc', description: 'go back' },
13
- { command: 'Tab', description: 'zoom a footer bar' },
14
16
  { command: 'Ctrl+R', description: "show the model's last thinking" },
15
17
  { command: 'y / n', description: 'allow or deny a permission request' },
16
- { command: 'arrows', description: 'scroll the conversation up and down; Page Up / Page Down jump a whole screen' },
18
+ { command: 'arrows', description: 'scroll the conversation up and down; Page Up / Page Down jump a whole screen; End returns to the newest' },
19
+ { command: 'wheel', description: 'the trackpad or mouse wheel scrolls the conversation too' },
20
+ { command: 'copying', description: 'hold Fn and drag to select text (Option in iTerm2, Shift elsewhere)' },
17
21
  ];
@@ -10,6 +10,6 @@ export function describeKeySource(source) {
10
10
  if (source === 'keychain')
11
11
  return 'key stored in your Mac keychain';
12
12
  if (source === 'env')
13
- return 'key in the .env development file';
13
+ return 'key in a local file - add it with /keys to store it safely';
14
14
  return 'no key';
15
15
  }
@@ -4,5 +4,5 @@ import { setVerbosePreference } from '../platform/config.js';
4
4
  export function toggleVerbose() {
5
5
  session.setVerbose(!session.verbose);
6
6
  setVerbosePreference(session.verbose);
7
- return session.verbose ? 'Verbose reasoning: on' : 'Verbose reasoning: off';
7
+ return session.verbose ? "Showing the model's thinking and technical details: on" : "Showing the model's thinking and technical details: off";
8
8
  }
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import { session } from '../state/session.js';
5
+ import { getAddress, setAddress } from '../platform/config.js';
6
+ import { isMouseSequence } from '../ink/mouse.js';
7
+ // The first-launch question, in Jeeves' own voice, asked once before the project
8
+ // picker whenever no address is saved; /address reopens the same screen later.
9
+ export function AddressPrompt({ rows }) {
10
+ const [value, setValue] = useState('');
11
+ const firstRun = session.launchStage === 'address';
12
+ useInput((input, key) => {
13
+ if (isMouseSequence(input))
14
+ return;
15
+ if (key.return) {
16
+ const address = value.trim() || getAddress() || 'Sir';
17
+ setAddress(address.slice(0, 30));
18
+ session.addressDone();
19
+ session.addNotice(`Very good - I shall address you as ${address.slice(0, 30)}.`);
20
+ return;
21
+ }
22
+ if (key.backspace || key.delete) {
23
+ setValue((v) => v.slice(0, -1));
24
+ return;
25
+ }
26
+ if (!input || key.ctrl || key.meta)
27
+ return;
28
+ setValue((v) => (v.length >= 30 ? v : v + input));
29
+ });
30
+ return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsx(Text, { dimColor: true, children: "Good evening. Before we begin \u2014 how shall I address you? Sir, Madam, or something else?" }), _jsx(Box, { flexGrow: 1, justifyContent: "center", flexDirection: "column", minHeight: 1, children: _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "Address: " }), _jsx(Text, { children: value }), _jsx(Text, { inverse: true, children: " " })] }) }), firstRun ? _jsx(Text, { dimColor: true, children: "type your title \u00B7 Enter save" }) : _jsx(Text, { dimColor: true, children: "type a new title \u00B7 Enter save \u00B7 unchanged keeps the current one" })] }));
31
+ }
@@ -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,19 +1,76 @@
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 } from 'react';
3
+ import { Box, Text, useCursor, useInput, usePaste, useStdout } from 'ink';
4
4
  import { runTurn } from '../agent/loop.js';
5
- import { answerApproval } from '../agent/permissions.js';
6
- import { useSession } from '../state/session.js';
7
- export function Input({ scrollPage = 10 }) {
8
- const [value, setValue] = useState('');
5
+ import { answerApproval, currentApprovalTrustable } from '../agent/permissions.js';
6
+ import { session, useSession } from '../state/session.js';
7
+ import { BLOCK_CURSOR, inputFrameRow } from '../ink/cursor.js';
8
+ import { isMouseSequence, handleMouseInput } from '../ink/mouse.js';
9
+ import { inputLayout, dropLastChar, splitTypedBurst, cleanPaste } from './input-layout.js';
10
+ // The rows the input box needs for the text being typed (the window makes room).
11
+ export const MAX_INPUT_ROWS = 6;
12
+ export function inputRowsFor(text, width) {
13
+ return text ? inputLayout(text, width, MAX_INPUT_ROWS).rows.length : 1;
14
+ }
15
+ // Sends a message, then any sent while Jeeves was busy, one after another.
16
+ async function sendAndDrain(text) {
17
+ await runTurn(text);
18
+ for (let next = session.takeQueued(); next !== undefined; next = session.takeQueued()) {
19
+ await runTurn(next);
20
+ }
21
+ }
22
+ export function Input({ scrollPage = 10, width = 76 }) {
23
+ // The text lives in a ref as well as state: the keystroke handler reads and
24
+ // writes the ref, so two keys arriving before React re-renders never build on
25
+ // a stale value.
26
+ const valueRef = useRef(session.inputText);
9
27
  const s = useSession();
28
+ const { stdout } = useStdout();
29
+ 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
+ valueRef.current = text;
34
+ session.setInputText(text);
35
+ };
36
+ // The block cursor sits at the text insertion point: two columns in (the
37
+ // border's │ and its padding space) plus the visible text's width, measured
38
+ // with stringWidth so wide characters count. y is the input row, third from
39
+ // the bottom (info bar, bottom border, input); inputFrameRow carries the +1
40
+ // Ink's fullscreen frames need. Set during render, as Ink documents: useCursor
41
+ // hands the position to Ink in its own useInsertionEffect, which runs before
42
+ // this commit's frame is written - a useLayoutEffect call runs after that and
43
+ // 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.
45
+ // The last row of the box stays on the same terminal row however tall the box
46
+ // grows (it grows upwards), so the cursor's row is unchanged.
47
+ setCursorPosition(showingText ? { x: 2 + layout.cursorX, y: inputFrameRow(stdout.rows ?? 24) } : undefined);
48
+ // The terminal's real cursor becomes a steady block for the whole session; it is
49
+ // restored to the shell's default shape by the AlternateScreen exit paths.
50
+ useEffect(() => {
51
+ try {
52
+ process.stdout.write(BLOCK_CURSOR);
53
+ }
54
+ catch {
55
+ // A closed stream must never crash the app.
56
+ }
57
+ }, []);
10
58
  useInput((input, key) => {
59
+ // SGR mouse events arrive as CSI chunks Ink cannot resolve; the wheel
60
+ // scrolls the transcript and every other mouse event is consumed here so
61
+ // none of it ever lands in the text.
62
+ if (isMouseSequence(input)) {
63
+ handleMouseInput(input);
64
+ return;
65
+ }
11
66
  if (s.pickerOpen || s.keysOpen || s.wizardActive || s.helpOpen)
12
67
  return;
13
68
  if (s.approvalPending) {
14
69
  const answer = input.toLowerCase();
15
70
  if (answer === 'y')
16
71
  answerApproval(true);
72
+ else if (answer === 'a' && currentApprovalTrustable())
73
+ answerApproval(true, true);
17
74
  else if (answer === 'n')
18
75
  answerApproval(false);
19
76
  return;
@@ -26,25 +83,9 @@ export function Input({ scrollPage = 10 }) {
26
83
  s.toggleShowLastReasoning();
27
84
  return;
28
85
  }
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.
86
+ // The alternate screen has no native scrollback, so these keys scroll the
87
+ // transcript region itself (Claude Code's bindings): arrows move 3 rows,
88
+ // Page Up/Down a full page, End jumps back to the newest and re-follows.
48
89
  if (key.upArrow) {
49
90
  s.scrollTranscript(3);
50
91
  return;
@@ -61,16 +102,61 @@ export function Input({ scrollPage = 10 }) {
61
102
  s.scrollTranscript(-scrollPage);
62
103
  return;
63
104
  }
105
+ if (key.end) {
106
+ s.followTranscript();
107
+ return;
108
+ }
109
+ // A burst of typing that ends with Enter is typing plus Enter (see splitTypedBurst).
110
+ const burst = key.return ? { typed: '', enter: true, rest: '' } : splitTypedBurst(input);
111
+ if (burst.enter) {
112
+ const text = (valueRef.current + burst.typed).trim();
113
+ setValue(burst.rest);
114
+ if (!text)
115
+ return;
116
+ s.followTranscript();
117
+ // /exit is honoured even mid-turn so a wedged request can never trap the user.
118
+ if (text === '/exit' || s.status !== 'working') {
119
+ void sendAndDrain(text);
120
+ }
121
+ else {
122
+ // Busy: the message waits its turn and is sent as soon as this job finishes.
123
+ session.queueMessage(text);
124
+ session.addNotice(`Noted - I'll read this as soon as I've finished: "${text.length > 80 ? text.slice(0, 79) + '…' : text}"`);
125
+ }
126
+ return;
127
+ }
64
128
  if (key.backspace || key.delete) {
65
- setValue((v) => v.slice(0, -1));
129
+ s.followTranscript();
130
+ setValue(dropLastChar(valueRef.current));
66
131
  return;
67
132
  }
68
133
  if (!input || key.ctrl || key.meta)
69
134
  return;
70
- setValue((v) => v + input);
135
+ s.followTranscript();
136
+ setValue(valueRef.current + input);
137
+ });
138
+ // Pasted text arrives whole (bracketed paste), keeps its line breaks, and never
139
+ // sends by itself - only Enter does.
140
+ usePaste((text) => {
141
+ if (s.pickerOpen || s.keysOpen || s.wizardActive || s.helpOpen || s.approvalPending)
142
+ return;
143
+ s.followTranscript();
144
+ setValue(valueRef.current + cleanPaste(text));
71
145
  });
72
146
  if (s.approvalPending) {
73
- return _jsx(Text, { color: "yellow", children: "y = allow \u00B7 n = deny" });
147
+ return (_jsx(Text, { color: "yellow", children: currentApprovalTrustable() ? 'y = allow · a = always allow in this project · n = deny' : 'y = allow · n = deny' }));
148
+ }
149
+ // While the transcript is scrolled away from the newest, the prompt is replaced
150
+ // by Claude Code's reading-history hint; End (or any typing) returns to the live
151
+ // conversation.
152
+ if (s.transcriptScrollUp > 0) {
153
+ return _jsx(Text, { dimColor: true, children: "reading history \u2014 press End to return" });
154
+ }
155
+ // Trailing spaces are dimmed: invisible on screen, but it makes the frame's
156
+ // bytes differ from the same text without them, keeping Ink on its full-frame
157
+ // path (see input-layout.ts).
158
+ 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' });
74
160
  }
75
- return (_jsxs(Text, { children: [_jsx(Text, { bold: true, children: '> ' }), value ? _jsx(Text, { children: value }) : _jsx(Text, { dimColor: true, children: "ask anything" })] }));
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))) }));
76
162
  }
@@ -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
  }