@tianmucreations/jeeves 0.3.0 → 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.
- package/LICENSE +37 -17
- package/README.md +5 -2
- package/dist/agent/loop.js +6 -0
- package/dist/agent/permissions.js +19 -3
- package/dist/agent/trust.js +29 -0
- package/dist/app.js +8 -3
- package/dist/commands/help.js +1 -0
- package/dist/components/Input.js +58 -23
- package/dist/components/Transcript.js +2 -2
- package/dist/components/input-layout.js +58 -0
- package/dist/components/transcript-layout.js +14 -2
- package/dist/platform/config.js +8 -0
- package/dist/state/session.js +20 -0
- package/dist/tools/index.js +8 -4
- package/package.json +2 -2
package/LICENSE
CHANGED
|
@@ -1,21 +1,41 @@
|
|
|
1
|
-
|
|
1
|
+
Jeeves Licence
|
|
2
|
+
Copyright (c) 2026 Tianmu Creations. All rights reserved.
|
|
2
3
|
|
|
3
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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
|
-
|
|
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/).
|
package/dist/agent/loop.js
CHANGED
|
@@ -17,6 +17,7 @@ import { startJob, endJob, reportStepCost, withinLimits } from './spending.js';
|
|
|
17
17
|
import { getAddress } from '../platform/config.js';
|
|
18
18
|
import { startTurnCheckpoints, undoLastChange } from '../checkpoints/index.js';
|
|
19
19
|
import { noteSkipRequest } from './research-gate.js';
|
|
20
|
+
import { untrustProject } from './trust.js';
|
|
20
21
|
// Added to the rulebook when the chosen model cannot use tools, so a task request
|
|
21
22
|
// gets a plain answer instead of a pretend attempt.
|
|
22
23
|
export const CHAT_ONLY_NOTE = `
|
|
@@ -42,6 +43,11 @@ export async function runTurn(input) {
|
|
|
42
43
|
else if (input === '/address') {
|
|
43
44
|
openAddressPrompt();
|
|
44
45
|
}
|
|
46
|
+
else if (input === '/ask') {
|
|
47
|
+
session.addNotice(untrustProject()
|
|
48
|
+
? "I'll ask before every change in this project folder again."
|
|
49
|
+
: 'I already ask before every change in this project folder.');
|
|
50
|
+
}
|
|
45
51
|
else if (input === '/undo') {
|
|
46
52
|
if (session.status === 'working') {
|
|
47
53
|
session.addNotice('Undo works between tasks - wait for this one to finish, then type /undo.');
|
|
@@ -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
|
-
|
|
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();
|
|
@@ -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
|
@@ -3,7 +3,7 @@ import { useEffect } from 'react';
|
|
|
3
3
|
import { Box, Text, 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';
|
|
@@ -29,7 +29,12 @@ export function App() {
|
|
|
29
29
|
const rows = Math.max(stdout.rows ?? 24, 8);
|
|
30
30
|
const columns = Math.max(stdout.columns ?? 80, 40);
|
|
31
31
|
const inner = columns - 2;
|
|
32
|
-
|
|
32
|
+
// The input box grows with the message (up to MAX_INPUT_ROWS); the transcript gives
|
|
33
|
+
// up the rows. A hint or question in the input row is always one row.
|
|
34
|
+
const inputRows = s.approvalPending || s.transcriptScrollUp > 0 ? 1 : inputRowsFor(s.inputText, inner - 2);
|
|
35
|
+
const midHeight = Math.max(1, rows - 5 - inputRows);
|
|
36
|
+
const inputSideLeft = Array.from({ length: inputRows }, () => '│ ').join('\n');
|
|
37
|
+
const inputSideRight = Array.from({ length: inputRows }, () => ' │').join('\n');
|
|
33
38
|
const side = '│\n'.repeat(midHeight - 1) + '│';
|
|
34
39
|
const separator = '─'.repeat(inner);
|
|
35
40
|
useEffect(() => {
|
|
@@ -79,5 +84,5 @@ export function App() {
|
|
|
79
84
|
if (s.launchStage === 'project') {
|
|
80
85
|
return _jsx(ProjectPicker, { rows: rows, columns: columns });
|
|
81
86
|
}
|
|
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:
|
|
87
|
+
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
88
|
}
|
package/dist/commands/help.js
CHANGED
|
@@ -5,6 +5,7 @@ export const COMMANDS = [
|
|
|
5
5
|
{ command: '/address', description: 'change how Jeeves addresses you' },
|
|
6
6
|
{ command: '/verbose', description: "show the model's thinking and technical details" },
|
|
7
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")' },
|
|
8
9
|
{ command: '/clear', description: 'start a fresh conversation and clear the screen' },
|
|
9
10
|
{ command: '/exit', description: 'quit' },
|
|
10
11
|
];
|
package/dist/components/Input.js
CHANGED
|
@@ -1,23 +1,38 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect, useRef
|
|
3
|
-
import { Text, useCursor, useInput, useStdout } 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';
|
|
5
|
+
import { answerApproval, currentApprovalTrustable } from '../agent/permissions.js';
|
|
6
|
+
import { session, useSession } from '../state/session.js';
|
|
7
7
|
import { BLOCK_CURSOR, inputFrameRow } from '../ink/cursor.js';
|
|
8
8
|
import { isMouseSequence, handleMouseInput } from '../ink/mouse.js';
|
|
9
|
-
import {
|
|
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
|
+
}
|
|
10
22
|
export function Input({ scrollPage = 10, width = 76 }) {
|
|
11
|
-
const [value, setValue] = useState('');
|
|
12
23
|
// The text lives in a ref as well as state: the keystroke handler reads and
|
|
13
24
|
// writes the ref, so two keys arriving before React re-renders never build on
|
|
14
25
|
// a stale value.
|
|
15
|
-
const valueRef = useRef(
|
|
26
|
+
const valueRef = useRef(session.inputText);
|
|
16
27
|
const s = useSession();
|
|
17
28
|
const { stdout } = useStdout();
|
|
18
29
|
const { setCursorPosition } = useCursor();
|
|
19
|
-
const
|
|
30
|
+
const layout = inputLayout(valueRef.current, width, MAX_INPUT_ROWS);
|
|
20
31
|
const showingText = !s.approvalPending && s.transcriptScrollUp === 0;
|
|
32
|
+
const setValue = (text) => {
|
|
33
|
+
valueRef.current = text;
|
|
34
|
+
session.setInputText(text);
|
|
35
|
+
};
|
|
21
36
|
// The block cursor sits at the text insertion point: two columns in (the
|
|
22
37
|
// border's │ and its padding space) plus the visible text's width, measured
|
|
23
38
|
// with stringWidth so wide characters count. y is the input row, third from
|
|
@@ -27,7 +42,9 @@ export function Input({ scrollPage = 10, width = 76 }) {
|
|
|
27
42
|
// this commit's frame is written - a useLayoutEffect call runs after that and
|
|
28
43
|
// only lands a frame late (measured: the cursor stayed hidden when the window
|
|
29
44
|
// opened). Hidden while the row shows a hint instead of the text.
|
|
30
|
-
|
|
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);
|
|
31
48
|
// The terminal's real cursor becomes a steady block for the whole session; it is
|
|
32
49
|
// restored to the shell's default shape by the AlternateScreen exit paths.
|
|
33
50
|
useEffect(() => {
|
|
@@ -52,6 +69,8 @@ export function Input({ scrollPage = 10, width = 76 }) {
|
|
|
52
69
|
const answer = input.toLowerCase();
|
|
53
70
|
if (answer === 'y')
|
|
54
71
|
answerApproval(true);
|
|
72
|
+
else if (answer === 'a' && currentApprovalTrustable())
|
|
73
|
+
answerApproval(true, true);
|
|
55
74
|
else if (answer === 'n')
|
|
56
75
|
answerApproval(false);
|
|
57
76
|
return;
|
|
@@ -87,32 +106,45 @@ export function Input({ scrollPage = 10, width = 76 }) {
|
|
|
87
106
|
s.followTranscript();
|
|
88
107
|
return;
|
|
89
108
|
}
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
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();
|
|
93
117
|
// /exit is honoured even mid-turn so a wedged request can never trap the user.
|
|
94
|
-
if (text === '/exit' ||
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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}"`);
|
|
99
125
|
}
|
|
100
126
|
return;
|
|
101
127
|
}
|
|
102
128
|
if (key.backspace || key.delete) {
|
|
103
129
|
s.followTranscript();
|
|
104
|
-
|
|
105
|
-
setValue(valueRef.current);
|
|
130
|
+
setValue(dropLastChar(valueRef.current));
|
|
106
131
|
return;
|
|
107
132
|
}
|
|
108
133
|
if (!input || key.ctrl || key.meta)
|
|
109
134
|
return;
|
|
110
135
|
s.followTranscript();
|
|
111
|
-
valueRef.current
|
|
112
|
-
|
|
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));
|
|
113
145
|
});
|
|
114
146
|
if (s.approvalPending) {
|
|
115
|
-
return _jsx(Text, { color: "yellow", children:
|
|
147
|
+
return (_jsx(Text, { color: "yellow", children: currentApprovalTrustable() ? 'y = allow · a = always allow in this project · n = deny' : 'y = allow · n = deny' }));
|
|
116
148
|
}
|
|
117
149
|
// While the transcript is scrolled away from the newest, the prompt is replaced
|
|
118
150
|
// by Claude Code's reading-history hint; End (or any typing) returns to the live
|
|
@@ -123,5 +155,8 @@ export function Input({ scrollPage = 10, width = 76 }) {
|
|
|
123
155
|
// Trailing spaces are dimmed: invisible on screen, but it makes the frame's
|
|
124
156
|
// bytes differ from the same text without them, keeping Ink on its full-frame
|
|
125
157
|
// path (see input-layout.ts).
|
|
126
|
-
|
|
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' });
|
|
160
|
+
}
|
|
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))) }));
|
|
127
162
|
}
|
|
@@ -2,7 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { useEffect, useMemo, useRef } from 'react';
|
|
3
3
|
import { Box, Text, useBoxMetrics, useStdout } from 'ink';
|
|
4
4
|
import { session, useSession } from '../state/session.js';
|
|
5
|
-
import { buildDisplayLines } from './transcript-layout.js';
|
|
5
|
+
import { buildDisplayLines, OWN_MESSAGE_BACKGROUND, OWN_MESSAGE_TEXT } from './transcript-layout.js';
|
|
6
6
|
// Claude Code's ScrollBox pattern (ch13-14-terminal-ui.md): the outer box clips at
|
|
7
7
|
// the viewport with overflow="hidden" and flexGrow={1}, so the transcript fills
|
|
8
8
|
// every row left over by the fixed header, input, and footer slots - no dead space.
|
|
@@ -34,5 +34,5 @@ export function Transcript({ width }) {
|
|
|
34
34
|
useEffect(() => {
|
|
35
35
|
session.setTranscriptScrollMax(maxScroll);
|
|
36
36
|
}, [maxScroll]);
|
|
37
|
-
return (_jsx(Box, { flexDirection: "column", overflow: "hidden", flexGrow: 1, justifyContent: "flex-end", ref: outer, children: _jsx(Box, { flexDirection: "column", flexShrink: 0, marginBottom: -scrollTop, ref: inner, children: lines.map((line, index) => (_jsx(Text, { color: line.color, dimColor: line.dim, children: line.text }, index))) }) }));
|
|
37
|
+
return (_jsx(Box, { flexDirection: "column", overflow: "hidden", flexGrow: 1, justifyContent: "flex-end", ref: outer, children: _jsx(Box, { flexDirection: "column", flexShrink: 0, marginBottom: -scrollTop, ref: inner, children: lines.map((line, index) => (_jsx(Text, { color: line.own ? OWN_MESSAGE_TEXT : line.color, backgroundColor: line.own ? OWN_MESSAGE_BACKGROUND : undefined, dimColor: line.dim, children: line.text }, index))) }) }));
|
|
38
38
|
}
|
|
@@ -32,3 +32,61 @@ export function inputView(value, rowWidth) {
|
|
|
32
32
|
export function dropLastChar(value) {
|
|
33
33
|
return Array.from(value).slice(0, -1).join('');
|
|
34
34
|
}
|
|
35
|
+
// The input box grows as the message does (as in Claude Code): the text wraps at
|
|
36
|
+
// word boundaries onto new rows, up to maxRows, then the oldest rows scroll away so
|
|
37
|
+
// the end of the message - where the typing is - always shows. Pasted line breaks
|
|
38
|
+
// start new rows. One column is kept spare so the cursor stays inside the box.
|
|
39
|
+
// This keeps inputView's two rules: every keystroke still changes what is drawn (a
|
|
40
|
+
// wrap adds a row, which resizes the box), and the last row's trailing spaces are
|
|
41
|
+
// returned apart so they can be styled.
|
|
42
|
+
export function inputLayout(value, rowWidth, maxRows = 6) {
|
|
43
|
+
const maxWidth = Math.max(1, rowWidth - 1);
|
|
44
|
+
const lines = [];
|
|
45
|
+
for (const paragraph of value.split('\n')) {
|
|
46
|
+
let line = '';
|
|
47
|
+
for (const ch of Array.from(paragraph)) {
|
|
48
|
+
if (stringWidth(line + ch) <= maxWidth) {
|
|
49
|
+
line += ch;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (ch === ' ') {
|
|
53
|
+
// A space at the edge ends the row; the next word starts the next row.
|
|
54
|
+
lines.push(line);
|
|
55
|
+
line = '';
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const lastSpace = line.lastIndexOf(' ');
|
|
59
|
+
if (lastSpace > 0) {
|
|
60
|
+
lines.push(line.slice(0, lastSpace));
|
|
61
|
+
line = line.slice(lastSpace + 1) + ch;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
lines.push(line);
|
|
65
|
+
line = ch;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
lines.push(line);
|
|
69
|
+
}
|
|
70
|
+
const shown = lines.slice(-maxRows);
|
|
71
|
+
const rows = shown.map((line, index) => {
|
|
72
|
+
if (index < shown.length - 1)
|
|
73
|
+
return { text: line, trailingSpaces: '' };
|
|
74
|
+
const text = line.replace(/ +$/, '');
|
|
75
|
+
return { text, trailingSpaces: line.slice(text.length) };
|
|
76
|
+
});
|
|
77
|
+
return { rows, cursorX: stringWidth(shown[shown.length - 1] ?? '') };
|
|
78
|
+
}
|
|
79
|
+
// A burst of typed characters can arrive together with Enter (when Jeeves is busy
|
|
80
|
+
// and the keyboard runs ahead). The text before the first line break is typed text,
|
|
81
|
+
// and the break itself is Enter. Measured 18 Sept: the break was otherwise stored in
|
|
82
|
+
// the message as a carriage return, which pushed the text over the window's border.
|
|
83
|
+
export function splitTypedBurst(input) {
|
|
84
|
+
const match = /\r\n|\r|\n/.exec(input);
|
|
85
|
+
if (!match)
|
|
86
|
+
return { typed: input, enter: false, rest: '' };
|
|
87
|
+
return { typed: input.slice(0, match.index), enter: true, rest: input.slice(match.index + match[0].length).replace(/\r\n|\r/g, '\n') };
|
|
88
|
+
}
|
|
89
|
+
// Pasted text keeps its line breaks (as newlines) and never sends by itself.
|
|
90
|
+
export function cleanPaste(text) {
|
|
91
|
+
return text.replace(/\r\n|\r/g, '\n').replace(/\t/g, ' ');
|
|
92
|
+
}
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import stringWidth from 'string-width';
|
|
2
|
+
// Claude Code marks the person's messages the same way: a blank line above and a
|
|
3
|
+
// grey band behind (its dark theme's userMessageBackground, rgb(55, 55, 55)), with
|
|
4
|
+
// white text so the band reads on light and dark terminals alike.
|
|
5
|
+
export const OWN_MESSAGE_BACKGROUND = '#373737';
|
|
6
|
+
export const OWN_MESSAGE_TEXT = '#ffffff';
|
|
1
7
|
// Greedy word-wrap for plain text. Long unbreakable words are hard-broken at the width.
|
|
2
8
|
export function wrapParagraph(s, max) {
|
|
3
9
|
if (max <= 0)
|
|
@@ -73,9 +79,15 @@ export function buildDisplayLines(entries, width) {
|
|
|
73
79
|
};
|
|
74
80
|
for (const entry of entries) {
|
|
75
81
|
switch (entry.kind) {
|
|
76
|
-
case 'user':
|
|
77
|
-
|
|
82
|
+
case 'user': {
|
|
83
|
+
// A single space: an empty line would be drawn with no height at all.
|
|
84
|
+
if (lines.length > 0)
|
|
85
|
+
lines.push({ text: ' ' });
|
|
86
|
+
for (const line of wrapWithPrefix(entry.text, width, '> ', ' ')) {
|
|
87
|
+
lines.push({ text: line + ' '.repeat(Math.max(0, width - stringWidth(line))), own: true });
|
|
88
|
+
}
|
|
78
89
|
break;
|
|
90
|
+
}
|
|
79
91
|
case 'assistant':
|
|
80
92
|
pushWrapped(entry.text, '', '');
|
|
81
93
|
break;
|
package/dist/platform/config.js
CHANGED
|
@@ -114,3 +114,11 @@ export function getEstimatedSpend() {
|
|
|
114
114
|
export function setEstimatedSpend(spend) {
|
|
115
115
|
config.set('estimatedSpend', spend);
|
|
116
116
|
}
|
|
117
|
+
// Project folders where the person chose "always allow": changes inside them need no
|
|
118
|
+
// yes/no (they are still backed up, so /undo works). /ask removes a folder again.
|
|
119
|
+
export function getTrustedProjects() {
|
|
120
|
+
return config.get('trustedProjects') ?? [];
|
|
121
|
+
}
|
|
122
|
+
export function setTrustedProjects(folders) {
|
|
123
|
+
config.set('trustedProjects', folders);
|
|
124
|
+
}
|
package/dist/state/session.js
CHANGED
|
@@ -58,6 +58,10 @@ class SessionStore {
|
|
|
58
58
|
// The furthest the transcript can scroll up (contentHeight - viewportHeight),
|
|
59
59
|
// reported by the Transcript from its live measurements.
|
|
60
60
|
transcriptScrollMax = Number.POSITIVE_INFINITY;
|
|
61
|
+
// What is being typed in the input box (the window sizes the box to fit it).
|
|
62
|
+
inputText = '';
|
|
63
|
+
// Messages sent while Jeeves was busy, in order; each is sent when he finishes.
|
|
64
|
+
queued = [];
|
|
61
65
|
turnEvents = [];
|
|
62
66
|
nextId = 1;
|
|
63
67
|
version = 0;
|
|
@@ -73,6 +77,22 @@ class SessionStore {
|
|
|
73
77
|
for (const listener of this.listeners)
|
|
74
78
|
listener();
|
|
75
79
|
}
|
|
80
|
+
setInputText(text) {
|
|
81
|
+
if (text === this.inputText)
|
|
82
|
+
return;
|
|
83
|
+
this.inputText = text;
|
|
84
|
+
this.emit();
|
|
85
|
+
}
|
|
86
|
+
queueMessage(text) {
|
|
87
|
+
this.queued = [...this.queued, text];
|
|
88
|
+
this.emit();
|
|
89
|
+
}
|
|
90
|
+
takeQueued() {
|
|
91
|
+
const [next, ...rest] = this.queued;
|
|
92
|
+
this.queued = rest;
|
|
93
|
+
this.emit();
|
|
94
|
+
return next;
|
|
95
|
+
}
|
|
76
96
|
setStatus(status) {
|
|
77
97
|
this.status = status;
|
|
78
98
|
this.emit();
|
package/dist/tools/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { runBashSchema, runRunBash } from './runBash.js';
|
|
|
8
8
|
import { webSearchSchema, runWebSearch, readWebPageSchema, runReadWebPage } from './web/research.js';
|
|
9
9
|
import { ensureCheckpoint, isOutsideProject, commandMayReachOutside } from '../checkpoints/index.js';
|
|
10
10
|
import { resolveFromCwd } from '../platform/paths.js';
|
|
11
|
+
import { isProjectTrusted } from '../agent/trust.js';
|
|
11
12
|
import { holdForWrite, holdForCommand, recordCommandResult, recordNote, noteResearchSchema, HELD_PREFIX, commandEditsFiles, } from '../agent/research-gate.js';
|
|
12
13
|
import { holdUntilReproduced } from '../agent/review.js';
|
|
13
14
|
// Assumption: every tool result is capped to keep huge outputs from flooding the conversation.
|
|
@@ -78,13 +79,15 @@ function defineTool(config) {
|
|
|
78
79
|
});
|
|
79
80
|
throw new Error(held);
|
|
80
81
|
}
|
|
81
|
-
const needsPermission = typeof config.permission === 'function' ? config.permission(input) : config.permission;
|
|
82
82
|
const warning = config.warning?.(input) ?? null;
|
|
83
|
+
// A change inside the project folder (no outside warning) may be "always allowed".
|
|
84
|
+
const trustable = warning === null;
|
|
85
|
+
const needsPermission = (typeof config.permission === 'function' ? config.permission(input) : config.permission) && !(trustable && isProjectTrusted());
|
|
83
86
|
if (warning)
|
|
84
87
|
session.addNotice(warning);
|
|
85
88
|
const lineId = session.addToolLine(config.name, summary, needsPermission ? 'awaiting' : 'running');
|
|
86
89
|
if (needsPermission) {
|
|
87
|
-
const approved = await requestApproval();
|
|
90
|
+
const approved = await requestApproval({ trustable });
|
|
88
91
|
if (!approved) {
|
|
89
92
|
session.updateToolLine(lineId, { state: 'declined' });
|
|
90
93
|
throw new Error(`Permission denied by the user - ${config.name} ${summary} was not executed.`);
|
|
@@ -154,7 +157,8 @@ export const TOOLS = {
|
|
|
154
157
|
schema: webSearchSchema,
|
|
155
158
|
permission: false,
|
|
156
159
|
summarize: (input) => clip(input.query, 60),
|
|
157
|
-
|
|
160
|
+
// On another service, searches still go through OpenRouter (about a cent each), so say so.
|
|
161
|
+
label: (input) => `Searched ${clip(input.query, 50)}${session.providerId === 'openrouter' ? '' : ' (via OpenRouter)'}`,
|
|
158
162
|
run: runWebSearch,
|
|
159
163
|
}),
|
|
160
164
|
readWebPage: defineTool({
|
|
@@ -163,7 +167,7 @@ export const TOOLS = {
|
|
|
163
167
|
schema: readWebPageSchema,
|
|
164
168
|
permission: false,
|
|
165
169
|
summarize: (input) => clip(input.url, 60),
|
|
166
|
-
label: (input) => `Read ${clip(input.url.replace(/^https?:\/\//, ''),
|
|
170
|
+
label: (input) => `Read ${clip(input.url.replace(/^https?:\/\//, ''), 50)}${session.providerId === 'openrouter' ? '' : ' (via OpenRouter)'}`,
|
|
167
171
|
run: runReadWebPage,
|
|
168
172
|
}),
|
|
169
173
|
runBash: defineTool({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tianmucreations/jeeves",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Your personal assistant in the terminal: say what you need in plain English and Jeeves does the work carefully - asks first, can undo, watches your spending. Auto mode picks the right AI model for you.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"bugs": {
|
|
34
34
|
"url": "https://github.com/tianmucreations/Jeeves/issues"
|
|
35
35
|
},
|
|
36
|
-
"license": "
|
|
36
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
37
37
|
"files": [
|
|
38
38
|
"bin/",
|
|
39
39
|
"dist/",
|