@tianmucreations/jeeves 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +32 -0
- package/bin/jeeves +2 -0
- package/dist/agent/context.js +50 -0
- package/dist/agent/errors.js +41 -0
- package/dist/agent/loop.js +84 -0
- package/dist/agent/permissions.js +27 -0
- package/dist/app.js +68 -0
- package/dist/commands/clear.js +9 -0
- package/dist/commands/help.js +17 -0
- package/dist/commands/keys.js +15 -0
- package/dist/commands/model.js +4 -0
- package/dist/commands/verbose.js +8 -0
- package/dist/components/AlternateScreen.js +74 -0
- package/dist/components/Footer.js +114 -0
- package/dist/components/Header.js +6 -0
- package/dist/components/HelpView.js +14 -0
- package/dist/components/Input.js +76 -0
- package/dist/components/KeysManager.js +281 -0
- package/dist/components/ModelPicker.js +457 -0
- package/dist/components/ProjectPicker.js +334 -0
- package/dist/components/TrafficLight.js +116 -0
- package/dist/components/Transcript.js +23 -0
- package/dist/components/UsageBar.js +35 -0
- package/dist/components/transcript-layout.js +103 -0
- package/dist/index.js +53 -0
- package/dist/ink/AlternateScreen.js +106 -0
- package/dist/keys/store.js +58 -0
- package/dist/models/filter.js +4 -0
- package/dist/models/registry.js +112 -0
- package/dist/platform/config.js +60 -0
- package/dist/platform/paths.js +60 -0
- package/dist/platform/shell.js +9 -0
- package/dist/providers/index.js +165 -0
- package/dist/providers/ollama.js +103 -0
- package/dist/providers/openrouter.js +109 -0
- package/dist/providers/types.js +1 -0
- package/dist/providers/zai.js +104 -0
- package/dist/state/session.js +315 -0
- package/dist/tools/index.js +106 -0
- package/dist/tools/listDir.js +55 -0
- package/dist/tools/readFile.js +15 -0
- package/dist/tools/runBash.js +22 -0
- package/dist/tools/writeFile.js +15 -0
- package/package.json +62 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo, useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import Fuse from 'fuse.js';
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { mkdir } from 'node:fs/promises';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { session, useSession } from '../state/session.js';
|
|
9
|
+
import { setRecentProjects } from '../platform/config.js';
|
|
10
|
+
import { homeLocations, listSubfolders, displayPath, projectNameProblem } from '../platform/paths.js';
|
|
11
|
+
import { hasCredentials } from '../providers/index.js';
|
|
12
|
+
// The cursor skips header lines; every other row is selectable.
|
|
13
|
+
function resolveIndex(items, cursor) {
|
|
14
|
+
const start = cursor < 0 ? 0 : cursor;
|
|
15
|
+
for (let i = start; i < items.length; i++) {
|
|
16
|
+
if (items[i].kind !== 'header')
|
|
17
|
+
return i;
|
|
18
|
+
}
|
|
19
|
+
for (let i = Math.min(start, items.length - 1); i >= 0; i--) {
|
|
20
|
+
if (items[i].kind !== 'header')
|
|
21
|
+
return i;
|
|
22
|
+
}
|
|
23
|
+
return -1;
|
|
24
|
+
}
|
|
25
|
+
function stepItem(items, from, delta) {
|
|
26
|
+
let i = from;
|
|
27
|
+
do {
|
|
28
|
+
i += delta;
|
|
29
|
+
} while (i >= 0 && i < items.length && items[i].kind === 'header');
|
|
30
|
+
return i >= 0 && i < items.length ? i : from;
|
|
31
|
+
}
|
|
32
|
+
function fuzzyFolders(pool, query) {
|
|
33
|
+
if (!query)
|
|
34
|
+
return pool;
|
|
35
|
+
const fuse = new Fuse(pool, { keys: ['name'], threshold: 0.4 });
|
|
36
|
+
return fuse.search(query).map((result) => result.item);
|
|
37
|
+
}
|
|
38
|
+
export function ProjectPicker({ rows, columns }) {
|
|
39
|
+
const s = useSession();
|
|
40
|
+
const [mode, setMode] = useState('list');
|
|
41
|
+
const [purpose, setPurpose] = useState('open');
|
|
42
|
+
const [stack, setStack] = useState([]);
|
|
43
|
+
const [filter, setFilter] = useState('');
|
|
44
|
+
const [cursor, setCursor] = useState(0);
|
|
45
|
+
const [createName, setCreateName] = useState('');
|
|
46
|
+
const [createTarget, setCreateTarget] = useState(null);
|
|
47
|
+
const [note, setNote] = useState('');
|
|
48
|
+
const listHeight = Math.max(1, rows - 4);
|
|
49
|
+
const current = stack.length > 0 ? stack[stack.length - 1] : null;
|
|
50
|
+
// Reading a folder is synchronous and fast, so both the listing and the menu are pure calculations.
|
|
51
|
+
const listing = useMemo(() => {
|
|
52
|
+
if (mode !== 'browse' || current === null)
|
|
53
|
+
return { folders: [], error: '' };
|
|
54
|
+
return listSubfolders(current);
|
|
55
|
+
}, [mode, current]);
|
|
56
|
+
const items = useMemo(() => {
|
|
57
|
+
if (mode === 'list') {
|
|
58
|
+
const recents = s.recentProjects.filter((folder) => existsSync(folder));
|
|
59
|
+
const out = [];
|
|
60
|
+
if (recents.length > 0) {
|
|
61
|
+
out.push({ kind: 'header', label: 'Recent projects' });
|
|
62
|
+
for (const folder of recents)
|
|
63
|
+
out.push({ kind: 'recent', folder });
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
out.push({ kind: 'header', label: 'No recent projects yet - pick Browse below' });
|
|
67
|
+
}
|
|
68
|
+
out.push({ kind: 'browse' }, { kind: 'create' });
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
if (mode === 'browse') {
|
|
72
|
+
const out = [];
|
|
73
|
+
if (current !== null) {
|
|
74
|
+
out.push({ kind: 'back' });
|
|
75
|
+
out.push(purpose === 'create' ? { kind: 'create-here' } : { kind: 'choose' });
|
|
76
|
+
for (const entry of fuzzyFolders(listing.folders, filter))
|
|
77
|
+
out.push({ kind: 'folder', entry });
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
for (const entry of fuzzyFolders(homeLocations(), filter))
|
|
81
|
+
out.push({ kind: 'folder', entry });
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
if (mode === 'create-location') {
|
|
86
|
+
const out = [];
|
|
87
|
+
for (const entry of homeLocations())
|
|
88
|
+
out.push({ kind: 'location', entry });
|
|
89
|
+
out.push({ kind: 'browse' });
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
return [];
|
|
93
|
+
}, [mode, purpose, current, listing, filter, s.recentProjects]);
|
|
94
|
+
const resolved = resolveIndex(items, cursor);
|
|
95
|
+
const half = Math.floor(listHeight / 2);
|
|
96
|
+
const start = Math.max(0, Math.min(items.length - listHeight, resolved - half));
|
|
97
|
+
const visible = items.slice(start, start + listHeight);
|
|
98
|
+
function startProject(folder) {
|
|
99
|
+
try {
|
|
100
|
+
process.chdir(folder);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Staying in the current folder is the safe fallback.
|
|
104
|
+
}
|
|
105
|
+
const updated = [folder, ...session.recentProjects.filter((p) => p !== folder)].slice(0, 10);
|
|
106
|
+
session.setRecentProjects(updated);
|
|
107
|
+
setRecentProjects(updated);
|
|
108
|
+
session.launchComplete();
|
|
109
|
+
session.addNotice(`Now working in ${displayPath(folder)}.`);
|
|
110
|
+
if (!hasCredentials()) {
|
|
111
|
+
session.startWizard(true);
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
session.openPicker();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async function createProject(folder) {
|
|
118
|
+
try {
|
|
119
|
+
await mkdir(folder, { recursive: true });
|
|
120
|
+
startProject(folder);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
setNote('That location could not be written to - pick another.');
|
|
124
|
+
setMode('create-location');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function goBack() {
|
|
128
|
+
setFilter('');
|
|
129
|
+
setCursor(0);
|
|
130
|
+
setNote('');
|
|
131
|
+
if (stack.length > 1) {
|
|
132
|
+
setStack(stack.slice(0, -1));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (stack.length === 1) {
|
|
136
|
+
setStack([]);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (mode === 'browse' && purpose === 'create') {
|
|
140
|
+
setMode('create-location');
|
|
141
|
+
setPurpose('open');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
setMode('list');
|
|
145
|
+
}
|
|
146
|
+
function openFolder(folder) {
|
|
147
|
+
setFilter('');
|
|
148
|
+
setCursor(0);
|
|
149
|
+
setStack([...stack, folder]);
|
|
150
|
+
}
|
|
151
|
+
function startCreate() {
|
|
152
|
+
setCreateName('');
|
|
153
|
+
setNote('');
|
|
154
|
+
setCursor(0);
|
|
155
|
+
setMode('create-name');
|
|
156
|
+
}
|
|
157
|
+
function clip(text) {
|
|
158
|
+
const width = Math.max(10, columns - 2);
|
|
159
|
+
return text.length > width ? text.slice(0, width - 1) + '…' : text;
|
|
160
|
+
}
|
|
161
|
+
useInput((input, key) => {
|
|
162
|
+
if (mode === 'create-name') {
|
|
163
|
+
if (key.escape) {
|
|
164
|
+
setMode('list');
|
|
165
|
+
setNote('');
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (key.return) {
|
|
169
|
+
const problem = projectNameProblem(createName);
|
|
170
|
+
if (problem) {
|
|
171
|
+
setNote(problem);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
setNote('');
|
|
175
|
+
setCursor(0);
|
|
176
|
+
setMode('create-location');
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (key.backspace || key.delete) {
|
|
180
|
+
setCreateName((name) => name.slice(0, -1));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (!input || key.ctrl || key.meta)
|
|
184
|
+
return;
|
|
185
|
+
setCreateName((name) => (name.length >= 60 ? name : name + input));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (mode === 'create-confirm') {
|
|
189
|
+
if (key.return && createTarget !== null) {
|
|
190
|
+
void createProject(path.join(createTarget.path, createName.trim()));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (key.escape) {
|
|
194
|
+
setMode('create-location');
|
|
195
|
+
setCursor(0);
|
|
196
|
+
}
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (key.upArrow) {
|
|
200
|
+
setCursor(stepItem(items, resolved, -1));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (key.downArrow) {
|
|
204
|
+
setCursor(stepItem(items, resolved, 1));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (key.escape) {
|
|
208
|
+
if (mode === 'list') {
|
|
209
|
+
startProject(process.cwd());
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (mode === 'create-location') {
|
|
213
|
+
setMode('create-name');
|
|
214
|
+
setCursor(0);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
goBack();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (mode === 'create-location' && key.return) {
|
|
221
|
+
const item = items[resolved];
|
|
222
|
+
if (!item)
|
|
223
|
+
return;
|
|
224
|
+
if (item.kind === 'location') {
|
|
225
|
+
setCreateTarget(item.entry);
|
|
226
|
+
setMode('create-confirm');
|
|
227
|
+
}
|
|
228
|
+
if (item.kind === 'browse') {
|
|
229
|
+
setPurpose('create');
|
|
230
|
+
setMode('browse');
|
|
231
|
+
setStack([]);
|
|
232
|
+
setFilter('');
|
|
233
|
+
setCursor(0);
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (mode === 'list' && key.return) {
|
|
238
|
+
const item = items[resolved];
|
|
239
|
+
if (!item)
|
|
240
|
+
return;
|
|
241
|
+
if (item.kind === 'recent')
|
|
242
|
+
startProject(item.folder);
|
|
243
|
+
if (item.kind === 'browse') {
|
|
244
|
+
setPurpose('open');
|
|
245
|
+
setMode('browse');
|
|
246
|
+
setStack([]);
|
|
247
|
+
setFilter('');
|
|
248
|
+
setCursor(0);
|
|
249
|
+
}
|
|
250
|
+
if (item.kind === 'create')
|
|
251
|
+
startCreate();
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (mode === 'browse') {
|
|
255
|
+
if (key.return) {
|
|
256
|
+
const item = items[resolved];
|
|
257
|
+
if (!item)
|
|
258
|
+
return;
|
|
259
|
+
if (item.kind === 'back')
|
|
260
|
+
goBack();
|
|
261
|
+
if (item.kind === 'choose' && current !== null)
|
|
262
|
+
startProject(current);
|
|
263
|
+
if (item.kind === 'create-here' && current !== null) {
|
|
264
|
+
void createProject(path.join(current, createName.trim()));
|
|
265
|
+
}
|
|
266
|
+
if (item.kind === 'folder')
|
|
267
|
+
openFolder(item.entry.path);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (key.backspace || key.delete) {
|
|
271
|
+
setFilter((currentFilter) => currentFilter.slice(0, -1));
|
|
272
|
+
setCursor(0);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (!input || key.ctrl || key.meta)
|
|
276
|
+
return;
|
|
277
|
+
setFilter((currentFilter) => currentFilter + input);
|
|
278
|
+
setCursor(0);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
const title = mode === 'list'
|
|
283
|
+
? 'Choose a project'
|
|
284
|
+
: mode === 'create-name'
|
|
285
|
+
? 'Create a new project'
|
|
286
|
+
: mode === 'create-location'
|
|
287
|
+
? `Where should "${createName.trim()}" live?`
|
|
288
|
+
: mode === 'create-confirm'
|
|
289
|
+
? 'Create the project?'
|
|
290
|
+
: purpose === 'create'
|
|
291
|
+
? `Where should "${createName.trim()}" live? - now in ${current !== null ? displayPath(current) : 'your standard folders'}`
|
|
292
|
+
: current !== null
|
|
293
|
+
? `Open a folder - now in ${displayPath(current)}`
|
|
294
|
+
: 'Open a folder - where do you keep your projects?';
|
|
295
|
+
const hint = mode === 'list'
|
|
296
|
+
? '↑↓ move · Enter choose · Esc current folder'
|
|
297
|
+
: mode === 'create-name'
|
|
298
|
+
? 'type a name · Enter continue · Esc cancel'
|
|
299
|
+
: mode === 'create-location'
|
|
300
|
+
? '↑↓ move · Enter choose · Esc back'
|
|
301
|
+
: mode === 'create-confirm'
|
|
302
|
+
? 'Enter create · Esc back'
|
|
303
|
+
: '↑↓ move · Enter open · type to filter · Esc back';
|
|
304
|
+
return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsx(Text, { dimColor: true, children: title }), mode === 'browse' ? (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "filter: " }), _jsx(Text, { children: filter }), _jsx(Text, { dimColor: true, children: filter ? '' : 'optional' })] })) : null, _jsxs(Box, { flexDirection: "column", flexGrow: 1, justifyContent: "center", minHeight: listHeight, children: [mode === 'create-name' ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "Name: " }), _jsx(Text, { children: createName }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'create-confirm' && createTarget !== null ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: clip(`Create ${createName.trim()} in ${createTarget.name} →`) }), _jsx(Text, { dimColor: true, children: displayPath(createTarget.path) })] })) : null, visible.map((item, index) => {
|
|
305
|
+
const absoluteIndex = start + index;
|
|
306
|
+
const selected = absoluteIndex === resolved;
|
|
307
|
+
if (item.kind === 'header') {
|
|
308
|
+
return (_jsx(Text, { dimColor: true, children: item.label }, `h${absoluteIndex}`));
|
|
309
|
+
}
|
|
310
|
+
if (item.kind === 'recent') {
|
|
311
|
+
const name = path.basename(item.folder);
|
|
312
|
+
return (_jsxs(Text, { inverse: selected, children: [` ${name}`.padEnd(26), _jsx(Text, { dimColor: true, children: displayPath(item.folder) })] }, `r${item.folder}`));
|
|
313
|
+
}
|
|
314
|
+
if (item.kind === 'browse') {
|
|
315
|
+
return (_jsx(Text, { inverse: selected, children: ' Browse for a folder →' }, `b${absoluteIndex}`));
|
|
316
|
+
}
|
|
317
|
+
if (item.kind === 'create') {
|
|
318
|
+
return (_jsx(Text, { inverse: selected, children: ' Create a new project →' }, "create"));
|
|
319
|
+
}
|
|
320
|
+
if (item.kind === 'back') {
|
|
321
|
+
return (_jsx(Text, { inverse: selected, dimColor: !selected, children: '← Back' }, "back"));
|
|
322
|
+
}
|
|
323
|
+
if (item.kind === 'choose') {
|
|
324
|
+
return (_jsx(Text, { inverse: selected, children: '✓ Choose this folder' }, "choose"));
|
|
325
|
+
}
|
|
326
|
+
if (item.kind === 'create-here') {
|
|
327
|
+
return current !== null ? (_jsx(Text, { inverse: selected, children: clip(`Create ${createName.trim()} in ${displayPath(current)} →`) }, "create-here")) : null;
|
|
328
|
+
}
|
|
329
|
+
if (item.kind === 'location') {
|
|
330
|
+
return (_jsx(Text, { inverse: selected, children: ` ${item.entry.name}` }, `l${item.entry.path}`));
|
|
331
|
+
}
|
|
332
|
+
return (_jsx(Text, { inverse: selected, children: ` ${item.entry.name}` }, `f${item.entry.path}`));
|
|
333
|
+
}), mode === 'browse' && current !== null && listing.folders.length === 0 && !listing.error ? (_jsx(Text, { dimColor: true, children: "(no folders inside - this one may be your project)" })) : null, mode === 'browse' && listing.error ? _jsxs(Text, { dimColor: true, children: ["(", listing.error, ")"] }) : null] }), note ? _jsx(Text, { color: "yellow", children: note }) : _jsx(Text, { dimColor: true, children: hint })] }));
|
|
334
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { Text } from 'ink';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import notifier from 'node-notifier';
|
|
6
|
+
import { execa } from 'execa';
|
|
7
|
+
import { platform } from 'node:os';
|
|
8
|
+
import { useSession } from '../state/session.js';
|
|
9
|
+
const DOT = '⬤';
|
|
10
|
+
// Two true-colour greens alternate roughly twice per second while working.
|
|
11
|
+
const PULSE_GREENS = ['#00FF00', '#007800'];
|
|
12
|
+
const PULSE_INTERVAL_MS = 500;
|
|
13
|
+
const STATIC_COLORS = {
|
|
14
|
+
idle: '#FF0000',
|
|
15
|
+
'awaiting-approval': '#FFB000',
|
|
16
|
+
disconnected: '#808080',
|
|
17
|
+
};
|
|
18
|
+
const TITLE_LABELS = {
|
|
19
|
+
working: 'working',
|
|
20
|
+
idle: 'done',
|
|
21
|
+
'awaiting-approval': 'approval',
|
|
22
|
+
disconnected: 'offline',
|
|
23
|
+
};
|
|
24
|
+
const TERMINAL_APP_NAMES = {
|
|
25
|
+
appleterminal: 'Terminal',
|
|
26
|
+
itermapp: 'iTerm2',
|
|
27
|
+
iterm: 'iTerm2',
|
|
28
|
+
vscode: 'Code',
|
|
29
|
+
warpterminal: 'Warp',
|
|
30
|
+
ghostty: 'Ghostty',
|
|
31
|
+
tmux: 'Terminal',
|
|
32
|
+
};
|
|
33
|
+
function expectedTerminalApp() {
|
|
34
|
+
const raw = (process.env.TERM_PROGRAM ?? '').toLowerCase().replace(/[^a-z]/g, '');
|
|
35
|
+
return TERMINAL_APP_NAMES[raw] ?? 'Terminal';
|
|
36
|
+
}
|
|
37
|
+
// Mirror the state in the terminal tab title (OSC 0). Terminals without OSC support
|
|
38
|
+
// ignore the sequence; non-TTY streams and dumb terminals are skipped so no escape
|
|
39
|
+
// bytes ever reach output they would corrupt.
|
|
40
|
+
function setTabTitle(label) {
|
|
41
|
+
if (!process.stdout.isTTY || process.env.TERM === 'dumb')
|
|
42
|
+
return;
|
|
43
|
+
try {
|
|
44
|
+
process.stdout.write(`\x1b]0;${label}\x07`);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// A closed stream must never crash the app.
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Best-effort focus check via AppleScript, intentionally macOS-only (spec Phase 8
|
|
51
|
+
// allows per-platform checks); other platforms simply always deliver the notification.
|
|
52
|
+
async function isTerminalFocused() {
|
|
53
|
+
if (platform() !== 'darwin')
|
|
54
|
+
return false;
|
|
55
|
+
try {
|
|
56
|
+
const front = await execa('osascript', [
|
|
57
|
+
'-e',
|
|
58
|
+
'tell application "System Events" to get name of first process whose frontmost is true',
|
|
59
|
+
]);
|
|
60
|
+
const expected = expectedTerminalApp().toLowerCase();
|
|
61
|
+
return front.stdout.toLowerCase().includes(expected);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async function notifyJobFinished(seconds) {
|
|
68
|
+
try {
|
|
69
|
+
if (await isTerminalFocused())
|
|
70
|
+
return;
|
|
71
|
+
notifier.notify({
|
|
72
|
+
title: 'Done',
|
|
73
|
+
message: `The task finished after ${Math.round(seconds)} seconds.`,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// Notifications are best-effort and must never surface errors.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export function TrafficLight() {
|
|
81
|
+
const s = useSession();
|
|
82
|
+
const [pulseTick, setPulseTick] = useState(0);
|
|
83
|
+
const jobStartRef = useRef(null);
|
|
84
|
+
// Pulse only while working; the interval is always cleaned up on change and unmount.
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (s.status !== 'working') {
|
|
87
|
+
setPulseTick(0);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const timer = setInterval(() => setPulseTick((tick) => (tick + 1) % 2), PULSE_INTERVAL_MS);
|
|
91
|
+
return () => clearInterval(timer);
|
|
92
|
+
}, [s.status]);
|
|
93
|
+
// Mirror the state in the terminal tab title; degrades silently where unsupported.
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
setTabTitle(TITLE_LABELS[s.status]);
|
|
96
|
+
}, [s.status]);
|
|
97
|
+
// Watch for long jobs completing; notify when the terminal is not focused.
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (s.status === 'working') {
|
|
100
|
+
if (jobStartRef.current === null)
|
|
101
|
+
jobStartRef.current = Date.now();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (s.status !== 'idle')
|
|
105
|
+
return;
|
|
106
|
+
const started = jobStartRef.current;
|
|
107
|
+
jobStartRef.current = null;
|
|
108
|
+
if (started !== null) {
|
|
109
|
+
const seconds = (Date.now() - started) / 1000;
|
|
110
|
+
if (seconds >= 20)
|
|
111
|
+
void notifyJobFinished(seconds);
|
|
112
|
+
}
|
|
113
|
+
}, [s.status]);
|
|
114
|
+
const color = s.status === 'working' ? PULSE_GREENS[pulseTick] : STATIC_COLORS[s.status];
|
|
115
|
+
return _jsx(Text, { children: chalk.hex(color)(DOT) });
|
|
116
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo } from 'react';
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
import { useSession } from '../state/session.js';
|
|
5
|
+
import { buildDisplayLines, visibleWindow } from './transcript-layout.js';
|
|
6
|
+
// The transcript is clipped to a fixed height so the frame never grows past the
|
|
7
|
+
// terminal window - this is what keeps the header and footer permanently in place.
|
|
8
|
+
// The terminal's native scrollback is unavailable in alternate-screen mode, so
|
|
9
|
+
// the region scrolls itself: arrow and page keys move the view, and 0 offset
|
|
10
|
+
// keeps it pinned to the newest line while answers stream in.
|
|
11
|
+
export function Transcript({ height, width }) {
|
|
12
|
+
const s = useSession();
|
|
13
|
+
const entries = useMemo(() => {
|
|
14
|
+
if (s.showLastReasoning && s.lastReasoning) {
|
|
15
|
+
return [...s.transcript, { id: -1, kind: 'reasoning', text: s.lastReasoning }];
|
|
16
|
+
}
|
|
17
|
+
return s.transcript;
|
|
18
|
+
}, [s.transcript, s.showLastReasoning, s.lastReasoning]);
|
|
19
|
+
const lines = useMemo(() => buildDisplayLines(entries, width), [entries, width]);
|
|
20
|
+
const { visible, linesAbove, linesBelow } = useMemo(() => visibleWindow(lines, height, s.transcriptScrollUp), [lines, height, s.transcriptScrollUp]);
|
|
21
|
+
const showPosition = linesAbove > 0 || linesBelow > 0;
|
|
22
|
+
return (_jsxs(Box, { flexDirection: "column", justifyContent: "flex-end", height: height, children: [showPosition && (_jsxs(Text, { dimColor: true, children: ["\u2191 ", linesAbove, " line", linesAbove === 1 ? '' : 's', " above \u00B7 newest \u2193", linesBelow > 0 ? ` (+${linesBelow} below)` : ''] })), visible.map((line, index) => (_jsx(Text, { color: line.color, dimColor: line.dim, children: line.text }, index)))] }));
|
|
23
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Text } from 'ink';
|
|
3
|
+
export function usageFraction(value, max) {
|
|
4
|
+
if (!Number.isFinite(value) || !Number.isFinite(max) || max <= 0)
|
|
5
|
+
return 0;
|
|
6
|
+
return Math.min(1, Math.max(0, value / max));
|
|
7
|
+
}
|
|
8
|
+
// Green below 70%, amber 70-89%, red 90% and above.
|
|
9
|
+
export function usageColor(fraction) {
|
|
10
|
+
if (fraction >= 0.9)
|
|
11
|
+
return '#FF0000';
|
|
12
|
+
if (fraction >= 0.7)
|
|
13
|
+
return '#FFB000';
|
|
14
|
+
return '#00CC00';
|
|
15
|
+
}
|
|
16
|
+
// Colours for "higher is better" bars such as the cache hit rate: a full bar is
|
|
17
|
+
// the good outcome, so green at 70%+, amber 20-69%, red below 20%.
|
|
18
|
+
export function benefitColor(fraction) {
|
|
19
|
+
if (fraction >= 0.7)
|
|
20
|
+
return '#00CC00';
|
|
21
|
+
if (fraction >= 0.2)
|
|
22
|
+
return '#FFB000';
|
|
23
|
+
return '#FF0000';
|
|
24
|
+
}
|
|
25
|
+
export function renderBar(fraction, width) {
|
|
26
|
+
const filled = Math.round(fraction * width);
|
|
27
|
+
return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled));
|
|
28
|
+
}
|
|
29
|
+
// A filled bar followed by a percentage, coloured by how full the limit is.
|
|
30
|
+
export function UsageBar({ label, value, max, unit, width = 10, goodWhenFull = false, }) {
|
|
31
|
+
const fraction = usageFraction(value, max);
|
|
32
|
+
const color = goodWhenFull ? benefitColor(fraction) : usageColor(fraction);
|
|
33
|
+
const percent = Math.round(fraction * 100);
|
|
34
|
+
return (_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: [label, " "] }), _jsxs(Text, { color: color, children: [renderBar(fraction, width), " ", percent, "%"] }), unit ? _jsxs(Text, { dimColor: true, children: [" ", unit] }) : null] }));
|
|
35
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Greedy word-wrap for plain text. Long unbreakable words are hard-broken at the width.
|
|
2
|
+
export function wrapParagraph(s, max) {
|
|
3
|
+
if (max <= 0)
|
|
4
|
+
return [s];
|
|
5
|
+
if (s.length <= max)
|
|
6
|
+
return [s];
|
|
7
|
+
const lines = [];
|
|
8
|
+
let start = 0;
|
|
9
|
+
while (start < s.length) {
|
|
10
|
+
if (s.length - start <= max) {
|
|
11
|
+
lines.push(s.slice(start).trimEnd());
|
|
12
|
+
break;
|
|
13
|
+
}
|
|
14
|
+
let cut = s.lastIndexOf(' ', start + max);
|
|
15
|
+
if (cut <= start)
|
|
16
|
+
cut = start + max;
|
|
17
|
+
lines.push(s.slice(start, cut).trimEnd());
|
|
18
|
+
start = s[cut] === ' ' ? cut + 1 : cut;
|
|
19
|
+
}
|
|
20
|
+
return lines;
|
|
21
|
+
}
|
|
22
|
+
// Clip to a single physical line - used for tool actions so they can never wrap.
|
|
23
|
+
export function clipLine(s, width) {
|
|
24
|
+
if (width <= 1)
|
|
25
|
+
return s.slice(0, width);
|
|
26
|
+
return s.length <= width ? s : s.slice(0, width - 1) + '…';
|
|
27
|
+
}
|
|
28
|
+
// Wraps an entry's text across multiple physical lines; the first line gets the
|
|
29
|
+
// prefix, continuation lines get the indent. Prefix and indent must be the same width.
|
|
30
|
+
function wrapWithPrefix(s, width, prefix, indent) {
|
|
31
|
+
const max = width - prefix.length;
|
|
32
|
+
const paragraphs = s.split('\n');
|
|
33
|
+
const raw = [];
|
|
34
|
+
for (let i = 0; i < paragraphs.length; i++) {
|
|
35
|
+
if (i > 0)
|
|
36
|
+
raw.push('');
|
|
37
|
+
if (paragraphs[i])
|
|
38
|
+
raw.push(...wrapParagraph(paragraphs[i], max));
|
|
39
|
+
}
|
|
40
|
+
return raw.map((line, index) => (index === 0 ? prefix + line : indent + line));
|
|
41
|
+
}
|
|
42
|
+
function toolLineText(d) {
|
|
43
|
+
if (d.state === 'awaiting') {
|
|
44
|
+
return { text: `? ${d.tool} ${d.summary} — allow? (y/n)`, color: 'yellow' };
|
|
45
|
+
}
|
|
46
|
+
if (d.state === 'running') {
|
|
47
|
+
return { text: `… ${d.tool} ${d.summary}`, dim: true };
|
|
48
|
+
}
|
|
49
|
+
if (d.state === 'declined') {
|
|
50
|
+
return { text: `✗ ${d.tool} declined`, color: 'red' };
|
|
51
|
+
}
|
|
52
|
+
if (d.state === 'failed') {
|
|
53
|
+
return { text: `✗ ${d.tool} failed: ${clipLine(d.label, 60)}`, color: 'red' };
|
|
54
|
+
}
|
|
55
|
+
return { text: `✓ ${d.label}` };
|
|
56
|
+
}
|
|
57
|
+
// Picks the visible window of display lines for the transcript region. The
|
|
58
|
+
// terminal's own scrollback is off in alternate-screen mode, so this is the
|
|
59
|
+
// app's whole scrolling story: scrollUp counts lines above the bottom edge,
|
|
60
|
+
// and 0 pins the view to the newest line (follow mode).
|
|
61
|
+
export function visibleWindow(lines, height, scrollUp) {
|
|
62
|
+
const total = lines.length;
|
|
63
|
+
const maxScroll = Math.max(0, total - height);
|
|
64
|
+
const offset = Math.min(Math.max(0, scrollUp), maxScroll);
|
|
65
|
+
const end = total - offset;
|
|
66
|
+
const start = Math.max(0, end - height);
|
|
67
|
+
const visible = lines.slice(start, end);
|
|
68
|
+
return { visible, linesAbove: start, linesBelow: total - end };
|
|
69
|
+
}
|
|
70
|
+
// Turns transcript entries into physical display lines that fit the given width.
|
|
71
|
+
export function buildDisplayLines(entries, width) {
|
|
72
|
+
const lines = [];
|
|
73
|
+
const pushWrapped = (text, prefix, indent, color, dim) => {
|
|
74
|
+
for (const line of wrapWithPrefix(text, width, prefix, indent)) {
|
|
75
|
+
lines.push({ text: line, color, dim });
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
switch (entry.kind) {
|
|
80
|
+
case 'user':
|
|
81
|
+
pushWrapped(entry.text, '> ', ' ');
|
|
82
|
+
break;
|
|
83
|
+
case 'assistant':
|
|
84
|
+
pushWrapped(entry.text, '', '');
|
|
85
|
+
break;
|
|
86
|
+
case 'reasoning':
|
|
87
|
+
pushWrapped(entry.text, '· ', ' ', undefined, true);
|
|
88
|
+
break;
|
|
89
|
+
case 'error':
|
|
90
|
+
pushWrapped(entry.text, '', '', 'red');
|
|
91
|
+
break;
|
|
92
|
+
case 'notice':
|
|
93
|
+
pushWrapped(entry.text, '', '', 'yellow');
|
|
94
|
+
break;
|
|
95
|
+
case 'tool': {
|
|
96
|
+
const rendered = toolLineText(entry.data);
|
|
97
|
+
lines.push({ text: clipLine(rendered.text, width), color: rendered.color, dim: rendered.dim });
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return lines;
|
|
103
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render } from 'ink';
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { App } from './app.js';
|
|
8
|
+
import { session } from './state/session.js';
|
|
9
|
+
import { AlternateScreen, leaveAltScreen } from './ink/AlternateScreen.js';
|
|
10
|
+
// Local development bridge: settings such as OPENROUTER_API_KEY are loaded from a gitignored
|
|
11
|
+
// .env file at the project root. Replaced by the secure OS credential store in Phase 7.
|
|
12
|
+
const envPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '.env');
|
|
13
|
+
if (existsSync(envPath)) {
|
|
14
|
+
try {
|
|
15
|
+
process.loadEnvFile(envPath);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// A malformed .env is non-fatal; the on-screen notice explains what is missing.
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// Graceful degradation: without an interactive terminal there is nothing to draw,
|
|
22
|
+
// so explain in plain English instead of crashing on raw mode.
|
|
23
|
+
if (!process.stdin.isTTY) {
|
|
24
|
+
console.error('This app needs an interactive terminal window to run.');
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
const program = new Command();
|
|
28
|
+
program
|
|
29
|
+
.name('jeeves')
|
|
30
|
+
.version('0.2.0')
|
|
31
|
+
.description('A plain-English terminal assistant.')
|
|
32
|
+
.argument('[prompt]', 'optional prompt to start with')
|
|
33
|
+
.action((prompt) => {
|
|
34
|
+
// The prompt argument is accepted but not auto-sent yet; a later phase wires it into the loop.
|
|
35
|
+
// The whole app - project picker, key screens, model picker, main window - runs inside a
|
|
36
|
+
// single AlternateScreen, so the terminal is taken over exactly once for the whole
|
|
37
|
+
// process and handed back only when Jeeves quits (Claude Code's mechanism).
|
|
38
|
+
const instance = render(_jsx(AlternateScreen, { children: _jsx(App, {}) }));
|
|
39
|
+
let quitting = false;
|
|
40
|
+
// /exit asks for a clean shutdown: let Ink finish its frame teardown, hand the
|
|
41
|
+
// terminal back, then leave.
|
|
42
|
+
session.subscribe(() => {
|
|
43
|
+
if (session.exitRequested && !quitting) {
|
|
44
|
+
quitting = true;
|
|
45
|
+
instance.unmount();
|
|
46
|
+
void instance.waitUntilExit().then(() => {
|
|
47
|
+
leaveAltScreen();
|
|
48
|
+
process.exit(0);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
program.parse();
|