@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
package/LICENSE CHANGED
@@ -1,21 +1,41 @@
1
- MIT License
1
+ Jeeves Licence
2
+ Copyright (c) 2026 Tianmu Creations. All rights reserved.
2
3
 
3
- Copyright (c) 2026 tianmucreations
4
+ This licence applies to Jeeves version 0.3.1 and every later version. Versions
5
+ 0.3.0 and earlier were released under the MIT licence, which still applies to
6
+ those versions.
4
7
 
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
8
+ 1. YOU MAY
9
+ - Install Jeeves and use it free of charge, for personal use and for work,
10
+ on as many of your own computers as you like.
11
+ - Read the source code on GitHub.
11
12
 
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
13
+ 2. YOU MAY NOT, without written permission from Tianmu Creations
14
+ - copy Jeeves, or any part of its source code, except as needed to install
15
+ and run it;
16
+ - change, adapt, translate, or build other software from Jeeves or its
17
+ source code;
18
+ - merge Jeeves, or any part of it, into other software;
19
+ - publish, share, distribute, host, or make Jeeves available to others,
20
+ changed or unchanged, except by pointing them to the official copies on
21
+ npm (npmjs.com/package/@tianmucreations/jeeves) and GitHub;
22
+ - sublicense, rent, lease, lend, or sell Jeeves or access to it; or
23
+ - remove or change this licence or any copyright notice.
14
24
 
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
25
+ 3. OTHER PEOPLE'S WORK
26
+ Jeeves uses add-ons made by others, which are installed alongside it and keep
27
+ their own licences. Its built-in list of AI models and prices comes from
28
+ models.dev (MIT licence).
29
+
30
+ 4. NO WARRANTY
31
+ Jeeves is provided "as is", without any warranty of any kind. Jeeves asks
32
+ before changing your computer and keeps backups for /undo, but you are
33
+ responsible for what you approve and for the costs of the AI services you
34
+ connect it to. To the fullest extent the law allows, Tianmu Creations is not
35
+ liable for any loss or damage arising from using Jeeves.
36
+
37
+ 5. ENDING
38
+ This licence ends automatically if you break it. When it ends you must stop
39
+ using Jeeves and delete your copies.
40
+
41
+ Questions or permission requests: https://tianmucreations.com
package/README.md CHANGED
@@ -32,7 +32,9 @@ Needs [Node.js](https://nodejs.org) 22.12 or newer, and a key from an AI service
32
32
 
33
33
  **He doesn't guess.** Facts about the outside world are looked up on the web, read from the most official page, and given with their source. Before building something new, he checks what already exists and shows you a short research note: use an existing tool, adapt one (licence permitting), or build new — and why. If the same problem happens twice, he researches the cause instead of patching blindly.
34
34
 
35
- **Safe to say yes to.** Anything that changes your computer asks first. Before any change in your project folder he quietly takes a backup, so `/undo` puts the folder back — changed and deleted files return, new ones disappear. Changes outside the folder come with a warning, because they can't be undone.
35
+ **Safe to say yes to.** Anything that changes your computer asks first — or answer **a** once to always allow changes inside that project folder (`/ask` turns asking back on). Before any change in your project folder he quietly takes a backup, so `/undo` puts the folder back — changed and deleted files return, new ones disappear. Changes outside the folder always ask, with a warning, because they can't be undone.
36
+
37
+ **Type while he works.** Your message box grows as you type, and anything you send while Jeeves is busy waits its turn and goes as soon as he finishes. Your own messages sit on a soft grey band, so they're easy to find when you scroll back.
36
38
 
37
39
  **Honest about costs.** A daily spending limit (you choose it; $3 to start) stops and asks before going over, and a long job checks in every 50 cents. Nothing is spent without you seeing it — and it can cost nothing at all: OpenRouter's free models, or models running on your own computer with Ollama.
38
40
 
@@ -70,6 +72,7 @@ Auto is offered only where a pairing has been measured doing everyday and diffic
70
72
  | `/model` | Choose a service and a model (or Auto), and set your daily limit |
71
73
  | `/keys` | Add, replace or remove keys |
72
74
  | `/undo` | Put the project folder back to before your last request |
75
+ | `/ask` | Ask before every change in this project folder again (after choosing "always allow") |
73
76
  | `/clear` | Start a fresh conversation |
74
77
  | `/address` | Change how Jeeves addresses you |
75
78
  | `/verbose` | Show the technical details as well |
@@ -90,4 +93,4 @@ Arrow keys and the trackpad scroll the conversation; `Page Up` / `Page Down` jum
90
93
 
91
94
  ## Licence
92
95
 
93
- MITmade by [Tianmu Creations](https://tianmucreations.com). More about Jeeves at [tianmucreations.com/jeeves](https://tianmucreations.com/jeeves/).
96
+ Free to use, for personal use and for work. All rights reserved see [LICENSE](LICENSE): you may not copy, change, share or sell Jeeves. Versions up to 0.3.0 were released under the MIT licence. Made by [Tianmu Creations](https://tianmucreations.com). More about Jeeves at [tianmucreations.com/jeeves](https://tianmucreations.com/jeeves/).
@@ -37,7 +37,7 @@ export function plainError(error, providerId) {
37
37
  const service = serviceName(providerId);
38
38
  const make = (message, kind) => ({ message, kind, detail: raw });
39
39
  if (/^no .*\bkey\b/.test(text)) {
40
- return make("There's no key yet - type /keys to add one.", 'auth');
40
+ return make('Jeeves isn\'t connected to an AI service yet - type /keys to connect one. It takes about a minute.', 'auth');
41
41
  }
42
42
  if (status === 401 ||
43
43
  status === 403 ||
@@ -11,12 +11,14 @@ import { isToolCapable } from '../models/filter.js';
11
11
  import { autoCatalogue } from './auto.js';
12
12
  import { isAuto, workingModelId, workerModel, expertModel, topModel, AUTO_NOTE, shouldTakeOver, createAskExpertTool, newAutoTurnState, topModelPriceRatio, topModelQuestion, REVIEW_FINISHED_JOBS } from './auto.js';
13
13
  import { jobNeedsReview, reviewJob, fixRequest, startReproducing, stopReproducing, UNCHECKED_NOTICE } from './review.js';
14
- import { requestApproval } from './permissions.js';
14
+ import { requestApproval, hasPendingApproval, answerApproval } from './permissions.js';
15
+ import { killAllRunningCommands } from '../tools/runBash.js';
15
16
  import { clearOldToolResults } from './housekeeping.js';
16
17
  import { startJob, endJob, reportStepCost, withinLimits } from './spending.js';
17
18
  import { getAddress } from '../platform/config.js';
18
19
  import { startTurnCheckpoints, undoLastChange } from '../checkpoints/index.js';
19
20
  import { noteSkipRequest } from './research-gate.js';
21
+ import { untrustProject } from './trust.js';
20
22
  // Added to the rulebook when the chosen model cannot use tools, so a task request
21
23
  // gets a plain answer instead of a pretend attempt.
22
24
  export const CHAT_ONLY_NOTE = `
@@ -25,6 +27,24 @@ Chat-Only Model
25
27
 
26
28
  The model currently selected can only chat. For now you have no tools: you cannot read files, write files, list folders, or run commands, whatever the sections above say. If you are asked to do something that needs them, say plainly that the model in use can only chat, and suggest typing /model to choose one that can do tasks. Never pretend to have done it.`;
27
29
  const DISCONNECTING = new Set(['auth', 'network', 'payment']);
30
+ // The job now running, so the person can stop it (Claude Code's Esc: the request is
31
+ // cancelled and running commands are closed).
32
+ let currentStop = null;
33
+ // Stops the job now running: the model request, any command it started, any
34
+ // question waiting for an answer, and messages waiting their turn. Returns false
35
+ // when nothing was running.
36
+ export function stopTurn() {
37
+ if (!currentStop || currentStop.signal.aborted)
38
+ return false;
39
+ currentStop.abort();
40
+ killAllRunningCommands();
41
+ while (hasPendingApproval())
42
+ answerApproval(false);
43
+ while (session.takeQueued() !== undefined) {
44
+ // Messages sent while busy are dropped too - "stop" means stop.
45
+ }
46
+ return true;
47
+ }
28
48
  export async function runTurn(input) {
29
49
  if (input.startsWith('/') && input.length > 1 && !input.startsWith('/ ')) {
30
50
  if (input === '/help') {
@@ -36,12 +56,20 @@ export async function runTurn(input) {
36
56
  else if (input === '/keys') {
37
57
  session.openKeys();
38
58
  }
59
+ else if (input === '/folder') {
60
+ session.openFolderPicker();
61
+ }
39
62
  else if (input === '/verbose') {
40
63
  session.addNotice(toggleVerbose());
41
64
  }
42
65
  else if (input === '/address') {
43
66
  openAddressPrompt();
44
67
  }
68
+ else if (input === '/ask') {
69
+ session.addNotice(untrustProject()
70
+ ? "I'll ask before every change in this project folder again."
71
+ : 'I already ask before every change in this project folder.');
72
+ }
45
73
  else if (input === '/undo') {
46
74
  if (session.status === 'working') {
47
75
  session.addNotice('Undo works between tasks - wait for this one to finish, then type /undo.');
@@ -62,6 +90,7 @@ export async function runTurn(input) {
62
90
  }
63
91
  else if (input === '/clear') {
64
92
  clearConversation();
93
+ session.addNotice('Started a fresh conversation - the earlier one is cleared.');
65
94
  }
66
95
  else if (input === '/exit') {
67
96
  session.requestExit();
@@ -96,6 +125,7 @@ export async function runTurn(input) {
96
125
  const autoState = newAutoTurnState();
97
126
  session.setActiveModel(auto ? workerModel() : null);
98
127
  const stop = new AbortController();
128
+ currentStop = stop;
99
129
  let countedSteps = 0;
100
130
  session.beginTurn();
101
131
  session.setStatus('working');
@@ -149,15 +179,18 @@ export async function runTurn(input) {
149
179
  return { modelId: stepModel, messages: tidied.freedTokens > 0 ? tidied.messages : undefined };
150
180
  },
151
181
  onToken: (token) => {
182
+ session.setThinking(false);
152
183
  if (assistantId === null)
153
184
  assistantId = session.startAssistant();
154
185
  session.appendToken(assistantId, token);
155
186
  },
156
187
  onReasoning: (delta) => {
188
+ session.setThinking(true);
157
189
  if (session.verbose)
158
190
  session.appendReasoning(delta);
159
191
  },
160
192
  onToolCall: () => {
193
+ session.setThinking(false);
161
194
  // Hide pre-tool chatter so only the final answer stays visible (spec 2.3). The
162
195
  // entry is removed, not just emptied, so the final answer appears below the
163
196
  // actions it reports on rather than above them.
@@ -213,10 +246,16 @@ export async function runTurn(input) {
213
246
  reportStepCost(cost);
214
247
  session.setPlanResetAt(null);
215
248
  session.setActiveModel(auto ? workerModel() : null);
249
+ session.setThinking(false);
250
+ if (currentStop === stop)
251
+ currentStop = null;
216
252
  endJob();
217
253
  session.setStatus('idle');
218
254
  }
219
255
  catch (error) {
256
+ session.setThinking(false);
257
+ if (currentStop === stop)
258
+ currentStop = null;
220
259
  endJob();
221
260
  session.setActiveModel(auto ? workerModel() : null);
222
261
  if (stop.signal.aborted) {
@@ -1,4 +1,6 @@
1
1
  import { session } from '../state/session.js';
2
+ import { trustProject } from './trust.js';
3
+ import { getAddress } from '../platform/config.js';
2
4
  // Pure-output commands that never require permission (the rule: harmless
3
5
  // output must never prompt). A command is auto-approved only when every stage of
4
6
  // it - across pipes, && and || - is one of the commands below, and the only
@@ -172,18 +174,32 @@ const queue = [];
172
174
  export function hasPendingApproval() {
173
175
  return queue.length > 0;
174
176
  }
175
- export function requestApproval() {
177
+ export function requestApproval(options = {}) {
176
178
  return new Promise((resolve) => {
177
- queue.push({ resolve });
179
+ queue.push({ resolve, trustable: options.trustable === true });
178
180
  if (queue.length === 1) {
179
181
  session.setActiveApproval();
180
182
  }
181
183
  });
182
184
  }
183
- export function answerApproval(approved) {
185
+ // Whether the question now on screen may be answered with "always allow".
186
+ export function currentApprovalTrustable() {
187
+ return queue[0]?.trustable === true;
188
+ }
189
+ // always: "always allow in this project" - approves this change, every other change
190
+ // inside the project already waiting, and all future ones in this folder.
191
+ export function answerApproval(approved, always = false) {
184
192
  const current = queue.shift();
185
193
  if (!current)
186
194
  return;
195
+ if (always && approved && current.trustable) {
196
+ trustProject();
197
+ session.addNotice(`From now on I won't ask before changing things in this project folder, ${getAddress() ?? 'Sir'} - every change is still backed up, so /undo puts it back. I'll still ask about anything outside it. Type /ask to have me ask every time again.`);
198
+ for (let i = queue.length - 1; i >= 0; i--) {
199
+ if (queue[i].trustable)
200
+ queue.splice(i, 1)[0].resolve(true);
201
+ }
202
+ }
187
203
  current.resolve(approved);
188
204
  if (queue.length > 0) {
189
205
  session.setActiveApproval();
@@ -1,11 +1,12 @@
1
1
  import { getAddress } from '../platform/config.js';
2
+ import { partOfDay } from '../platform/address.js';
2
3
  // The system prompt is the personality and the rulebook, copied verbatim from the
3
4
  // product specification. {{ADDRESS}} is replaced with the user's saved form of
4
5
  // address (config key "address", asked once on first launch, changeable via
5
6
  // /address); "Sir" is the fallback if none is saved yet.
6
7
  export const SYSTEM_PROMPT_TEMPLATE = `Identity
7
8
 
8
- You are Jeeves, a gentleman's personal assistant built by Tianmu Creations. You speak with quiet formality, dry wit, and impeccable discretion, in the tradition of P.G. Wodehouse. You are competent, unflappable, and never flustered. You do not use modern slang. You do not use emoji. Your replies are concise and warm, never servile. When you complete a task, you say so plainly and stop. You address the user as {{ADDRESS}}.
9
+ You are Jeeves, a gentleman's personal assistant built by Tianmu Creations. You speak with quiet formality, dry wit, and impeccable discretion, in the tradition of P.G. Wodehouse. Your wit never costs clarity: say the plain fact first. You are competent, unflappable, and never flustered. You do not use modern slang. You do not use emoji. Your replies are concise and warm, never servile. When you complete a task, you say so plainly and stop. You address the user as {{ADDRESS}}.
9
10
 
10
11
  The person using you may have no technical background at all: they describe what they want in ordinary words, and you do the work by reading files, writing files, listing folders, and running shell commands.
11
12
 
@@ -65,6 +66,7 @@ You have seven tools: readFile, listDir, writeFile, runBash, webSearch, readWebP
65
66
  Researching the Web
66
67
 
67
68
  For facts about the outside world — versions, prices, dates, rules, current events, how a product works — research before stating them:
69
+ Today's date is {{TODAY}}. What you learned in training stops well before it, so "this year", "the current season" and "the latest" mean the year of today's date: search for that year by name, and never present an earlier year's results as current.
68
70
  1. webSearch to find where to look. Its snippets are not checked facts.
69
71
  2. readWebPage on the most official source: the maker's own website, documentation, release list, or registry, in preference to news or blogs.
70
72
  3. State the fact only once readWebPage has returned the exact quote, and name the source in a few words.
@@ -85,8 +87,10 @@ Never use placeholders or guess missing parameters in tool calls.
85
87
  Complete tasks fully. Do not stop mid-task or leave work incomplete.
86
88
  Tone and Style
87
89
 
88
- Your output appears in a command-line interface. Keep responses short.
89
- Answer concisely fewer than four lines of text (not counting tool use), unless the user asks for detail.
90
+ Write for a person who has not seen your working. {{ADDRESS}} cannot see your thinking or most of what your tools returned — only your words. They do not know names, labels or shorthand you made up along the way (such as "Corner A", "the finalists", "the capacity corner"), so never use them: say what the thing actually is.
91
+ Use complete, plain sentences that can be read once and understood. Never put a metaphor or figure of speech in place of a fact.
92
+ In a longer job, a progress update says in a sentence or two what you found, what it means for {{ADDRESS}}, and what you are doing next.
93
+ Be brief, but clarity comes first: if {{ADDRESS}} would have to read it twice or ask what you meant, it was too short. A simple question gets a short, direct answer.
90
94
  Lead with the answer, not the reasoning. Skip filler, preamble, and unnecessary transitions.
91
95
  Never say "Let me...", "I'll now...", or "First, I will..." before acting. Just act, then report the result in a sentence or two.
92
96
  Do not summarise your own actions. Do not explain your code unless asked.
@@ -97,14 +101,24 @@ Writing files and running non-read-only commands may ask the user for permission
97
101
  If the user declines a permission, do not ask again for the same action. Acknowledge it briefly and continue with whatever can still be done.
98
102
  Environment
99
103
 
104
+ Today's date is {{TODAY}}, and it is {{PART_OF_DAY}} (this computer's own date and clock). Greet by that - never guess the time of day.
100
105
  The computer is macOS. The working directory is the user's chosen project folder; relative paths refer to it.
101
106
  Shell commands run in the user's default shell. Prefer cross-platform-safe commands.
102
107
  If a task would be destructive or hard to undo, say so plainly before doing it.
103
108
  Professional Objectivity
104
109
 
105
110
  Prioritise technical accuracy over validating the user's beliefs. If the user's approach has a problem, say so plainly and offer the better path.`;
106
- export function buildSystemPrompt(address) {
107
- return SYSTEM_PROMPT_TEMPLATE.replaceAll('{{ADDRESS}}', address);
111
+ // Today's date on this computer, as Claude Code gives it (constants/common.ts
112
+ // getLocalISODate): the local calendar date, so it is right wherever the person is.
113
+ // Without it, a question about "the season" was answered with last year's (19 Sept).
114
+ export function localISODate(now = new Date()) {
115
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
116
+ }
117
+ // The part of the day as well (owner's request, 19 Sept): the model otherwise
118
+ // guessed "Good evening". It changes three times a day, so the prompt stays
119
+ // cacheable in between.
120
+ export function buildSystemPrompt(address, today = localISODate(), dayPart = partOfDay()) {
121
+ return SYSTEM_PROMPT_TEMPLATE.replaceAll('{{ADDRESS}}', address).replaceAll('{{TODAY}}', today).replaceAll('{{PART_OF_DAY}}', dayPart);
108
122
  }
109
123
  // The address the user saved on first launch; "Sir" until one is saved.
110
124
  export function getSystemPrompt() {
@@ -0,0 +1,29 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { getTrustedProjects, setTrustedProjects } from '../platform/config.js';
3
+ // "Always allow in this project" (after Claude Code's "Yes, allow all edits in this
4
+ // folder during this session" - here remembered per project folder, as asked for).
5
+ // It covers changes inside the project folder only: anything outside it, or a
6
+ // command that may reach outside it, still asks. Every change is still backed up
7
+ // first, so /undo can put it back.
8
+ function canonical(folder) {
9
+ try {
10
+ return realpathSync(folder);
11
+ }
12
+ catch {
13
+ return folder;
14
+ }
15
+ }
16
+ export function isProjectTrusted(folder = process.cwd()) {
17
+ return getTrustedProjects().includes(canonical(folder));
18
+ }
19
+ export function trustProject(folder = process.cwd()) {
20
+ const key = canonical(folder);
21
+ if (!getTrustedProjects().includes(key))
22
+ setTrustedProjects([...getTrustedProjects(), key]);
23
+ }
24
+ export function untrustProject(folder = process.cwd()) {
25
+ const key = canonical(folder);
26
+ const before = getTrustedProjects();
27
+ setTrustedProjects(before.filter((entry) => entry !== key));
28
+ return before.includes(key);
29
+ }
package/dist/app.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect } from 'react';
3
- import { Box, Text, useStdout } from 'ink';
3
+ import { Box, Text, useInput, useStdout } from 'ink';
4
4
  import { Header } from './components/Header.js';
5
5
  import { Transcript } from './components/Transcript.js';
6
- import { Input } from './components/Input.js';
6
+ import { Input, inputRowsFor } from './components/Input.js';
7
7
  import { Footer } from './components/Footer.js';
8
8
  import { session, useSession } from './state/session.js';
9
9
  import { initKeys, hasCredentials, refreshCredit, serviceKey } from './providers/index.js';
@@ -16,6 +16,8 @@ import { KeysManager } from './components/KeysManager.js';
16
16
  import { HelpView } from './components/HelpView.js';
17
17
  import { ProjectPicker } from './components/ProjectPicker.js';
18
18
  import { AddressPrompt } from './components/AddressPrompt.js';
19
+ import { ENABLE_MOUSE_TRACKING, DISABLE_MOUSE_TRACKING } from './ink/mouse.js';
20
+ import { pressCtrlCToQuit } from './ink/quit.js';
19
21
  // The main window: a rounded box border around the top section only, with the
20
22
  // info bar on its own row below the box. Top to bottom: plain top border, header
21
23
  // row inside the box (Jeeves left, dot right), transcript (flexGrow), internal
@@ -29,7 +31,12 @@ export function App() {
29
31
  const rows = Math.max(stdout.rows ?? 24, 8);
30
32
  const columns = Math.max(stdout.columns ?? 80, 40);
31
33
  const inner = columns - 2;
32
- const midHeight = Math.max(1, rows - 6);
34
+ // The input box grows with the message (up to MAX_INPUT_ROWS); the transcript gives
35
+ // up the rows. A hint or question in the input row is always one row.
36
+ const inputRows = s.approvalPending || s.transcriptScrollUp > 0 ? 1 : inputRowsFor(s.inputText, inner - 2);
37
+ const midHeight = Math.max(1, rows - 5 - inputRows);
38
+ const inputSideLeft = Array.from({ length: inputRows }, () => '│ ').join('\n');
39
+ const inputSideRight = Array.from({ length: inputRows }, () => ' │').join('\n');
33
40
  const side = '│\n'.repeat(midHeight - 1) + '│';
34
41
  const separator = '─'.repeat(inner);
35
42
  useEffect(() => {
@@ -61,6 +68,26 @@ export function App() {
61
68
  }
62
69
  });
63
70
  }, []);
71
+ // The mouse is Jeeves's only on the conversation screen (wheel scrolling, drag to
72
+ // copy). On every other screen - keys, models, help, folders, the first question -
73
+ // it goes back to the terminal, so its own selecting and Cmd+C copy work: a web
74
+ // address such as where to get a key can be copied (owner, 19 Sept: "I still
75
+ // can't copy things").
76
+ const onConversation = !(s.wizardActive || s.keysOpen || s.pickerOpen || s.helpOpen || s.addressOpen || s.launchStage !== 'ready');
77
+ // On the setup screens Ctrl+C is only for quitting - still twice, never at once.
78
+ // (The conversation screen's typing box handles its own.)
79
+ useInput((input, key) => {
80
+ if (key.ctrl && input === 'c' && !onConversation)
81
+ pressCtrlCToQuit();
82
+ });
83
+ useEffect(() => {
84
+ try {
85
+ process.stdout.write(onConversation ? ENABLE_MOUSE_TRACKING : DISABLE_MOUSE_TRACKING);
86
+ }
87
+ catch {
88
+ // A closed stream must never crash the app.
89
+ }
90
+ }, [onConversation]);
64
91
  if (s.wizardActive) {
65
92
  return _jsx(KeysManager, { mode: "wizard", rows: rows, columns: columns });
66
93
  }
@@ -79,5 +106,5 @@ export function App() {
79
106
  if (s.launchStage === 'project') {
80
107
  return _jsx(ProjectPicker, { rows: rows, columns: columns });
81
108
  }
82
- return (_jsxs(Box, { flexDirection: "column", height: rows, width: columns, children: [_jsxs(Text, { dimColor: true, children: ["\u256D", separator, "\u256E"] }), _jsxs(Box, { height: 1, children: [_jsx(Text, { dimColor: true, children: "\u2502" }), _jsx(Box, { width: inner, paddingLeft: 1, paddingRight: 1, flexDirection: "column", children: _jsx(Header, {}) }), _jsx(Text, { dimColor: true, children: "\u2502" })] }), _jsxs(Box, { height: midHeight, children: [_jsx(Box, { width: 1, flexShrink: 0, children: _jsx(Text, { dimColor: true, children: side }) }), _jsx(Box, { width: inner, paddingLeft: 1, paddingRight: 1, children: _jsx(Transcript, { width: inner - 2 }) }), _jsx(Box, { width: 1, flexShrink: 0, children: _jsx(Text, { dimColor: true, children: side }) })] }), _jsxs(Text, { dimColor: true, children: ["\u251C", separator, "\u2524"] }), _jsxs(Box, { height: 1, children: [_jsx(Text, { dimColor: true, children: "\u2502 " }), _jsx(Box, { width: inner - 2, children: _jsx(Input, { scrollPage: midHeight, width: inner - 2 }) }), _jsx(Text, { dimColor: true, children: " \u2502" })] }), _jsxs(Text, { dimColor: true, children: ["\u2570", separator, "\u256F"] }), _jsx(Box, { height: 1, flexDirection: "column", children: _jsx(Footer, {}) })] }));
109
+ return (_jsxs(Box, { flexDirection: "column", height: rows, width: columns, children: [_jsxs(Text, { dimColor: true, children: ["\u256D", separator, "\u256E"] }), _jsxs(Box, { height: 1, children: [_jsx(Text, { dimColor: true, children: "\u2502" }), _jsx(Box, { width: inner, paddingLeft: 1, paddingRight: 1, flexDirection: "column", children: _jsx(Header, {}) }), _jsx(Text, { dimColor: true, children: "\u2502" })] }), _jsxs(Box, { height: midHeight, children: [_jsx(Box, { width: 1, flexShrink: 0, children: _jsx(Text, { dimColor: true, children: side }) }), _jsx(Box, { width: inner, paddingLeft: 1, paddingRight: 1, children: _jsx(Transcript, { width: inner - 2 }) }), _jsx(Box, { width: 1, flexShrink: 0, children: _jsx(Text, { dimColor: true, children: side }) })] }), _jsxs(Text, { dimColor: true, children: ["\u251C", separator, "\u2524"] }), _jsxs(Box, { height: inputRows, children: [_jsx(Text, { dimColor: true, children: inputSideLeft }), _jsx(Box, { width: inner - 2, children: _jsx(Input, { scrollPage: midHeight, width: inner - 2 }) }), _jsx(Text, { dimColor: true, children: inputSideRight })] }), _jsxs(Text, { dimColor: true, children: ["\u2570", separator, "\u256F"] }), _jsx(Box, { height: 1, flexDirection: "column", children: _jsx(Footer, {}) })] }));
83
110
  }
@@ -90,11 +90,41 @@ export function isOutsideProject(target, folder = process.cwd()) {
90
90
  export function commandMayReachOutside(command, folder = process.cwd()) {
91
91
  if (/(^|[\s;|&])sudo\b|\bbrew\s|\s(-g|--global)\b|\bapt(-get)?\s|\bchoco\s|\bwinget\s/.test(command))
92
92
  return true;
93
- if (/(^|[\s'"=])~(\/|\s|$)|(^|[\s'"=/])\.\.(\/|\\|\s|$)|\$HOME|%USERPROFILE%/.test(command))
93
+ if (/\$HOME|%USERPROFILE%/.test(command))
94
94
  return true;
95
- for (const match of command.matchAll(/(?:^|[\s'"=])((?:\/|[A-Za-z]:\\)[^\s'"]*)/g)) {
96
- const candidate = match[1];
97
- if (/^\/dev\/null$/.test(candidate))
95
+ // A quoted path is one path however many spaces it holds - a project folder
96
+ // called "Income Streams Research" was read only up to "Income", so every command
97
+ // naming the folder in full counted as outside it and could never be "always
98
+ // allowed" (19 Sept). Quoted text is judged whole: a path is checked as a path,
99
+ // anything else (bash -c "...") as a command of its own. A backslash-escaped
100
+ // space (Income\ Streams) joins a word the same way.
101
+ const quoted = [];
102
+ const rest = command
103
+ .replace(/"([^"]*)"|'([^']*)'/g, (_match, double, single) => {
104
+ quoted.push(double ?? single ?? '');
105
+ return ' ';
106
+ })
107
+ .replace(/\\ /g, '\u0000');
108
+ for (const text of quoted) {
109
+ if (/^~(\/|$)/.test(text))
110
+ return true;
111
+ if (/^(\/|[A-Za-z]:\\|\.\.(\/|\\|$))/.test(text)) {
112
+ if (text !== '/dev/null' && isOutsideProject(text, folder))
113
+ return true;
114
+ // A path that goes on into more command (bash -c "/x/run.sh && rm /y") is also
115
+ // read word by word - a false question is safe, a false "allowed" is not.
116
+ if (/[;&|<>]|\s[-/~.]/.test(text) && commandMayReachOutside(text, folder))
117
+ return true;
118
+ }
119
+ else if (text.trim() && commandMayReachOutside(text, folder)) {
120
+ return true;
121
+ }
122
+ }
123
+ if (/(^|[\s=])~(\/|\s|$)|(^|[\s=/])\.\.(\/|\\|\s|$)/.test(rest))
124
+ return true;
125
+ for (const match of rest.matchAll(/(?:^|[\s=])((?:\/|[A-Za-z]:\\)\S*)/g)) {
126
+ const candidate = match[1].replace(/\u0000/g, ' ');
127
+ if (candidate === '/dev/null')
98
128
  continue;
99
129
  if (isOutsideProject(candidate, folder))
100
130
  return true;
@@ -1,6 +1,7 @@
1
1
  import { session } from '../state/session.js';
2
2
  import { resetStickySession } from '../providers/openrouter.js';
3
3
  import { resetResearchGate } from '../agent/research-gate.js';
4
+ import { resetBorrowedSearch } from '../tools/web/research.js';
4
5
  // Starts fresh: wipes the screen and the conversation the model remembers.
5
6
  export function clearConversation() {
6
7
  session.clearTranscript();
@@ -8,4 +9,5 @@ export function clearConversation() {
8
9
  session.setLastReasoning('');
9
10
  resetStickySession();
10
11
  resetResearchGate();
12
+ resetBorrowedSearch();
11
13
  }
@@ -1,20 +1,23 @@
1
+ import { WORD_JUMP_KEYS } from '../platform/wording.js';
1
2
  export const COMMANDS = [
2
3
  { command: '/help', description: 'show this list' },
3
- { command: '/model', description: 'pick a different AI model' },
4
- { command: '/keys', description: 'add or remove keys' },
4
+ { command: '/model', description: 'choose the AI service and model' },
5
+ { command: '/keys', description: 'connect an AI service, or remove one' },
6
+ { command: '/folder', description: 'work in a different folder, or just chat' },
7
+ { command: '/undo', description: "put the folder back to how it was before Jeeves's last change" },
8
+ { command: '/ask', description: 'ask before every change again (after "always allow")' },
9
+ { command: '/clear', description: 'start a fresh conversation' },
5
10
  { 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: '/clear', description: 'start a fresh conversation and clear the screen' },
11
+ { command: '/verbose', description: 'show technical details as well (for curious people)' },
9
12
  { command: '/exit', description: 'quit' },
10
13
  ];
11
14
  export const KEY_BINDINGS = [
12
- { command: 'up down', description: 'move in lists' },
13
- { command: 'Enter', description: 'select' },
14
- { command: 'Esc', description: 'go back' },
15
- { command: 'Ctrl+R', description: "show the model's last thinking" },
16
- { command: 'y / n', description: 'allow or deny a permission request' },
17
- { command: 'arrows', description: 'scroll the conversation up and down; Page Up / Page Down jump a whole screen; End returns to the newest' },
18
- { command: 'wheel', description: 'the trackpad or mouse wheel scrolls the conversation too' },
19
- { command: 'copying', description: 'hold Fn and drag to select text (Option in iTerm2, Shift elsewhere)' },
15
+ { command: 'Enter', description: 'send your message, or choose in a list' },
16
+ { command: 'Esc', description: 'stop what Jeeves is doing, or go back' },
17
+ { command: 'Ctrl+C', description: 'clear what you are typing or stop Jeeves; press it twice to quit' },
18
+ { command: 'y / a / n', description: 'answer a question: yes, always allow in this folder, or no' },
19
+ { command: ' ', description: 'move in a list, or scroll the conversation; Page Up / Page Down jump a whole screen; End returns to the newest' },
20
+ { command: '← →', description: `move the cursor in what you are typing; ${WORD_JUMP_KEYS} jump a word; Ctrl+A / Ctrl+E go to the start / end; or click where you want it` },
21
+ { command: 'copying', description: 'drag over any text - it is copied when you let go' },
22
+ { command: 'Ctrl+R', description: 'show the notes Jeeves made while thinking about the last answer' },
20
23
  ];
@@ -1,3 +1,4 @@
1
+ import { KEY_STORE } from '../platform/wording.js';
1
2
  // Plain-English key helpers; the interactive screens live in KeysManager.
2
3
  export function keyLooksValid(key, provider) {
3
4
  const trimmed = key.trim();
@@ -8,7 +9,7 @@ export function keyLooksValid(key, provider) {
8
9
  }
9
10
  export function describeKeySource(source) {
10
11
  if (source === 'keychain')
11
- return 'key stored in your Mac keychain';
12
+ return `key stored in ${KEY_STORE}`;
12
13
  if (source === 'env')
13
14
  return 'key in a local file - add it with /keys to store it safely';
14
15
  return 'no key';
@@ -4,19 +4,33 @@ import { Box, Text, useInput } from 'ink';
4
4
  import { session } from '../state/session.js';
5
5
  import { getAddress, setAddress } from '../platform/config.js';
6
6
  import { isMouseSequence } from '../ink/mouse.js';
7
+ import { cleanAddress, timeOfDayGreeting, ADDRESS_MAX } from '../platform/address.js';
8
+ import { splitTypedBurst } from './input-layout.js';
7
9
  // The first-launch question, in Jeeves' own voice, asked once before the project
8
10
  // picker whenever no address is saved; /address reopens the same screen later.
9
11
  export function AddressPrompt({ rows }) {
10
12
  const [value, setValue] = useState('');
13
+ const [note, setNote] = useState('');
11
14
  const firstRun = session.launchStage === 'address';
12
15
  useInput((input, key) => {
13
16
  if (isMouseSequence(input))
14
17
  return;
15
- if (key.return) {
16
- const address = value.trim() || getAddress() || 'Sir';
17
- setAddress(address.slice(0, 30));
18
+ // Typing and Enter can arrive together (as in the main typing box, 18 Sept):
19
+ // the text before the line break is typed text, the break is Enter.
20
+ const burst = key.return ? { typed: '', enter: true } : splitTypedBurst(input);
21
+ if (burst.enter) {
22
+ const typedValue = (value + burst.typed).slice(0, ADDRESS_MAX);
23
+ // Nothing typed keeps the current address; on the first run there is none to
24
+ // keep, so Jeeves asks rather than guessing "Sir".
25
+ const address = cleanAddress(typedValue) ?? getAddress();
26
+ if (!address) {
27
+ setValue(typedValue);
28
+ setNote("Type Sir, Ma'am, your name, or whatever you'd like to be called.");
29
+ return;
30
+ }
31
+ setAddress(address);
18
32
  session.addressDone();
19
- session.addNotice(`Very good - I shall address you as ${address.slice(0, 30)}.`);
33
+ session.addNotice(`Very good - I shall address you as ${address}.`);
20
34
  return;
21
35
  }
22
36
  if (key.backspace || key.delete) {
@@ -25,7 +39,8 @@ export function AddressPrompt({ rows }) {
25
39
  }
26
40
  if (!input || key.ctrl || key.meta)
27
41
  return;
28
- setValue((v) => (v.length >= 30 ? v : v + input));
42
+ setNote('');
43
+ setValue((v) => (v.length >= ADDRESS_MAX ? v : v + input));
29
44
  });
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" })] }));
45
+ return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsxs(Text, { dimColor: true, children: [timeOfDayGreeting(), ". Before we begin \u2014 how shall I address you? Sir, Ma'am, or something else?"] }), _jsx(Box, { flexGrow: 1, justifyContent: "center", flexDirection: "column", minHeight: 1, children: _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "Call me: " }), _jsx(Text, { children: value }), _jsx(Text, { inverse: true, children: " " })] }) }), note ? _jsx(Text, { color: "yellow", children: note }) : null, firstRun ? _jsx(Text, { dimColor: true, children: "type Sir, Ma'am or a name \u00B7 Enter save" }) : _jsx(Text, { dimColor: true, children: "type a new title \u00B7 Enter save \u00B7 unchanged keeps the current one" })] }));
31
46
  }
@@ -5,6 +5,7 @@ import { useSession } from '../state/session.js';
5
5
  import { allowanceToday } from '../agent/spending.js';
6
6
  import { isAuto, workerModel } from '../agent/auto.js';
7
7
  import { isDirectService, CUSTOM_SERVICE_ID } from '../providers/direct-services.js';
8
+ import { hasCredentials } from '../providers/index.js';
8
9
  export function shortModelName(model) {
9
10
  const short = model.split('/').pop();
10
11
  return short && short.length > 0 ? short : model;
@@ -14,6 +15,8 @@ const money = (value) => `$${value.toFixed(2)}`;
14
15
  // and on the right what today has cost and what is left - or, for a flat-rate
15
16
  // plan, whether it has allowance. Warnings appear only when they matter.
16
17
  export function footerSegments(info) {
18
+ if (info.connected === false)
19
+ return [{ text: `not connected - ${info.connectHint ?? 'type /keys to connect'}`, color: 'yellow' }];
17
20
  const busy = info.busyNote ?? (info.tidying ? 'tidying up…' : null);
18
21
  const direct = isDirectService(info.providerId);
19
22
  if (busy && info.providerId !== 'openrouter' && !direct)
@@ -72,11 +75,13 @@ export function Footer() {
72
75
  providerId: s.providerId,
73
76
  allowance: allowanceToday(),
74
77
  tidying: s.tidying,
75
- busyNote: s.busyNote,
78
+ busyNote: s.busyNote ?? (s.thinkingSince !== null ? 'thinking…' : null),
76
79
  todaySpend: s.todaySpend,
77
80
  creditRemaining: s.creditRemaining,
78
81
  creditIsAccount: s.creditIsAccount,
79
82
  planResetAt: s.planResetAt,
83
+ // Whether a service is set up at all - not the brief "disconnected" of an internet drop.
84
+ connected: hasCredentials(),
80
85
  });
81
86
  const rightWidth = segments.reduce((sum, segment) => sum + segment.text.length, 0) + 3 * (segments.length - 1);
82
87
  // In Auto mode the bar names the model actually working: "auto · deepseek-v4-flash-0731".