privateer-agent 0.1.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.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +474 -0
  3. package/bin/privateer.mjs +11 -0
  4. package/package.json +74 -0
  5. package/src/agents/loader.ts +49 -0
  6. package/src/auth/privateer.ts +393 -0
  7. package/src/commands/custom.ts +75 -0
  8. package/src/commands/registry.ts +499 -0
  9. package/src/components/AgentGroupView.tsx +104 -0
  10. package/src/components/App.tsx +1376 -0
  11. package/src/components/ApprovalPrompt.tsx +38 -0
  12. package/src/components/Banner.tsx +58 -0
  13. package/src/components/Markdown.tsx +183 -0
  14. package/src/components/ModeHint.tsx +40 -0
  15. package/src/components/ModelPicker.tsx +269 -0
  16. package/src/components/Onboarding.tsx +203 -0
  17. package/src/components/PlanConfirm.tsx +37 -0
  18. package/src/components/PrivateerLogin.tsx +109 -0
  19. package/src/components/PromptInput.tsx +602 -0
  20. package/src/components/RewindPicker.tsx +69 -0
  21. package/src/components/Root.tsx +95 -0
  22. package/src/components/SessionPicker.tsx +64 -0
  23. package/src/components/StatusBar.tsx +121 -0
  24. package/src/components/TodoPanel.tsx +36 -0
  25. package/src/components/ToolCallView.tsx +109 -0
  26. package/src/components/Transcript.tsx +203 -0
  27. package/src/components/figures.ts +13 -0
  28. package/src/components/promptModel.ts +73 -0
  29. package/src/components/spinnerVerbs.ts +46 -0
  30. package/src/components/theme.ts +55 -0
  31. package/src/components/types.ts +34 -0
  32. package/src/components/useTeeShield.ts +104 -0
  33. package/src/components/useTerminalWidth.ts +24 -0
  34. package/src/components/useZdrShield.ts +126 -0
  35. package/src/config/load.ts +115 -0
  36. package/src/config/paths.ts +61 -0
  37. package/src/config/schema.ts +94 -0
  38. package/src/context/outputStyles.ts +42 -0
  39. package/src/context/projectInfo.ts +59 -0
  40. package/src/context/systemPrompt.ts +167 -0
  41. package/src/engine/QueryEngine.ts +399 -0
  42. package/src/engine/errors.ts +197 -0
  43. package/src/engine/events.ts +74 -0
  44. package/src/engine/router.ts +165 -0
  45. package/src/hooks/engine.ts +155 -0
  46. package/src/main.tsx +167 -0
  47. package/src/mcp/client.ts +236 -0
  48. package/src/mcp/oauth.ts +245 -0
  49. package/src/memory/auto.ts +146 -0
  50. package/src/memory/checkpoints.ts +227 -0
  51. package/src/memory/store.ts +127 -0
  52. package/src/permissions/danger.ts +56 -0
  53. package/src/permissions/gate.ts +38 -0
  54. package/src/permissions/mode.ts +39 -0
  55. package/src/permissions/protected.ts +29 -0
  56. package/src/permissions/uiGate.ts +73 -0
  57. package/src/providers/attestation.ts +149 -0
  58. package/src/providers/capabilities.ts +104 -0
  59. package/src/providers/catalog.ts +66 -0
  60. package/src/providers/models.ts +183 -0
  61. package/src/providers/registry.ts +71 -0
  62. package/src/providers/resolve.ts +78 -0
  63. package/src/remote/relayClient.ts +283 -0
  64. package/src/session.ts +264 -0
  65. package/src/tools/bash.ts +98 -0
  66. package/src/tools/context.ts +114 -0
  67. package/src/tools/edit.ts +67 -0
  68. package/src/tools/exec.ts +60 -0
  69. package/src/tools/glob.ts +39 -0
  70. package/src/tools/grep.ts +86 -0
  71. package/src/tools/index.ts +69 -0
  72. package/src/tools/memory.ts +53 -0
  73. package/src/tools/processRegistry.ts +77 -0
  74. package/src/tools/read.ts +42 -0
  75. package/src/tools/saveAttachment.ts +53 -0
  76. package/src/tools/task.ts +52 -0
  77. package/src/tools/todo.ts +36 -0
  78. package/src/tools/todoStore.ts +31 -0
  79. package/src/tools/walk.ts +44 -0
  80. package/src/tools/web.ts +145 -0
  81. package/src/tools/write.ts +40 -0
  82. package/src/util/attachmentStore.ts +72 -0
  83. package/src/util/images.ts +343 -0
  84. package/src/util/limit.ts +32 -0
  85. package/src/util/redact.ts +44 -0
  86. package/src/version.ts +13 -0
