@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.
- package/LICENSE +37 -17
- package/README.md +5 -2
- package/dist/agent/errors.js +1 -1
- package/dist/agent/loop.js +40 -1
- package/dist/agent/permissions.js +19 -3
- package/dist/agent/systemPrompt.js +19 -5
- package/dist/agent/trust.js +29 -0
- package/dist/app.js +31 -4
- package/dist/checkpoints/index.js +34 -4
- package/dist/commands/clear.js +2 -0
- package/dist/commands/help.js +16 -13
- package/dist/commands/keys.js +2 -1
- package/dist/components/AddressPrompt.js +21 -6
- package/dist/components/Footer.js +6 -1
- package/dist/components/Header.js +5 -1
- package/dist/components/HelpView.js +1 -1
- package/dist/components/Input.js +181 -24
- package/dist/components/KeysManager.js +33 -18
- package/dist/components/ModelPicker.js +27 -8
- package/dist/components/OpenRouterConnect.js +114 -0
- package/dist/components/ProjectPicker.js +55 -16
- package/dist/components/Transcript.js +40 -3
- package/dist/components/input-layout.js +161 -0
- package/dist/components/markdown.js +185 -0
- package/dist/components/transcript-layout.js +64 -4
- package/dist/index.js +2 -1
- package/dist/ink/mouse.js +39 -6
- package/dist/ink/quit.js +21 -0
- package/dist/ink/selection.js +128 -0
- package/dist/keys/store.js +72 -2
- package/dist/platform/address.js +16 -0
- package/dist/platform/chat-folder.js +24 -0
- package/dist/platform/config.js +11 -0
- package/dist/platform/wording.js +10 -0
- package/dist/providers/direct.js +8 -2
- package/dist/providers/index.js +21 -3
- package/dist/providers/ollama.js +8 -2
- package/dist/providers/openrouter-signin.js +102 -0
- package/dist/providers/openrouter.js +17 -2
- package/dist/providers/silence.js +39 -0
- package/dist/providers/zai.js +17 -13
- package/dist/state/session.js +61 -2
- package/dist/tools/index.js +27 -6
- package/dist/tools/runBash.js +17 -5
- package/dist/tools/web/research.js +119 -21
- package/dist/tools/web/zai-search.js +79 -0
- package/package.json +3 -2
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { signInWithOpenRouter, WAITING_STEPS } from '../providers/openrouter-signin.js';
|
|
5
|
+
import { noCreditNote } from '../providers/openrouter.js';
|
|
6
|
+
import { storeOpenRouterKey, refreshCredit } from '../providers/index.js';
|
|
7
|
+
import { keyLooksValid } from '../commands/keys.js';
|
|
8
|
+
import { isMouseSequence } from '../ink/mouse.js';
|
|
9
|
+
import { KEY_STORE, KEY_STORE_SUBJECT } from '../platform/wording.js';
|
|
10
|
+
const CHOICES = [
|
|
11
|
+
{ title: 'Sign in with OpenRouter (recommended)', detail: 'Your browser opens. Log in, or make a free account, then click Authorize and come back. No key to copy.' },
|
|
12
|
+
{ title: 'Paste a key I already have', detail: 'For people who already made a key at openrouter.ai/keys.' },
|
|
13
|
+
];
|
|
14
|
+
export function OpenRouterConnect({ hasKey, onDone, onBack }) {
|
|
15
|
+
const [step, setStep] = useState('choose');
|
|
16
|
+
const [cursor, setCursor] = useState(0);
|
|
17
|
+
const [pasted, setPasted] = useState('');
|
|
18
|
+
const [note, setNote] = useState('');
|
|
19
|
+
const [address, setAddress] = useState('');
|
|
20
|
+
const signingIn = useRef(null);
|
|
21
|
+
useEffect(() => () => signingIn.current?.abort(), []);
|
|
22
|
+
async function saveKey(key, how) {
|
|
23
|
+
if (!(await storeOpenRouterKey(key))) {
|
|
24
|
+
setStep('choose');
|
|
25
|
+
setNote(`${KEY_STORE_SUBJECT} was not reachable - please try again.`);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
void refreshCredit();
|
|
29
|
+
const credit = await noCreditNote(key);
|
|
30
|
+
const saved = how === 'signed-in'
|
|
31
|
+
? `Connected - Jeeves is signed in to OpenRouter, and the key is saved securely in ${KEY_STORE}.`
|
|
32
|
+
: `Your OpenRouter key is saved securely in ${KEY_STORE}. You will not be asked for it again.`;
|
|
33
|
+
onDone(credit ? `${saved} ${credit}` : saved);
|
|
34
|
+
}
|
|
35
|
+
function signIn() {
|
|
36
|
+
const controller = new AbortController();
|
|
37
|
+
signingIn.current = controller;
|
|
38
|
+
setStep('waiting');
|
|
39
|
+
setNote('');
|
|
40
|
+
setAddress('');
|
|
41
|
+
void signInWithOpenRouter({ signal: controller.signal, onUrl: setAddress }).then((result) => {
|
|
42
|
+
signingIn.current = null;
|
|
43
|
+
if (result.ok) {
|
|
44
|
+
void saveKey(result.key, 'signed-in');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
setStep('choose');
|
|
48
|
+
if (result.reason === 'cancelled')
|
|
49
|
+
return;
|
|
50
|
+
setNote(result.reason === 'timeout'
|
|
51
|
+
? 'No approval arrived after fifteen minutes, so I stopped waiting. Choose Sign in to try again.'
|
|
52
|
+
: "OpenRouter didn't complete the sign-in. Choose Sign in to try again, or paste a key.");
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
useInput((input, key) => {
|
|
56
|
+
if (isMouseSequence(input))
|
|
57
|
+
return;
|
|
58
|
+
if (step === 'waiting') {
|
|
59
|
+
if (key.escape)
|
|
60
|
+
signingIn.current?.abort();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (step === 'paste') {
|
|
64
|
+
if (key.escape) {
|
|
65
|
+
setStep('choose');
|
|
66
|
+
setPasted('');
|
|
67
|
+
setNote('');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (key.return) {
|
|
71
|
+
const value = pasted.trim();
|
|
72
|
+
if (!value) {
|
|
73
|
+
setNote('Paste your key first - or press Esc to go back and choose Sign in instead.');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (!keyLooksValid(value, 'openrouter')) {
|
|
77
|
+
setPasted('');
|
|
78
|
+
setNote('That does not look like an OpenRouter key (they start with sk-or-) - paste it again.');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
void saveKey(value, 'pasted');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (key.backspace || key.delete) {
|
|
85
|
+
setPasted((current) => current.slice(0, -1));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (!input || key.ctrl || key.meta)
|
|
89
|
+
return;
|
|
90
|
+
setNote('');
|
|
91
|
+
setPasted((current) => current + input.replace(/[\r\n]/g, ''));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// step: choose
|
|
95
|
+
if (key.escape)
|
|
96
|
+
return onBack();
|
|
97
|
+
if (key.upArrow || key.downArrow) {
|
|
98
|
+
setCursor((current) => (current === 0 ? 1 : 0));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (input === '1' || input === '2') {
|
|
102
|
+
setCursor(Number(input) - 1);
|
|
103
|
+
}
|
|
104
|
+
if (key.return || input === '1' || input === '2') {
|
|
105
|
+
const choice = input === '1' ? 0 : input === '2' ? 1 : cursor;
|
|
106
|
+
setNote('');
|
|
107
|
+
if (choice === 0)
|
|
108
|
+
signIn();
|
|
109
|
+
else
|
|
110
|
+
setStep('paste');
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(Text, { dimColor: true, children: "Connect Jeeves to OpenRouter" }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, justifyContent: "center", children: [step === 'choose' && (_jsxs(_Fragment, { children: [_jsx(Text, { children: "OpenRouter runs the AI models Jeeves thinks with. One account gives you hundreds of models, and you pay only for what you use." }), hasKey ? _jsx(Text, { color: "yellow", children: "You already have an OpenRouter key saved. A new one replaces it - same account, same credit." }) : null, _jsx(Text, { children: " " }), CHOICES.map((choice, index) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { inverse: index === cursor, children: ` ${index + 1}. ${choice.title} ` }), _jsx(Box, { paddingLeft: 4, children: _jsx(Text, { dimColor: true, children: choice.detail }) })] }, choice.title)))] })), step === 'waiting' && (_jsxs(_Fragment, { children: [WAITING_STEPS.map((line) => (_jsx(Text, { children: line }, line))), _jsx(Text, { children: " " }), address ? _jsx(Text, { dimColor: true, children: `Browser didn't open? Go to: ${address}` }) : null] })), step === 'paste' && (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [_jsx(Text, { children: "Paste your OpenRouter key (it stays hidden): " }), _jsx(Text, { dimColor: true, children: pasted ? `${pasted.length} characters ` : '' }), _jsx(Text, { inverse: true, children: " " })] }), _jsx(Text, { dimColor: true, children: "Keys start with sk-or- and are made at openrouter.ai/keys." })] }))] }), note ? (_jsx(Text, { color: "yellow", children: note })) : (_jsx(Text, { dimColor: true, children: step === 'choose' ? '↑↓ or 1 / 2 choose · Enter continue · Esc back' : step === 'waiting' ? 'Esc stop waiting' : 'paste the key · Enter save · Esc back' }))] }));
|
|
114
|
+
}
|
|
@@ -6,10 +6,11 @@ import { existsSync } from 'node:fs';
|
|
|
6
6
|
import { mkdir } from 'node:fs/promises';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { session, useSession } from '../state/session.js';
|
|
9
|
-
import { setRecentProjects } from '../platform/config.js';
|
|
9
|
+
import { setRecentProjects, getAddress, getDefaultModel } from '../platform/config.js';
|
|
10
10
|
import { homeLocations, listSubfolders, displayPath, projectNameProblem } from '../platform/paths.js';
|
|
11
|
-
import { hasCredentials } from '../providers/index.js';
|
|
11
|
+
import { hasCredentials, keysRead } from '../providers/index.js';
|
|
12
12
|
import { isMouseSequence } from '../ink/mouse.js';
|
|
13
|
+
import { ensureChatFolder, chatNotice } from '../platform/chat-folder.js';
|
|
13
14
|
// The cursor skips header lines; every other row is selectable.
|
|
14
15
|
function resolveIndex(items, cursor) {
|
|
15
16
|
const start = cursor < 0 ? 0 : cursor;
|
|
@@ -57,7 +58,8 @@ export function ProjectPicker({ rows, columns }) {
|
|
|
57
58
|
const items = useMemo(() => {
|
|
58
59
|
if (mode === 'list') {
|
|
59
60
|
const recents = s.recentProjects.filter((folder) => existsSync(folder));
|
|
60
|
-
|
|
61
|
+
// First, for anyone who only wants to ask something: no project needed.
|
|
62
|
+
const out = [{ kind: 'chat' }];
|
|
61
63
|
if (recents.length > 0) {
|
|
62
64
|
out.push({ kind: 'header', label: 'Recent projects' });
|
|
63
65
|
for (const folder of recents)
|
|
@@ -96,24 +98,46 @@ export function ProjectPicker({ rows, columns }) {
|
|
|
96
98
|
const half = Math.floor(listHeight / 2);
|
|
97
99
|
const start = Math.max(0, Math.min(items.length - listHeight, resolved - half));
|
|
98
100
|
const visible = items.slice(start, start + listHeight);
|
|
99
|
-
function
|
|
101
|
+
function startChat() {
|
|
102
|
+
const folder = ensureChatFolder();
|
|
103
|
+
if (!folder) {
|
|
104
|
+
setNote("The Jeeves Chats folder couldn't be made in Documents - pick a project instead.");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
startProject(folder, false);
|
|
108
|
+
}
|
|
109
|
+
function startProject(folder, remember = true) {
|
|
100
110
|
try {
|
|
101
111
|
process.chdir(folder);
|
|
102
112
|
}
|
|
103
113
|
catch {
|
|
104
114
|
// Staying in the current folder is the safe fallback.
|
|
105
115
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (!hasCredentials()) {
|
|
112
|
-
session.startWizard(true);
|
|
116
|
+
// The chat folder is not a project, so it never joins the recent list.
|
|
117
|
+
if (remember) {
|
|
118
|
+
const updated = [folder, ...session.recentProjects.filter((p) => p !== folder)].slice(0, 10);
|
|
119
|
+
session.setRecentProjects(updated);
|
|
120
|
+
setRecentProjects(updated);
|
|
113
121
|
}
|
|
114
|
-
|
|
115
|
-
|
|
122
|
+
const switching = session.switchingFolder;
|
|
123
|
+
session.launchComplete();
|
|
124
|
+
session.addNotice(remember ? `Now working in ${displayPath(folder)}.` : chatNotice(getAddress() ?? 'Sir'));
|
|
125
|
+
if (switching) {
|
|
126
|
+
// The conversation carries on in the new folder; the model is told it moved.
|
|
127
|
+
session.pendingContextNote = `[The person moved Jeeves to ${remember ? `the folder ${folder}` : `the chat folder ${folder}`}. Files mentioned earlier may not be here - look again before relying on them.]`;
|
|
128
|
+
return;
|
|
116
129
|
}
|
|
130
|
+
// Only once the saved keys have been read can "no key yet" be true.
|
|
131
|
+
void keysRead().then(() => {
|
|
132
|
+
if (!hasCredentials()) {
|
|
133
|
+
session.startWizard(true);
|
|
134
|
+
}
|
|
135
|
+
else if (!getDefaultModel()) {
|
|
136
|
+
// The model list only when no model has been chosen yet - not every morning
|
|
137
|
+
// (Claude Code starts straight in the conversation; /model changes it).
|
|
138
|
+
session.openPicker();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
117
141
|
}
|
|
118
142
|
async function createProject(folder) {
|
|
119
143
|
try {
|
|
@@ -209,7 +233,13 @@ export function ProjectPicker({ rows, columns }) {
|
|
|
209
233
|
}
|
|
210
234
|
if (key.escape) {
|
|
211
235
|
if (mode === 'list') {
|
|
212
|
-
|
|
236
|
+
// From /folder: back to the conversation, nothing changed. On first launch:
|
|
237
|
+
// Just chat - never the folder Jeeves happened to start in, which for most
|
|
238
|
+
// people is their whole home folder.
|
|
239
|
+
if (session.switchingFolder)
|
|
240
|
+
session.launchComplete();
|
|
241
|
+
else
|
|
242
|
+
startChat();
|
|
213
243
|
return;
|
|
214
244
|
}
|
|
215
245
|
if (mode === 'create-location') {
|
|
@@ -241,6 +271,8 @@ export function ProjectPicker({ rows, columns }) {
|
|
|
241
271
|
const item = items[resolved];
|
|
242
272
|
if (!item)
|
|
243
273
|
return;
|
|
274
|
+
if (item.kind === 'chat')
|
|
275
|
+
startChat();
|
|
244
276
|
if (item.kind === 'recent')
|
|
245
277
|
startProject(item.folder);
|
|
246
278
|
if (item.kind === 'browse') {
|
|
@@ -283,7 +315,9 @@ export function ProjectPicker({ rows, columns }) {
|
|
|
283
315
|
}
|
|
284
316
|
});
|
|
285
317
|
const title = mode === 'list'
|
|
286
|
-
?
|
|
318
|
+
? session.switchingFolder
|
|
319
|
+
? 'Change folder - or Just chat'
|
|
320
|
+
: 'Just chat, or choose a project'
|
|
287
321
|
: mode === 'create-name'
|
|
288
322
|
? 'Create a new project'
|
|
289
323
|
: mode === 'create-location'
|
|
@@ -296,7 +330,9 @@ export function ProjectPicker({ rows, columns }) {
|
|
|
296
330
|
? `Open a folder - now in ${displayPath(current)}`
|
|
297
331
|
: 'Open a folder - where do you keep your projects?';
|
|
298
332
|
const hint = mode === 'list'
|
|
299
|
-
?
|
|
333
|
+
? session.switchingFolder
|
|
334
|
+
? '↑↓ move · Enter choose · Esc back to the conversation'
|
|
335
|
+
: '↑↓ move · Enter choose · Esc just chat'
|
|
300
336
|
: mode === 'create-name'
|
|
301
337
|
? 'type a name · Enter continue · Esc cancel'
|
|
302
338
|
: mode === 'create-location'
|
|
@@ -314,6 +350,9 @@ export function ProjectPicker({ rows, columns }) {
|
|
|
314
350
|
const name = path.basename(item.folder);
|
|
315
351
|
return (_jsxs(Text, { inverse: selected, wrap: "truncate-middle", children: [` ${name}`.padEnd(26), _jsx(Text, { dimColor: true, children: displayPath(item.folder) })] }, `r${item.folder}`));
|
|
316
352
|
}
|
|
353
|
+
if (item.kind === 'chat') {
|
|
354
|
+
return (_jsxs(Text, { inverse: selected, children: [_jsx(Text, { color: "#c9a96a", bold: true, children: ' Just chat' }), _jsx(Text, { dimColor: true, children: ' - no project needed' })] }, "chat"));
|
|
355
|
+
}
|
|
317
356
|
if (item.kind === 'browse') {
|
|
318
357
|
return (_jsx(Text, { inverse: selected, children: ' Browse for a folder →' }, `b${absoluteIndex}`));
|
|
319
358
|
}
|
|
@@ -1,8 +1,37 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } 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
|
+
import { selectedRange } from '../ink/selection.js';
|
|
7
|
+
// A formatted line cut into pieces wherever the style or the selection changes.
|
|
8
|
+
function styledSegments(text, spans, selected) {
|
|
9
|
+
const cuts = new Set([0, text.length]);
|
|
10
|
+
for (const span of spans) {
|
|
11
|
+
cuts.add(span.from);
|
|
12
|
+
cuts.add(span.to);
|
|
13
|
+
}
|
|
14
|
+
if (selected) {
|
|
15
|
+
cuts.add(selected[0]);
|
|
16
|
+
cuts.add(selected[1]);
|
|
17
|
+
}
|
|
18
|
+
const points = [...cuts].filter((n) => n >= 0 && n <= text.length).sort((a, b) => a - b);
|
|
19
|
+
const pieces = [];
|
|
20
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
21
|
+
const [from, to] = [points[i], points[i + 1]];
|
|
22
|
+
if (from === to)
|
|
23
|
+
continue;
|
|
24
|
+
const style = spans.filter((span) => span.from <= from && span.to >= to);
|
|
25
|
+
pieces.push(_jsx(Text, { bold: style.some((span) => span.bold), italic: style.some((span) => span.italic), color: style.some((span) => span.code) ? CODE_COLOUR : undefined, inverse: selected !== null && from >= selected[0] && to <= selected[1], children: text.slice(from, to) }, from));
|
|
26
|
+
}
|
|
27
|
+
return pieces;
|
|
28
|
+
}
|
|
29
|
+
// Inline code in Tianmu gold, as Claude Code gives it its own colour.
|
|
30
|
+
const CODE_COLOUR = '#c9a96a';
|
|
31
|
+
// Where the conversation sits on screen (1-based): below the top border and the
|
|
32
|
+
// header row, one column in past the border and one of padding (app.tsx).
|
|
33
|
+
const VIEW_TOP = 3;
|
|
34
|
+
const VIEW_LEFT = 3;
|
|
6
35
|
// Claude Code's ScrollBox pattern (ch13-14-terminal-ui.md): the outer box clips at
|
|
7
36
|
// the viewport with overflow="hidden" and flexGrow={1}, so the transcript fills
|
|
8
37
|
// every row left over by the fixed header, input, and footer slots - no dead space.
|
|
@@ -34,5 +63,13 @@ export function Transcript({ width }) {
|
|
|
34
63
|
useEffect(() => {
|
|
35
64
|
session.setTranscriptScrollMax(maxScroll);
|
|
36
65
|
}, [maxScroll]);
|
|
37
|
-
|
|
66
|
+
// The mouse turns a screen position into a line and character with this.
|
|
67
|
+
session.transcriptView = { top: VIEW_TOP, left: VIEW_LEFT, height: viewport.height, lines: lines.map((line) => line.text), scrollTop };
|
|
68
|
+
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) => {
|
|
69
|
+
// Selected text is drawn reversed, as a terminal's own selection is.
|
|
70
|
+
const range = s.selection ? selectedRange(index, line.text.length) : null;
|
|
71
|
+
if (line.spans)
|
|
72
|
+
return _jsx(Text, { children: styledSegments(line.text, line.spans, range) }, index);
|
|
73
|
+
return (_jsx(Text, { color: line.own ? OWN_MESSAGE_TEXT : line.color, backgroundColor: line.own ? OWN_MESSAGE_BACKGROUND : undefined, dimColor: line.dim, children: range ? (_jsxs(_Fragment, { children: [line.text.slice(0, range[0]), _jsx(Text, { inverse: true, children: line.text.slice(range[0], range[1]) }), line.text.slice(range[1])] })) : (line.text) }, index));
|
|
74
|
+
}) }) }));
|
|
38
75
|
}
|
|
@@ -32,3 +32,164 @@ export function inputView(value, rowWidth) {
|
|
|
32
32
|
export function dropLastChar(value) {
|
|
33
33
|
return Array.from(value).slice(0, -1).join('');
|
|
34
34
|
}
|
|
35
|
+
// Wraps at word boundaries (a space at the edge ends the row and is not drawn),
|
|
36
|
+
// remembering where each row starts in the message so the cursor and clicks can
|
|
37
|
+
// be placed. Pasted line breaks start new rows.
|
|
38
|
+
function wrapLines(chars, maxWidth) {
|
|
39
|
+
const lines = [];
|
|
40
|
+
let paragraphStart = 0;
|
|
41
|
+
const value = chars.join('');
|
|
42
|
+
for (const paragraph of value.split('\n')) {
|
|
43
|
+
const pchars = Array.from(paragraph);
|
|
44
|
+
let line = '';
|
|
45
|
+
let lineStart = paragraphStart;
|
|
46
|
+
pchars.forEach((ch, i) => {
|
|
47
|
+
const at = paragraphStart + i;
|
|
48
|
+
if (stringWidth(line + ch) <= maxWidth) {
|
|
49
|
+
line += ch;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (ch === ' ') {
|
|
53
|
+
// A space at the edge ends the row; the next word starts the next row.
|
|
54
|
+
lines.push({ text: line, start: lineStart });
|
|
55
|
+
line = '';
|
|
56
|
+
lineStart = at + 1;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const lineChars = Array.from(line);
|
|
60
|
+
const lastSpace = lineChars.lastIndexOf(' ');
|
|
61
|
+
if (lastSpace > 0) {
|
|
62
|
+
lines.push({ text: lineChars.slice(0, lastSpace).join(''), start: lineStart });
|
|
63
|
+
line = lineChars.slice(lastSpace + 1).join('') + ch;
|
|
64
|
+
lineStart = lineStart + lastSpace + 1;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
lines.push({ text: line, start: lineStart });
|
|
68
|
+
line = ch;
|
|
69
|
+
lineStart = at;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
lines.push({ text: line, start: lineStart });
|
|
73
|
+
paragraphStart += pchars.length + 1;
|
|
74
|
+
}
|
|
75
|
+
return lines;
|
|
76
|
+
}
|
|
77
|
+
// Which row a character position is on: the last row starting at or before it.
|
|
78
|
+
function lineOf(lines, position) {
|
|
79
|
+
let found = 0;
|
|
80
|
+
lines.forEach((line, index) => {
|
|
81
|
+
if (line.start <= position)
|
|
82
|
+
found = index;
|
|
83
|
+
});
|
|
84
|
+
return found;
|
|
85
|
+
}
|
|
86
|
+
// The input box grows as the message does (as in Claude Code): the text wraps at
|
|
87
|
+
// word boundaries onto new rows, up to maxRows, then the oldest rows scroll away so
|
|
88
|
+
// the end of the message - where the typing is - shows. A message taller than the
|
|
89
|
+
// box can be read back: scrollUp moves the view towards the start (Claude Code's
|
|
90
|
+
// Up/Down move through a message that spans more than one line), and a dim line
|
|
91
|
+
// at the top or bottom says how many lines are out of view. Pasted line breaks
|
|
92
|
+
// start new rows. One column is kept spare so the cursor stays inside the box.
|
|
93
|
+
// This keeps inputView's two rules: every keystroke still changes what is drawn (a
|
|
94
|
+
// wrap adds a row, which resizes the box), and the last row's trailing spaces are
|
|
95
|
+
// returned apart so they can be styled.
|
|
96
|
+
// cursor: the cursor's place in the message in characters, or null for the end.
|
|
97
|
+
// Inside the text it is drawn as a highlighted character, as Claude Code draws it,
|
|
98
|
+
// which also keeps every cursor move a visible change.
|
|
99
|
+
export function inputLayout(value, rowWidth, maxRows = 6, scrollUp = 0, cursor = null) {
|
|
100
|
+
const maxWidth = Math.max(1, rowWidth - 1);
|
|
101
|
+
const chars = Array.from(value);
|
|
102
|
+
const lines = wrapLines(chars, maxWidth);
|
|
103
|
+
const inside = cursor !== null && cursor < chars.length;
|
|
104
|
+
const cursorLine = inside ? lineOf(lines, cursor) : lines.length - 1;
|
|
105
|
+
const mark = (line, index, row) => {
|
|
106
|
+
if (!inside || index !== cursorLine)
|
|
107
|
+
return row;
|
|
108
|
+
return { ...row, cursorAt: Math.min(cursor - line.start, Array.from(line.text).length) };
|
|
109
|
+
};
|
|
110
|
+
if (lines.length <= maxRows) {
|
|
111
|
+
const rows = lines.map((line, index) => {
|
|
112
|
+
if (index < lines.length - 1 || inside)
|
|
113
|
+
return mark(line, index, { text: line.text, trailingSpaces: '', start: line.start });
|
|
114
|
+
const text = line.text.replace(/ +$/, '');
|
|
115
|
+
return { text, trailingSpaces: line.text.slice(text.length), start: line.start };
|
|
116
|
+
});
|
|
117
|
+
return { rows, cursorX: stringWidth(lines[lines.length - 1]?.text ?? ''), scrollUp: 0, maxScrollUp: 0, cursorVisible: true, cursorLine };
|
|
118
|
+
}
|
|
119
|
+
// Too tall for the box. Scrolled all the way back, the first maxRows-1 lines show
|
|
120
|
+
// above a "below" hint; at the end, a hint above the last maxRows-1 lines.
|
|
121
|
+
const maxScrollUp = lines.length - (maxRows - 1);
|
|
122
|
+
const up = Math.max(0, Math.min(scrollUp, maxScrollUp));
|
|
123
|
+
const end = lines.length - up;
|
|
124
|
+
const slots = maxRows - (up > 0 ? 1 : 0);
|
|
125
|
+
const start = end - slots <= 0 ? 0 : end - (slots - 1);
|
|
126
|
+
const rows = [];
|
|
127
|
+
// A hint never wraps: in a narrow window it is cut to the row.
|
|
128
|
+
const hint = (text) => ({ text: Array.from(text).slice(0, maxWidth).join(''), trailingSpaces: '', hint: true });
|
|
129
|
+
if (start > 0)
|
|
130
|
+
rows.push(hint(`↑ ${start} more line${start === 1 ? '' : 's'} above - ↑ ↓ to read`));
|
|
131
|
+
lines.slice(start, end).forEach((line, offset, shown) => {
|
|
132
|
+
const index = start + offset;
|
|
133
|
+
if (up > 0 || inside || offset < shown.length - 1) {
|
|
134
|
+
rows.push(mark(line, index, { text: line.text, trailingSpaces: '', start: line.start }));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const text = line.text.replace(/ +$/, '');
|
|
138
|
+
rows.push({ text, trailingSpaces: line.text.slice(text.length), start: line.start });
|
|
139
|
+
});
|
|
140
|
+
if (up > 0)
|
|
141
|
+
rows.push(hint(`↓ ${up} more line${up === 1 ? '' : 's'} below - ↓ or keep typing to return`));
|
|
142
|
+
return {
|
|
143
|
+
rows,
|
|
144
|
+
cursorX: stringWidth(lines[lines.length - 1]?.text ?? ''),
|
|
145
|
+
scrollUp: up,
|
|
146
|
+
maxScrollUp,
|
|
147
|
+
cursorVisible: cursorLine >= start && cursorLine < end,
|
|
148
|
+
cursorLine,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
// The scroll position that brings a cursor inside the text into view, keeping the
|
|
152
|
+
// current one when it already shows.
|
|
153
|
+
export function scrollToShowCursor(value, rowWidth, maxRows, scrollUp, cursor) {
|
|
154
|
+
const first = inputLayout(value, rowWidth, maxRows, scrollUp, cursor);
|
|
155
|
+
if (first.cursorVisible || first.maxScrollUp === 0)
|
|
156
|
+
return first.scrollUp;
|
|
157
|
+
for (let up = 0; up <= first.maxScrollUp; up++) {
|
|
158
|
+
if (inputLayout(value, rowWidth, maxRows, up, cursor).cursorVisible)
|
|
159
|
+
return up;
|
|
160
|
+
}
|
|
161
|
+
return first.scrollUp;
|
|
162
|
+
}
|
|
163
|
+
// Word jumps (Option + arrows): to the start of the previous word, or the end of the next.
|
|
164
|
+
export function previousWordStart(value, cursor) {
|
|
165
|
+
const chars = Array.from(value);
|
|
166
|
+
let i = Math.min(cursor, chars.length);
|
|
167
|
+
while (i > 0 && /\s/.test(chars[i - 1]))
|
|
168
|
+
i--;
|
|
169
|
+
while (i > 0 && !/\s/.test(chars[i - 1]))
|
|
170
|
+
i--;
|
|
171
|
+
return i;
|
|
172
|
+
}
|
|
173
|
+
export function nextWordEnd(value, cursor) {
|
|
174
|
+
const chars = Array.from(value);
|
|
175
|
+
let i = cursor;
|
|
176
|
+
while (i < chars.length && /\s/.test(chars[i]))
|
|
177
|
+
i++;
|
|
178
|
+
while (i < chars.length && !/\s/.test(chars[i]))
|
|
179
|
+
i++;
|
|
180
|
+
return i;
|
|
181
|
+
}
|
|
182
|
+
// A burst of typed characters can arrive together with Enter (when Jeeves is busy
|
|
183
|
+
// and the keyboard runs ahead). The text before the first line break is typed text,
|
|
184
|
+
// and the break itself is Enter. Measured 18 Sept: the break was otherwise stored in
|
|
185
|
+
// the message as a carriage return, which pushed the text over the window's border.
|
|
186
|
+
export function splitTypedBurst(input) {
|
|
187
|
+
const match = /\r\n|\r|\n/.exec(input);
|
|
188
|
+
if (!match)
|
|
189
|
+
return { typed: input, enter: false, rest: '' };
|
|
190
|
+
return { typed: input.slice(0, match.index), enter: true, rest: input.slice(match.index + match[0].length).replace(/\r\n|\r/g, '\n') };
|
|
191
|
+
}
|
|
192
|
+
// Pasted text keeps its line breaks (as newlines) and never sends by itself.
|
|
193
|
+
export function cleanPaste(text) {
|
|
194
|
+
return text.replace(/\r\n|\r/g, '\n').replace(/\t/g, ' ');
|
|
195
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { marked } from 'marked';
|
|
2
|
+
let configured = false;
|
|
3
|
+
function configure() {
|
|
4
|
+
if (configured)
|
|
5
|
+
return;
|
|
6
|
+
configured = true;
|
|
7
|
+
// As Claude Code: no strikethrough - "~100" means "about 100", not crossed out.
|
|
8
|
+
marked.use({ tokenizer: { del: () => undefined } });
|
|
9
|
+
}
|
|
10
|
+
class Builder {
|
|
11
|
+
text = '';
|
|
12
|
+
spans = [];
|
|
13
|
+
add(value, style) {
|
|
14
|
+
if (!value)
|
|
15
|
+
return;
|
|
16
|
+
if (style && (style.bold || style.italic || style.code)) {
|
|
17
|
+
this.spans.push({ from: this.text.length, to: this.text.length + value.length, ...style });
|
|
18
|
+
}
|
|
19
|
+
this.text += value;
|
|
20
|
+
}
|
|
21
|
+
newline() {
|
|
22
|
+
this.text += '\n';
|
|
23
|
+
}
|
|
24
|
+
// One blank line between blocks, never more.
|
|
25
|
+
endBlock() {
|
|
26
|
+
if (!this.text)
|
|
27
|
+
return;
|
|
28
|
+
if (!this.text.endsWith('\n'))
|
|
29
|
+
this.text += '\n';
|
|
30
|
+
if (!this.text.endsWith('\n\n'))
|
|
31
|
+
this.text += '\n';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function inline(b, tokens, style = {}) {
|
|
35
|
+
for (const token of tokens ?? []) {
|
|
36
|
+
switch (token.type) {
|
|
37
|
+
case 'strong':
|
|
38
|
+
inline(b, token.tokens, { ...style, bold: true });
|
|
39
|
+
break;
|
|
40
|
+
case 'em':
|
|
41
|
+
inline(b, token.tokens, { ...style, italic: true });
|
|
42
|
+
break;
|
|
43
|
+
case 'codespan':
|
|
44
|
+
b.add(token.text, { ...style, code: true });
|
|
45
|
+
break;
|
|
46
|
+
case 'link': {
|
|
47
|
+
const link = token;
|
|
48
|
+
const start = b.text.length;
|
|
49
|
+
inline(b, link.tokens, style);
|
|
50
|
+
const shown = b.text.slice(start);
|
|
51
|
+
// The address is shown too when the words don't already say it.
|
|
52
|
+
if (link.href && shown !== link.href && !link.href.startsWith('mailto:'))
|
|
53
|
+
b.add(` (${link.href})`, style);
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
case 'br':
|
|
57
|
+
b.newline();
|
|
58
|
+
break;
|
|
59
|
+
case 'image':
|
|
60
|
+
b.add(token.href, style);
|
|
61
|
+
break;
|
|
62
|
+
case 'text': {
|
|
63
|
+
const text = token;
|
|
64
|
+
if (text.tokens)
|
|
65
|
+
inline(b, text.tokens, style);
|
|
66
|
+
else
|
|
67
|
+
b.add(decode(text.text), style);
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
default:
|
|
71
|
+
b.add(decode(token.text ?? token.raw), style);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function block(b, token, indent = '') {
|
|
76
|
+
switch (token.type) {
|
|
77
|
+
case 'space':
|
|
78
|
+
break;
|
|
79
|
+
case 'heading':
|
|
80
|
+
b.endBlock();
|
|
81
|
+
b.add(indent);
|
|
82
|
+
inline(b, token.tokens, { bold: true });
|
|
83
|
+
b.endBlock();
|
|
84
|
+
break;
|
|
85
|
+
case 'paragraph':
|
|
86
|
+
b.add(indent);
|
|
87
|
+
inline(b, token.tokens);
|
|
88
|
+
b.endBlock();
|
|
89
|
+
break;
|
|
90
|
+
case 'list': {
|
|
91
|
+
const list = token;
|
|
92
|
+
list.items.forEach((item, index) => {
|
|
93
|
+
const marker = list.ordered ? `${Number(list.start || 1) + index}. ` : '- ';
|
|
94
|
+
b.add(indent + marker);
|
|
95
|
+
let first = true;
|
|
96
|
+
for (const part of item.tokens) {
|
|
97
|
+
if (part.type === 'list') {
|
|
98
|
+
if (!b.text.endsWith('\n'))
|
|
99
|
+
b.newline();
|
|
100
|
+
block(b, part, indent + ' ');
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (!first && !b.text.endsWith('\n'))
|
|
104
|
+
b.newline();
|
|
105
|
+
if (!first)
|
|
106
|
+
b.add(indent + ' ');
|
|
107
|
+
if (part.type === 'text' || part.type === 'paragraph')
|
|
108
|
+
inline(b, part.tokens ?? [part]);
|
|
109
|
+
else
|
|
110
|
+
inline(b, [part]);
|
|
111
|
+
first = false;
|
|
112
|
+
}
|
|
113
|
+
if (!b.text.endsWith('\n'))
|
|
114
|
+
b.newline();
|
|
115
|
+
});
|
|
116
|
+
b.endBlock();
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
case 'blockquote': {
|
|
120
|
+
const inner = new Builder();
|
|
121
|
+
for (const part of token.tokens)
|
|
122
|
+
block(inner, part);
|
|
123
|
+
for (const line of inner.text.replace(/\n+$/, '').split('\n')) {
|
|
124
|
+
b.add(`${indent}│ `);
|
|
125
|
+
b.add(line, { italic: true });
|
|
126
|
+
b.newline();
|
|
127
|
+
}
|
|
128
|
+
b.endBlock();
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
case 'code':
|
|
132
|
+
for (const line of token.text.split('\n')) {
|
|
133
|
+
b.add(indent + line, { code: true });
|
|
134
|
+
b.newline();
|
|
135
|
+
}
|
|
136
|
+
b.endBlock();
|
|
137
|
+
break;
|
|
138
|
+
case 'hr':
|
|
139
|
+
b.add(indent + '---');
|
|
140
|
+
b.endBlock();
|
|
141
|
+
break;
|
|
142
|
+
case 'table': {
|
|
143
|
+
const table = token;
|
|
144
|
+
const cell = (c) => {
|
|
145
|
+
const t = new Builder();
|
|
146
|
+
inline(t, c.tokens);
|
|
147
|
+
return t.text;
|
|
148
|
+
};
|
|
149
|
+
const rows = [table.header.map(cell), ...table.rows.map((row) => row.map(cell))];
|
|
150
|
+
const widths = rows[0].map((_, i) => Math.max(3, ...rows.map((row) => (row[i] ?? '').length)));
|
|
151
|
+
rows.forEach((row, r) => {
|
|
152
|
+
const line = `| ${row.map((c, i) => c.padEnd(widths[i])).join(' | ')} |`;
|
|
153
|
+
b.add(indent + line, r === 0 ? { bold: true } : undefined);
|
|
154
|
+
b.newline();
|
|
155
|
+
if (r === 0) {
|
|
156
|
+
b.add(`${indent}|${widths.map((w) => '-'.repeat(w + 2)).join('|')}|`);
|
|
157
|
+
b.newline();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
b.endBlock();
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
default:
|
|
164
|
+
b.add(indent + decode(token.text ?? token.raw));
|
|
165
|
+
b.endBlock();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// marked keeps HTML escapes (& " ') in its text; the screen wants the characters.
|
|
169
|
+
function decode(text) {
|
|
170
|
+
return text.replace(/&(amp|lt|gt|quot|#39);/g, (_, name) => ({ amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'" })[name] ?? _);
|
|
171
|
+
}
|
|
172
|
+
export function renderMarkdown(source) {
|
|
173
|
+
configure();
|
|
174
|
+
const b = new Builder();
|
|
175
|
+
try {
|
|
176
|
+
for (const token of marked.lexer(source))
|
|
177
|
+
block(b, token);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// Anything the reader can't follow is shown exactly as written.
|
|
181
|
+
return { text: source, spans: [] };
|
|
182
|
+
}
|
|
183
|
+
const text = b.text.replace(/\n+$/, '');
|
|
184
|
+
return { text, spans: b.spans.filter((span) => span.from < text.length).map((span) => ({ ...span, to: Math.min(span.to, text.length) })) };
|
|
185
|
+
}
|