@tianmucreations/jeeves 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +37 -17
  2. package/README.md +5 -2
  3. package/dist/agent/errors.js +1 -1
  4. package/dist/agent/loop.js +40 -1
  5. package/dist/agent/permissions.js +19 -3
  6. package/dist/agent/systemPrompt.js +19 -5
  7. package/dist/agent/trust.js +29 -0
  8. package/dist/app.js +31 -4
  9. package/dist/checkpoints/index.js +34 -4
  10. package/dist/commands/clear.js +2 -0
  11. package/dist/commands/help.js +16 -13
  12. package/dist/commands/keys.js +2 -1
  13. package/dist/components/AddressPrompt.js +21 -6
  14. package/dist/components/Footer.js +6 -1
  15. package/dist/components/Header.js +5 -1
  16. package/dist/components/HelpView.js +1 -1
  17. package/dist/components/Input.js +181 -24
  18. package/dist/components/KeysManager.js +33 -18
  19. package/dist/components/ModelPicker.js +27 -8
  20. package/dist/components/OpenRouterConnect.js +114 -0
  21. package/dist/components/ProjectPicker.js +55 -16
  22. package/dist/components/Transcript.js +40 -3
  23. package/dist/components/input-layout.js +161 -0
  24. package/dist/components/markdown.js +185 -0
  25. package/dist/components/transcript-layout.js +64 -4
  26. package/dist/index.js +2 -1
  27. package/dist/ink/mouse.js +39 -6
  28. package/dist/ink/quit.js +21 -0
  29. package/dist/ink/selection.js +128 -0
  30. package/dist/keys/store.js +72 -2
  31. package/dist/platform/address.js +16 -0
  32. package/dist/platform/chat-folder.js +24 -0
  33. package/dist/platform/config.js +11 -0
  34. package/dist/platform/wording.js +10 -0
  35. package/dist/providers/direct.js +8 -2
  36. package/dist/providers/index.js +21 -3
  37. package/dist/providers/ollama.js +8 -2
  38. package/dist/providers/openrouter-signin.js +102 -0
  39. package/dist/providers/openrouter.js +17 -2
  40. package/dist/providers/silence.js +39 -0
  41. package/dist/providers/zai.js +17 -13
  42. package/dist/state/session.js +61 -2
  43. package/dist/tools/index.js +27 -6
  44. package/dist/tools/runBash.js +17 -5
  45. package/dist/tools/web/research.js +119 -21
  46. package/dist/tools/web/zai-search.js +79 -0
  47. package/package.json +3 -2
@@ -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
- const out = [];
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 startProject(folder) {
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
- const updated = [folder, ...session.recentProjects.filter((p) => p !== folder)].slice(0, 10);
107
- session.setRecentProjects(updated);
108
- setRecentProjects(updated);
109
- session.launchComplete();
110
- session.addNotice(`Now working in ${displayPath(folder)}.`);
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
- else {
115
- session.openPicker();
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
- startProject(process.cwd());
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
- ? 'Choose a project'
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
- ? '↑↓ move · Enter choose · Esc current folder'
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
- 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))) }) }));
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 (&amp; &quot; &#39;) 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
+ }