@@ -0,0 +1,602 @@
1
+ import React, { useMemo, useRef, useState } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { theme } from "./theme.ts";
4
+ import { POINTER, TREE } from "./figures.ts";
5
+ import { walkFiles } from "../tools/walk.ts";
6
+ import { detectMode, slashQuery, mentionAt, filterCommands, filterFiles } from "./promptModel.ts";
7
+ import { resolveAttachments, chipFor, describeAttachment } from "../util/images.ts";
8
+ import type { Attachment } from "../util/images.ts";
9
+
10
+ const MENU_LIMIT = 8;
11
+
12
+ interface Candidate {
13
+ value: string; // text used when accepting
14
+ label: string; // primary display
15
+ hint?: string; // secondary, dimmed
16
+ }
17
+
18
+ const MODE_TAG: Record<string, { label: string; color: string } | undefined> = {
19
+ bash: { label: "bash", color: theme.warning },
20
+ memory: { label: "memory", color: theme.success },
21
+ command: { label: "command", color: theme.accent },
22
+ };
23
+
24
+ export function PromptInput({
25
+ busy,
26
+ cwd,
27
+ queued,
28
+ vimEnabled = false,
29
+ commands,
30
+ history,
31
+ imageSeqRef,
32
+ pendingImagesRef,
33
+ onSubmit,
34
+ onClear,
35
+ }: {
36
+ busy: boolean;
37
+ cwd: string;
38
+ queued: number;
39
+ vimEnabled?: boolean;
40
+ commands: { name: string; summary: string }[];
41
+ history: React.MutableRefObject<string[]>;
42
+ // Shared session counter for "[Image #n]" chips and the staging area where
43
+ // live-resolved (drag-drop/paste) attachments wait to be claimed at submit.
44
+ // Both optional so the component can render without image support (tests).
45
+ imageSeqRef?: React.MutableRefObject<number>;
46
+ pendingImagesRef?: React.MutableRefObject<Attachment[]>;
47
+ onSubmit: (value: string) => void;
48
+ onClear?: () => void;
49
+ }) {
50
+ // value + cursor live in one state object so edits compose via functional
51
+ // updates — robust to batched keystroke bursts (and to test harness input).
52
+ const [{ value, cursor }, setBufState] = useState<{ value: string; cursor: number }>({
53
+ value: "",
54
+ cursor: 0,
55
+ });
56
+ const [sel, setSel] = useState(0);
57
+ const [dismissed, setDismissed] = useState(false);
58
+ const [histIdx, setHistIdx] = useState<number | null>(null);
59
+ // Vim modal editing: "insert" is the normal text-entry mode; "normal" is the
60
+ // motion/command mode. Only consulted when vimEnabled.
61
+ const [vimMode, setVimMode] = useState<"insert" | "normal">("insert");
62
+ const pendingOpRef = useRef<string | null>(null); // for two-key ops like `dd`
63
+ // ctrl-r reverse history search: query + index among matches, or null when off.
64
+ const [search, setSearch] = useState<{ query: string; index: number } | null>(null);
65
+ // Always-current mirror of the buffer. The useInput closure can lag a render
66
+ // behind in Ink, so logic that must read the live value (submit) reads this.
67
+ // Assigned during render (not in an effect) so it's current the moment the
68
+ // matching frame is committed.
69
+ const bufRef = useRef({ value, cursor });
70
+ bufRef.current = { value, cursor };
71
+ // Same render-synced mirror for the modal flags the input handler reads.
72
+ const vimModeRef = useRef(vimMode);
73
+ vimModeRef.current = vimMode;
74
+ const searchRef = useRef(search);
75
+ searchRef.current = search;
76
+ // Most recently killed text (Ctrl+K/U/W, Alt+D); re-inserted by Ctrl+Y.
77
+ const killRef = useRef<string>("");
78
+ // File list is walked lazily on first @-mention so we don't pay for it at mount.
79
+ const filesRef = useRef<string[] | null>(null);
80
+ const getFiles = () => (filesRef.current ??= walkFiles(cwd));
81
+
82
+ const mode = detectMode(value);
83
+ const sQuery = slashQuery(value, cursor);
84
+ const mention = mentionAt(value, cursor);
85
+
86
+ const { candidates, menuKind } = useMemo((): {
87
+ candidates: Candidate[];
88
+ menuKind: "command" | "file" | null;
89
+ } => {
90
+ if (dismissed) return { candidates: [], menuKind: null };
91
+ if (sQuery !== null) {
92
+ const items: Candidate[] = filterCommands(commands, sQuery).map((c) => ({
93
+ value: c.name,
94
+ label: `/${c.name}`,
95
+ hint: c.summary,
96
+ }));
97
+ return { candidates: items, menuKind: "command" };
98
+ }
99
+ if (mention) {
100
+ const items: Candidate[] = filterFiles(getFiles(), mention.query, MENU_LIMIT).map((f) => ({
101
+ value: f,
102
+ label: f,
103
+ }));
104
+ return { candidates: items, menuKind: "file" };
105
+ }
106
+ return { candidates: [], menuKind: null };
107
+ // eslint-disable-next-line react-hooks/exhaustive-deps
108
+ }, [value, cursor, dismissed, commands]);
109
+
110
+ const menuOpen = menuKind !== null && candidates.length > 0;
111
+ const selClamped = Math.min(sel, Math.max(0, candidates.length - 1));
112
+
113
+ // --- cursor / buffer helpers ---
114
+ type Buf = { value: string; cursor: number };
115
+ const clamp = (v: string, c: number) => Math.max(0, Math.min(c, v.length));
116
+ const lineStart = (v: string, c: number) => v.lastIndexOf("\n", c - 1) + 1;
117
+ const lineEnd = (v: string, c: number) => {
118
+ const nl = v.indexOf("\n", c);
119
+ return nl === -1 ? v.length : nl;
120
+ };
121
+ // Word motions (vim w/b): skip the current run then any whitespace.
122
+ function wordForward(v: string, c: number): number {
123
+ let i = c;
124
+ while (i < v.length && !/\s/.test(v[i])) i++;
125
+ while (i < v.length && /\s/.test(v[i])) i++;
126
+ return i;
127
+ }
128
+ function wordBack(v: string, c: number): number {
129
+ let i = c;
130
+ while (i > 0 && /\s/.test(v[i - 1])) i--;
131
+ while (i > 0 && !/\s/.test(v[i - 1])) i--;
132
+ return i;
133
+ }
134
+ // Readline-style forward word (Alt+F / Alt+D): skip leading whitespace then
135
+ // the word, landing at the *end* of the word (unlike vim's `w` above).
136
+ function wordRight(v: string, c: number): number {
137
+ let i = c;
138
+ while (i < v.length && /\s/.test(v[i])) i++;
139
+ while (i < v.length && !/\s/.test(v[i])) i++;
140
+ return i;
141
+ }
142
+ // History entries (newest first, de-duped) containing `query`, for ctrl-r search.
143
+ function searchMatches(query: string): string[] {
144
+ const seen = new Set<string>();
145
+ const out: string[] = [];
146
+ for (let i = history.current.length - 1; i >= 0; i--) {
147
+ const e = history.current[i];
148
+ if (e.includes(query) && !seen.has(e)) {
149
+ seen.add(e);
150
+ out.push(e);
151
+ }
152
+ }
153
+ return out;
154
+ }
155
+
156
+ // Edit the buffer from its previous value (burst-safe) and reset menu/history.
157
+ function edit(fn: (b: Buf) => Buf) {
158
+ setBufState((b) => {
159
+ const n = fn(b);
160
+ return { value: n.value, cursor: clamp(n.value, n.cursor) };
161
+ });
162
+ setHistIdx(null);
163
+ setDismissed(false);
164
+ setSel(0);
165
+ }
166
+ // Move the cursor only, preserving menu/history context.
167
+ function moveCursor(fn: (b: Buf) => number) {
168
+ setBufState((b) => ({ value: b.value, cursor: clamp(b.value, fn(b)) }));
169
+ }
170
+ function replaceBuf(v: string, c: number) {
171
+ setBufState({ value: v, cursor: clamp(v, c) });
172
+ }
173
+
174
+ function accept(cand: Candidate) {
175
+ if (menuKind === "command") {
176
+ const next = `/${cand.value} `;
177
+ edit(() => ({ value: next, cursor: next.length }));
178
+ } else if (menuKind === "file" && mention) {
179
+ edit((b) => {
180
+ const before = b.value.slice(0, mention.start);
181
+ const token = `@${cand.value} `;
182
+ return { value: before + token + b.value.slice(b.cursor), cursor: before.length + token.length };
183
+ });
184
+ }
185
+ }
186
+
187
+ function submit() {
188
+ const v = bufRef.current.value;
189
+ if (v.trim().length === 0) return;
190
+ history.current.push(v);
191
+ replaceBuf("", 0);
192
+ setHistIdx(null);
193
+ setDismissed(false);
194
+ setSel(0);
195
+ setVimMode("insert");
196
+ onSubmit(v);
197
+ }
198
+
199
+ function histStep(dir: -1 | 1) {
200
+ const h = history.current;
201
+ if (h.length === 0) return;
202
+ let idx: number;
203
+ if (dir === -1) idx = histIdx === null ? h.length - 1 : Math.max(0, histIdx - 1);
204
+ else {
205
+ if (histIdx === null) return;
206
+ idx = histIdx + 1;
207
+ if (idx >= h.length) {
208
+ replaceBuf("", 0);
209
+ setHistIdx(null);
210
+ return;
211
+ }
212
+ }
213
+ replaceBuf(h[idx], h[idx].length);
214
+ setHistIdx(idx);
215
+ }
216
+
217
+ function moveLine(dir: -1 | 1) {
218
+ setBufState((b) => {
219
+ const ls = lineStart(b.value, b.cursor);
220
+ const col = b.cursor - ls;
221
+ if (dir === -1) {
222
+ if (ls === 0) return b;
223
+ const prevStart = lineStart(b.value, ls - 1);
224
+ return { value: b.value, cursor: prevStart + Math.min(col, ls - 1 - prevStart) };
225
+ }
226
+ const le = lineEnd(b.value, b.cursor);
227
+ if (le === b.value.length) return b;
228
+ const nextStart = le + 1;
229
+ return { value: b.value, cursor: nextStart + Math.min(col, lineEnd(b.value, nextStart) - nextStart) };
230
+ });
231
+ }
232
+
233
+ // Current ctrl-r reverse-search results (newest first) and the highlighted match.
234
+ const searchResults = search ? searchMatches(search.query) : [];
235
+ const searchMatch = search
236
+ ? (searchResults[Math.min(search.index, Math.max(0, searchResults.length - 1))] ?? "")
237
+ : "";
238
+
239
+ function acceptSearch() {
240
+ replaceBuf(searchMatch, searchMatch.length);
241
+ setSearch(null);
242
+ }
243
+
244
+ // Keys while the reverse-search prompt is active.
245
+ function handleSearchKey(str: string, key: { return?: boolean; escape?: boolean; backspace?: boolean; delete?: boolean; ctrl?: boolean; leftArrow?: boolean; rightArrow?: boolean }) {
246
+ if (key.ctrl && str === "r") return void setSearch((s) => (s ? { ...s, index: s.index + 1 } : s));
247
+ if (key.return) return void acceptSearch();
248
+ if (key.leftArrow || key.rightArrow) return void acceptSearch();
249
+ if (key.escape || (key.ctrl && str === "c")) return void setSearch(null);
250
+ if (key.backspace || key.delete) return void setSearch((s) => (s ? { query: s.query.slice(0, -1), index: 0 } : s));
251
+ if (key.ctrl || !str) return;
252
+ setSearch((s) => (s ? { query: s.query + str, index: 0 } : s));
253
+ }
254
+
255
+ // Vim normal-mode keys. Returns true when the key was consumed.
256
+ function handleNormalKey(str: string): boolean {
257
+ const enterInsert = () => setVimMode("insert");
258
+ // Two-key operator: dd deletes the current line.
259
+ if (pendingOpRef.current === "d") {
260
+ pendingOpRef.current = null;
261
+ if (str === "d") {
262
+ edit((b) => {
263
+ const ls = lineStart(b.value, b.cursor);
264
+ const le = lineEnd(b.value, b.cursor);
265
+ const start = ls > 0 && le === b.value.length ? ls - 1 : ls;
266
+ const end = b.value[le] === "\n" ? le + 1 : le;
267
+ return { value: b.value.slice(0, start) + b.value.slice(end), cursor: start };
268
+ });
269
+ return true;
270
+ }
271
+ }
272
+ switch (str) {
273
+ case "i":
274
+ enterInsert();
275
+ return true;
276
+ case "a":
277
+ moveCursor((b) => b.cursor + 1);
278
+ enterInsert();
279
+ return true;
280
+ case "I":
281
+ moveCursor((b) => lineStart(b.value, b.cursor));
282
+ enterInsert();
283
+ return true;
284
+ case "A":
285
+ moveCursor((b) => lineEnd(b.value, b.cursor));
286
+ enterInsert();
287
+ return true;
288
+ case "o":
289
+ edit((b) => {
290
+ const le = lineEnd(b.value, b.cursor);
291
+ return { value: b.value.slice(0, le) + "\n" + b.value.slice(le), cursor: le + 1 };
292
+ });
293
+ enterInsert();
294
+ return true;
295
+ case "O":
296
+ edit((b) => {
297
+ const ls = lineStart(b.value, b.cursor);
298
+ return { value: b.value.slice(0, ls) + "\n" + b.value.slice(ls), cursor: ls };
299
+ });
300
+ enterInsert();
301
+ return true;
302
+ case "h":
303
+ moveCursor((b) => b.cursor - 1);
304
+ return true;
305
+ case "l":
306
+ moveCursor((b) => b.cursor + 1);
307
+ return true;
308
+ case "k":
309
+ if (value.includes("\n")) moveLine(-1);
310
+ else histStep(-1);
311
+ return true;
312
+ case "j":
313
+ if (value.includes("\n")) moveLine(1);
314
+ else histStep(1);
315
+ return true;
316
+ case "0":
317
+ moveCursor((b) => lineStart(b.value, b.cursor));
318
+ return true;
319
+ case "$":
320
+ moveCursor((b) => lineEnd(b.value, b.cursor));
321
+ return true;
322
+ case "w":
323
+ moveCursor((b) => wordForward(b.value, b.cursor));
324
+ return true;
325
+ case "b":
326
+ moveCursor((b) => wordBack(b.value, b.cursor));
327
+ return true;
328
+ case "x":
329
+ edit((b) => ({ value: b.value.slice(0, b.cursor) + b.value.slice(b.cursor + 1), cursor: b.cursor }));
330
+ return true;
331
+ case "D":
332
+ edit((b) => ({ value: b.value.slice(0, b.cursor) + b.value.slice(lineEnd(b.value, b.cursor)), cursor: b.cursor }));
333
+ return true;
334
+ case "C":
335
+ edit((b) => ({ value: b.value.slice(0, b.cursor) + b.value.slice(lineEnd(b.value, b.cursor)), cursor: b.cursor }));
336
+ enterInsert();
337
+ return true;
338
+ case "d":
339
+ pendingOpRef.current = "d";
340
+ return true;
341
+ default:
342
+ return true; // swallow other keys in normal mode
343
+ }
344
+ }
345
+
346
+ useInput((str, key) => {
347
+ // Reverse-search intercepts everything while active.
348
+ if (searchRef.current) return void handleSearchKey(str, key);
349
+ if (key.ctrl && str === "r") return void setSearch({ query: "", index: 0 });
350
+
351
+ // Menu navigation takes priority over history / line moves.
352
+ if (menuOpen && (key.upArrow || key.downArrow)) {
353
+ const n = candidates.length;
354
+ setSel((s) => (key.upArrow ? (Math.min(s, n - 1) - 1 + n) % n : (Math.min(s, n - 1) + 1) % n));
355
+ return;
356
+ }
357
+ // Plain Tab accepts the menu selection; Shift+Tab is reserved for the
358
+ // app-level permission-mode cycle, so let it fall through.
359
+ if (key.tab && !key.shift && menuOpen) {
360
+ accept(candidates[selClamped]);
361
+ return;
362
+ }
363
+ if (key.return) {
364
+ // Backslash-Enter inserts a newline instead of submitting (line continuation).
365
+ const b0 = bufRef.current;
366
+ if (b0.cursor > 0 && b0.value[b0.cursor - 1] === "\\") {
367
+ edit((b) => ({
368
+ value: b.value.slice(0, b.cursor - 1) + "\n" + b.value.slice(b.cursor),
369
+ cursor: b.cursor,
370
+ }));
371
+ return;
372
+ }
373
+ if (menuOpen) {
374
+ accept(candidates[selClamped]);
375
+ return;
376
+ }
377
+ submit();
378
+ return;
379
+ }
380
+ if (key.escape) {
381
+ if (menuOpen) setDismissed(true);
382
+ else if (vimEnabled && vimModeRef.current === "insert") setVimMode("normal");
383
+ return;
384
+ }
385
+ // Arrows work in both vim modes.
386
+ // Ctrl/Alt+arrow jumps by word (must precede the plain one-char moves).
387
+ if (key.leftArrow && (key.ctrl || key.meta)) return void moveCursor((b) => wordBack(b.value, b.cursor));
388
+ if (key.rightArrow && (key.ctrl || key.meta)) return void moveCursor((b) => wordRight(b.value, b.cursor));
389
+ if (key.leftArrow) return void moveCursor((b) => b.cursor - 1);
390
+ if (key.rightArrow) return void moveCursor((b) => b.cursor + 1);
391
+ if (key.upArrow) {
392
+ if (value.includes("\n")) moveLine(-1);
393
+ else histStep(-1);
394
+ return;
395
+ }
396
+ if (key.downArrow) {
397
+ if (value.includes("\n")) moveLine(1);
398
+ else histStep(1);
399
+ return;
400
+ }
401
+ // Vim normal mode consumes letters as motions/commands.
402
+ if (vimEnabled && vimModeRef.current === "normal" && !key.ctrl && !key.meta && str) {
403
+ if (handleNormalKey(str)) return;
404
+ }
405
+ // Alt/Option+Backspace deletes the word before the cursor (readline
406
+ // backward-kill-word; macOS Option+Delete sends ESC + DEL, i.e. meta+backspace).
407
+ // Must precede the plain backspace handler below, which checks neither modifier
408
+ // and would otherwise swallow it as a single-char delete. Saves to killRef so
409
+ // Ctrl+Y can yank it back, matching Ctrl+W.
410
+ if ((key.meta || key.ctrl) && (key.backspace || key.delete))
411
+ return void edit((b) => {
412
+ let i = b.cursor;
413
+ while (i > 0 && /\s/.test(b.value[i - 1])) i--;
414
+ while (i > 0 && !/\s/.test(b.value[i - 1])) i--;
415
+ killRef.current = b.value.slice(i, b.cursor);
416
+ return { value: b.value.slice(0, i) + b.value.slice(b.cursor), cursor: i };
417
+ });
418
+ if (key.backspace || key.delete) {
419
+ edit((b) =>
420
+ b.cursor === 0
421
+ ? b
422
+ : { value: b.value.slice(0, b.cursor - 1) + b.value.slice(b.cursor), cursor: b.cursor - 1 },
423
+ );
424
+ return;
425
+ }
426
+ // Emacs/readline-style line editing.
427
+ if (key.ctrl && str === "a") return void moveCursor((b) => lineStart(b.value, b.cursor));
428
+ if (key.ctrl && str === "e") return void moveCursor((b) => lineEnd(b.value, b.cursor));
429
+ if (key.ctrl && str === "b") return void moveCursor((b) => b.cursor - 1);
430
+ if (key.ctrl && str === "f") return void moveCursor((b) => b.cursor + 1);
431
+ if (key.meta && !key.escape && str === "b") return void moveCursor((b) => wordBack(b.value, b.cursor));
432
+ if (key.meta && !key.escape && str === "f") return void moveCursor((b) => wordRight(b.value, b.cursor));
433
+ if (key.ctrl && str === "u")
434
+ return void edit((b) => {
435
+ const ls = lineStart(b.value, b.cursor);
436
+ killRef.current = b.value.slice(ls, b.cursor);
437
+ return { value: b.value.slice(0, ls) + b.value.slice(b.cursor), cursor: ls };
438
+ });
439
+ if (key.ctrl && str === "k")
440
+ return void edit((b) => {
441
+ const le = lineEnd(b.value, b.cursor);
442
+ killRef.current = b.value.slice(b.cursor, le);
443
+ return { value: b.value.slice(0, b.cursor) + b.value.slice(le), cursor: b.cursor };
444
+ });
445
+ if (key.ctrl && str === "w")
446
+ return void edit((b) => {
447
+ let i = b.cursor;
448
+ while (i > 0 && /\s/.test(b.value[i - 1])) i--;
449
+ while (i > 0 && !/\s/.test(b.value[i - 1])) i--;
450
+ killRef.current = b.value.slice(i, b.cursor);
451
+ return { value: b.value.slice(0, i) + b.value.slice(b.cursor), cursor: i };
452
+ });
453
+ if (key.meta && !key.escape && str === "d")
454
+ return void edit((b) => {
455
+ const j = wordRight(b.value, b.cursor);
456
+ killRef.current = b.value.slice(b.cursor, j);
457
+ return { value: b.value.slice(0, b.cursor) + b.value.slice(j), cursor: b.cursor };
458
+ });
459
+ if (key.ctrl && str === "d")
460
+ return void edit((b) =>
461
+ b.cursor >= b.value.length
462
+ ? b
463
+ : { value: b.value.slice(0, b.cursor) + b.value.slice(b.cursor + 1), cursor: b.cursor },
464
+ );
465
+ if (key.ctrl && str === "y")
466
+ return void edit((b) => ({
467
+ value: b.value.slice(0, b.cursor) + killRef.current + b.value.slice(b.cursor),
468
+ cursor: b.cursor + killRef.current.length,
469
+ }));
470
+ if (key.ctrl && str === "l") return void onClear?.();
471
+ if (key.ctrl || key.meta) return; // ignore other control combos (ctrl-c handled in App)
472
+ if (str) {
473
+ // A multi-char chunk is a paste or drag-drop (a terminal sends a dropped
474
+ // file as one escaped path). If it carries attachable file paths, convert them
475
+ // to "[Kind #n]" chips live — so the user sees a short reference instead of a
476
+ // long escaped path — and stage the base64 for the next submit. Single
477
+ // keystrokes are never paths, so they skip this and insert verbatim. (Inlined
478
+ // text files are resolved at submit, not here, so the buffer stays clean.)
479
+ // inlineMaxBytes 0 → text files aren't inlined here; their raw paths stay in the
480
+ // buffer and get inlined at submit (runTurn). Only binary attachments chip live.
481
+ if (str.length > 1 && imageSeqRef && pendingImagesRef) {
482
+ const resolved = resolveAttachments(str, cwd, imageSeqRef.current, 0);
483
+ if (resolved.attachments.length > 0) {
484
+ imageSeqRef.current += resolved.attachments.length;
485
+ pendingImagesRef.current.push(...resolved.attachments);
486
+ const chunk = resolved.text;
487
+ edit((b) => ({
488
+ value: b.value.slice(0, b.cursor) + chunk + b.value.slice(b.cursor),
489
+ cursor: b.cursor + chunk.length,
490
+ }));
491
+ return;
492
+ }
493
+ }
494
+ edit((b) => ({
495
+ value: b.value.slice(0, b.cursor) + str + b.value.slice(b.cursor),
496
+ cursor: b.cursor + str.length,
497
+ }));
498
+ }
499
+ });
500
+
501
+ const tag = MODE_TAG[mode];
502
+ const placeholder = busy
503
+ ? queued > 0
504
+ ? `working… (${queued} queued)`
505
+ : "working… (type to queue)"
506
+ : "type a prompt — / commands · @ files · ! bash · # memory";
507
+
508
+ const vimTag = vimEnabled ? (vimMode === "normal" ? "NORMAL" : "INSERT") : null;
509
+
510
+ // Live provenance for staged drag-drop/paste attachments still referenced in the
511
+ // buffer: filename · dimensions · size. Surfaces a wrong-but-complete capture (the
512
+ // macOS file-promise hazard) before submit — chips alone hide what actually landed.
513
+ // Derived from the buffer text so an edited-away chip drops its line automatically.
514
+ const stagedInBuffer = pendingImagesRef
515
+ ? pendingImagesRef.current.filter((a) => value.includes(chipFor(a)))
516
+ : [];
517
+
518
+ return (
519
+ <Box flexDirection="column">
520
+ <Box borderStyle="round" borderColor={busy ? theme.dim : theme.accent} paddingX={1}>
521
+ <Text color={busy ? theme.dim : theme.accent}>{`${POINTER} `}</Text>
522
+ <Box flexDirection="column" flexGrow={1}>
523
+ {search ? (
524
+ <Text>
525
+ <Text color={theme.dim}>{`(reverse-i-search)\`${search.query}\`: `}</Text>
526
+ {searchMatch || <Text color={theme.dim}>(no match)</Text>}
527
+ </Text>
528
+ ) : (
529
+ renderBuffer(value, cursor, placeholder)
530
+ )}
531
+ </Box>
532
+ {vimTag && (
533
+ <Text color={vimMode === "normal" ? theme.warning : theme.dim}> {vimTag}</Text>
534
+ )}
535
+ {tag && (
536
+ <Text color={tag.color}> [{tag.label}]</Text>
537
+ )}
538
+ </Box>
539
+
540
+ {stagedInBuffer.length > 0 && (
541
+ <Box flexDirection="column" marginLeft={1}>
542
+ {stagedInBuffer.map((a) => (
543
+ <Text key={a.n} color={theme.dim} wrap="truncate-end">
544
+ {`${TREE} ${chipFor(a)} ${describeAttachment(a)}`}
545
+ </Text>
546
+ ))}
547
+ </Box>
548
+ )}
549
+
550
+ {menuOpen && !search && (
551
+ <Box flexDirection="column" paddingX={1} marginLeft={1}>
552
+ {candidates.map((c, i) => (
553
+ <Box key={c.value} gap={1}>
554
+ <Text color={i === selClamped ? theme.accent : theme.dim}>
555
+ {i === selClamped ? POINTER : " "}
556
+ </Text>
557
+ <Text color={i === selClamped ? theme.accent : undefined}>{c.label}</Text>
558
+ {c.hint && <Text color={theme.dim}>{c.hint}</Text>}
559
+ </Box>
560
+ ))}
561
+ </Box>
562
+ )}
563
+ </Box>
564
+ );
565
+ }
566
+
567
+ // Render the buffer with a block cursor, wrapping across newlines.
568
+ function renderBuffer(value: string, cursor: number, placeholder: string): React.ReactNode {
569
+ if (value.length === 0) {
570
+ return (
571
+ <Text wrap="truncate-end">
572
+ <Text inverse> </Text>
573
+ <Text color={theme.dim}>{placeholder}</Text>
574
+ </Text>
575
+ );
576
+ }
577
+ const lines = value.split("\n");
578
+ let idx = 0;
579
+ let curLine = 0;
580
+ let curCol = cursor;
581
+ for (let i = 0; i < lines.length; i++) {
582
+ if (cursor <= idx + lines[i].length) {
583
+ curLine = i;
584
+ curCol = cursor - idx;
585
+ break;
586
+ }
587
+ idx += lines[i].length + 1;
588
+ }
589
+ return lines.map((ln, i) => {
590
+ if (i !== curLine) return <Text key={i}>{ln.length ? ln : " "}</Text>;
591
+ const before = ln.slice(0, curCol);
592
+ const at = ln.slice(curCol, curCol + 1) || " ";
593
+ const after = ln.slice(curCol + 1);
594
+ return (
595
+ <Text key={i}>
596
+ {before}
597
+ <Text inverse>{at}</Text>
598
+ {after}
599
+ </Text>
600
+ );
601
+ });
602
+ }
@@ -0,0 +1,69 @@
1
+ import React, { useState } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { theme } from "./theme.ts";
4
+ import { POINTER } from "./figures.ts";
5
+ import type { Checkpoint, RewindScope } from "../memory/checkpoints.ts";
6
+
7
+ function ago(ts: number): string {
8
+ const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
9
+ if (s < 60) return `${s}s ago`;
10
+ if (s < 3600) return `${Math.round(s / 60)}m ago`;
11
+ return `${Math.round(s / 3600)}h ago`;
12
+ }
13
+
14
+ // Lists checkpoints (newest first). Move with ↑/↓ (or j/k); restore with a scope
15
+ // key — b/Enter both, c conversation, f files; Esc cancels.
16
+ export function RewindPicker({
17
+ checkpoints,
18
+ onRestore,
19
+ onCancel,
20
+ }: {
21
+ checkpoints: Checkpoint[];
22
+ onRestore: (id: string, scope: RewindScope) => void;
23
+ onCancel: () => void;
24
+ }) {
25
+ const items = [...checkpoints].reverse(); // newest first
26
+ const [sel, setSel] = useState(0);
27
+
28
+ useInput((input, key) => {
29
+ if (key.escape) return void onCancel();
30
+ if (items.length === 0) return;
31
+ if (key.upArrow || input === "k") return void setSel((s) => (s - 1 + items.length) % items.length);
32
+ if (key.downArrow || input === "j") return void setSel((s) => (s + 1) % items.length);
33
+ const cp = items[Math.min(sel, items.length - 1)];
34
+ if (key.return || input === "b") return void onRestore(cp.id, "both");
35
+ if (input === "c") return void onRestore(cp.id, "conversation");
36
+ if (input === "f") return void onRestore(cp.id, "files");
37
+ });
38
+
39
+ if (items.length === 0) {
40
+ return (
41
+ <Box flexDirection="column" borderStyle="round" borderColor={theme.accent} paddingX={1}>
42
+ <Text color={theme.dim}>No checkpoints yet — they're taken before each turn. Esc to close.</Text>
43
+ </Box>
44
+ );
45
+ }
46
+
47
+ return (
48
+ <Box flexDirection="column" borderStyle="round" borderColor={theme.accent} paddingX={1}>
49
+ <Text color={theme.accent}>Rewind to a checkpoint</Text>
50
+ {items.map((cp, i) => {
51
+ const active = i === Math.min(sel, items.length - 1);
52
+ const nFiles = Object.keys(cp.files).length;
53
+ return (
54
+ <Box key={cp.id} gap={1}>
55
+ <Text color={active ? theme.accent : theme.dim}>{active ? POINTER : " "}</Text>
56
+ <Text color={active ? theme.accent : undefined}>{cp.label}</Text>
57
+ <Text color={theme.dim}>
58
+ ({ago(cp.ts)}{nFiles ? `, ${nFiles} file${nFiles === 1 ? "" : "s"}` : ""})
59
+ </Text>
60
+ </Box>
61
+ );
62
+ })}
63
+ <Text color={theme.dim}>
64
+ <Text color={theme.accent}>b</Text>/enter both · <Text color={theme.accent}>c</Text> conversation ·{" "}
65
+ <Text color={theme.accent}>f</Text> files · esc cancel
66
+ </Text>
67
+ </Box>
68
+ );
69
+ }