@nanhara/hara 0.122.6 → 0.123.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/README.md +22 -6
- package/dist/agent/loop.js +3 -2
- package/dist/config.js +70 -11
- package/dist/context/workspace-scope.js +4 -4
- package/dist/index.js +206 -44
- package/dist/org-fleet/enroll.js +30 -4
- package/dist/security/guardian.js +13 -5
- package/dist/security/private-state.js +3 -1
- package/dist/serve/protocol.js +2 -1
- package/dist/serve/server.js +125 -50
- package/dist/serve/sessions.js +12 -7
- package/dist/session/store.js +32 -3
- package/dist/session/task.js +161 -0
- package/dist/tui/App.js +21 -9
- package/dist/tui/InputBox.js +61 -18
- package/dist/tui/bracketed-paste.js +265 -0
- package/dist/tui/run.js +8 -2
- package/package.json +1 -1
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
export const TASK_SCHEMA_VERSION = 1;
|
|
3
|
+
export const MAX_TASK_OBJECTIVE_CHARS = 4096;
|
|
4
|
+
export const MAX_TASK_STEERING_CHARS = 4096;
|
|
5
|
+
export const MAX_TASK_STEERING_ENTRIES = 24;
|
|
6
|
+
function iso(at = new Date()) {
|
|
7
|
+
return typeof at === "string" ? at : at.toISOString();
|
|
8
|
+
}
|
|
9
|
+
function boundedText(value, max) {
|
|
10
|
+
const normalized = value.replace(/\r\n?/g, "\n").trim();
|
|
11
|
+
return (normalized || "(image-only task)").slice(0, max);
|
|
12
|
+
}
|
|
13
|
+
function validId(value) {
|
|
14
|
+
return typeof value === "string" && value.length > 0 && value.length <= 220 && !/[\\/\0]/.test(value);
|
|
15
|
+
}
|
|
16
|
+
function validTimestamp(value) {
|
|
17
|
+
return typeof value === "string" && value.length > 0 && Number.isFinite(Date.parse(value));
|
|
18
|
+
}
|
|
19
|
+
export function newTurnInteraction() {
|
|
20
|
+
return { kind: "turn", turnId: randomUUID() };
|
|
21
|
+
}
|
|
22
|
+
export function newSteerInteraction(expectedTurnId) {
|
|
23
|
+
return { kind: "steer", expectedTurnId, turnId: randomUUID() };
|
|
24
|
+
}
|
|
25
|
+
export function createTaskExecution(objective, turnId, at = new Date()) {
|
|
26
|
+
const now = iso(at);
|
|
27
|
+
return {
|
|
28
|
+
schemaVersion: TASK_SCHEMA_VERSION,
|
|
29
|
+
id: randomUUID(),
|
|
30
|
+
objective: boundedText(objective, MAX_TASK_OBJECTIVE_CHARS),
|
|
31
|
+
status: "running",
|
|
32
|
+
turnId,
|
|
33
|
+
createdAt: now,
|
|
34
|
+
updatedAt: now,
|
|
35
|
+
startedAt: now,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export function continueTaskExecution(task, interaction, at = new Date()) {
|
|
39
|
+
if (!task)
|
|
40
|
+
return { ok: false, reason: "there is no task to steer" };
|
|
41
|
+
if (task.turnId !== interaction.expectedTurnId) {
|
|
42
|
+
return { ok: false, reason: `stale steer for turn ${interaction.expectedTurnId}; active turn is ${task.turnId}` };
|
|
43
|
+
}
|
|
44
|
+
const now = iso(at);
|
|
45
|
+
return {
|
|
46
|
+
ok: true,
|
|
47
|
+
task: {
|
|
48
|
+
...task,
|
|
49
|
+
status: "running",
|
|
50
|
+
turnId: interaction.turnId,
|
|
51
|
+
updatedAt: now,
|
|
52
|
+
startedAt: now,
|
|
53
|
+
endedAt: undefined,
|
|
54
|
+
lastOutcome: undefined,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export function recordTaskSteering(task, expectedTurnId, content, at = new Date()) {
|
|
59
|
+
if (!task)
|
|
60
|
+
return { ok: false, reason: "there is no running task to steer" };
|
|
61
|
+
if (task.status !== "running")
|
|
62
|
+
return { ok: false, reason: `task ${task.id} is ${task.status}, not running` };
|
|
63
|
+
if (task.turnId !== expectedTurnId) {
|
|
64
|
+
return { ok: false, reason: `stale steer for turn ${expectedTurnId}; active turn is ${task.turnId}` };
|
|
65
|
+
}
|
|
66
|
+
const now = iso(at);
|
|
67
|
+
const steering = [
|
|
68
|
+
...(task.steering ?? []),
|
|
69
|
+
{ id: randomUUID(), turnId: expectedTurnId, content: boundedText(content, MAX_TASK_STEERING_CHARS), createdAt: now },
|
|
70
|
+
].slice(-MAX_TASK_STEERING_ENTRIES);
|
|
71
|
+
return { ok: true, task: { ...task, steering, updatedAt: now } };
|
|
72
|
+
}
|
|
73
|
+
export function finishTaskExecution(task, outcome, todos = [], interrupted = false, at = new Date()) {
|
|
74
|
+
if (!task)
|
|
75
|
+
return undefined;
|
|
76
|
+
const now = iso(at);
|
|
77
|
+
const incomplete = todos.some((todo) => todo.status !== "done");
|
|
78
|
+
const lastOutcome = interrupted ? "interrupted" : (outcome?.status ?? "interrupted");
|
|
79
|
+
const status = interrupted
|
|
80
|
+
? "paused"
|
|
81
|
+
: outcome?.status === "completed"
|
|
82
|
+
? (incomplete ? "paused" : "completed")
|
|
83
|
+
: outcome?.status === "error" || outcome?.status === "empty" || outcome?.status === "halted"
|
|
84
|
+
? "blocked"
|
|
85
|
+
: "paused";
|
|
86
|
+
return { ...task, status, lastOutcome, updatedAt: now, endedAt: now };
|
|
87
|
+
}
|
|
88
|
+
/** A process died while this task was running. Recovery is explicit and never claims success. */
|
|
89
|
+
export function recoverTaskExecution(task, at = new Date()) {
|
|
90
|
+
if (!task || task.status !== "running")
|
|
91
|
+
return task;
|
|
92
|
+
const now = iso(at);
|
|
93
|
+
return { ...task, status: "paused", lastOutcome: "interrupted", updatedAt: now, endedAt: now };
|
|
94
|
+
}
|
|
95
|
+
export function forkTaskExecution(task, at = new Date()) {
|
|
96
|
+
if (!task)
|
|
97
|
+
return undefined;
|
|
98
|
+
const now = iso(at);
|
|
99
|
+
return {
|
|
100
|
+
...task,
|
|
101
|
+
id: randomUUID(),
|
|
102
|
+
status: "paused",
|
|
103
|
+
turnId: randomUUID(),
|
|
104
|
+
createdAt: now,
|
|
105
|
+
updatedAt: now,
|
|
106
|
+
startedAt: now,
|
|
107
|
+
endedAt: now,
|
|
108
|
+
lastOutcome: "interrupted",
|
|
109
|
+
steering: task.steering?.slice(-MAX_TASK_STEERING_ENTRIES).map((entry) => ({ ...entry })),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
export function taskExecutionContext(task, interaction) {
|
|
113
|
+
const steeringNote = interaction.kind === "steer"
|
|
114
|
+
? "This interaction steers the existing task. Refine execution without replacing its objective."
|
|
115
|
+
: "This interaction created a new task.";
|
|
116
|
+
return [
|
|
117
|
+
"# Task execution (authoritative; separate from conversation history)",
|
|
118
|
+
`Task ID: ${task.id}`,
|
|
119
|
+
`Turn ID: ${task.turnId}`,
|
|
120
|
+
`Objective: ${task.objective}`,
|
|
121
|
+
`Interaction: ${interaction.kind}`,
|
|
122
|
+
steeringNote,
|
|
123
|
+
"Conversation messages provide evidence and refinements, but the task objective above remains authoritative until an explicit new task starts.",
|
|
124
|
+
].join("\n");
|
|
125
|
+
}
|
|
126
|
+
export function formatTaskExecution(task) {
|
|
127
|
+
if (!task)
|
|
128
|
+
return "(no task state)";
|
|
129
|
+
return [
|
|
130
|
+
`task ${task.id.slice(0, 8)} · ${task.status}`,
|
|
131
|
+
`turn ${task.turnId.slice(0, 8)} · outcome ${task.lastOutcome ?? "running"}`,
|
|
132
|
+
`objective: ${task.objective}`,
|
|
133
|
+
`steering: ${task.steering?.length ?? 0}`,
|
|
134
|
+
].join("\n");
|
|
135
|
+
}
|
|
136
|
+
export function isTaskExecution(value) {
|
|
137
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
138
|
+
return false;
|
|
139
|
+
const task = value;
|
|
140
|
+
if (task.schemaVersion !== TASK_SCHEMA_VERSION ||
|
|
141
|
+
!validId(task.id) ||
|
|
142
|
+
typeof task.objective !== "string" || task.objective.length === 0 || task.objective.length > MAX_TASK_OBJECTIVE_CHARS ||
|
|
143
|
+
(task.status !== "running" && task.status !== "paused" && task.status !== "completed" && task.status !== "blocked") ||
|
|
144
|
+
!validId(task.turnId) ||
|
|
145
|
+
!validTimestamp(task.createdAt) || !validTimestamp(task.updatedAt) || !validTimestamp(task.startedAt) ||
|
|
146
|
+
(task.endedAt !== undefined && !validTimestamp(task.endedAt)) ||
|
|
147
|
+
(task.lastOutcome !== undefined && task.lastOutcome !== "completed" && task.lastOutcome !== "error" && task.lastOutcome !== "empty" && task.lastOutcome !== "halted" && task.lastOutcome !== "interrupted"))
|
|
148
|
+
return false;
|
|
149
|
+
if (task.steering === undefined)
|
|
150
|
+
return true;
|
|
151
|
+
if (!Array.isArray(task.steering) || task.steering.length > MAX_TASK_STEERING_ENTRIES)
|
|
152
|
+
return false;
|
|
153
|
+
return task.steering.every((entry) => {
|
|
154
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
155
|
+
return false;
|
|
156
|
+
const steering = entry;
|
|
157
|
+
return validId(steering.id) && validId(steering.turnId) &&
|
|
158
|
+
typeof steering.content === "string" && steering.content.length > 0 && steering.content.length <= MAX_TASK_STEERING_CHARS &&
|
|
159
|
+
validTimestamp(steering.createdAt);
|
|
160
|
+
});
|
|
161
|
+
}
|
package/dist/tui/App.js
CHANGED
|
@@ -20,6 +20,7 @@ import { clearTodos, currentTodos, onTodosChange } from "../tools/todo.js";
|
|
|
20
20
|
import { onTurnPhase, turnPhase } from "../agent/phase.js";
|
|
21
21
|
import { listJobs, onJobsChange } from "../exec/jobs.js";
|
|
22
22
|
import { ModelPicker } from "./model-picker.js";
|
|
23
|
+
import { newSteerInteraction, newTurnInteraction } from "../session/task.js";
|
|
23
24
|
let _id = 0;
|
|
24
25
|
const nid = () => ++_id;
|
|
25
26
|
const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
@@ -289,7 +290,7 @@ function StatusRow({ working, todos, queued }) {
|
|
|
289
290
|
// Pre-first-token honesty (codex-parity): "waiting for the model" reads very differently from a
|
|
290
291
|
// generic "working" when the network is slow — the user knows the request is out, not dead.
|
|
291
292
|
const verb = (phase === "waiting" ? `waiting for the model… ${elapsedSec}s · esc to interrupt` : spinnerVerb(todos, elapsedSec)) + bgTag;
|
|
292
|
-
return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: frames[frame % frames.length] }), _jsx(Text, { dimColor: true, children: ` ${verb} · ⏎
|
|
293
|
+
return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: frames[frame % frames.length] }), _jsx(Text, { dimColor: true, children: ` ${verb} · ⏎ steers task${queued ? ` (${queued})` : ""}` })] }));
|
|
293
294
|
}
|
|
294
295
|
// Short per-mode descriptions for the ONE-ROW mode line (the old two-row ModeBar's long sentences
|
|
295
296
|
// don't fit inline). Full behavior is documented in /help; this line is a switching aid, not a manual.
|
|
@@ -366,6 +367,7 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
366
367
|
const [todos, setTodos] = useState(() => currentTodos());
|
|
367
368
|
const ctrlRef = useRef(null);
|
|
368
369
|
const queueRef = useRef([]); // type-ahead: FIFO of messages entered while working
|
|
370
|
+
const activeTurnRef = useRef(null); // interaction identity survives React render timing
|
|
369
371
|
const [pool, setPool] = useState([]); // type-ahead pool: queued message lines, shown above the input
|
|
370
372
|
const drainingRef = useRef(false); // idempotency guard so the drain effect can't double-send one item
|
|
371
373
|
const statusRef = useRef(status);
|
|
@@ -478,7 +480,7 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
478
480
|
}
|
|
479
481
|
ctrlRef.current?.abort();
|
|
480
482
|
}, []);
|
|
481
|
-
const handleSubmit = useCallback(async (line, images) => {
|
|
483
|
+
const handleSubmit = useCallback(async (line, images, forcedInteraction) => {
|
|
482
484
|
const t = line.trim();
|
|
483
485
|
// A free-text question (ask_user) is awaiting an answer: this submission IS the answer, not a new turn.
|
|
484
486
|
if (askText) {
|
|
@@ -490,15 +492,21 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
490
492
|
if ((!t && !images?.length) || prompt)
|
|
491
493
|
return; // nothing to send, or a choice is pending
|
|
492
494
|
if (working) {
|
|
493
|
-
// type-ahead
|
|
494
|
-
|
|
495
|
+
// Bind every type-ahead message to the exact live turn. A stale message must never silently become
|
|
496
|
+
// a new task after another turn starts (Codex's expected_turn_id steering invariant).
|
|
497
|
+
const expectedTurnId = activeTurnRef.current;
|
|
498
|
+
if (!expectedTurnId)
|
|
499
|
+
return;
|
|
500
|
+
queueRef.current.push({ line, images, expectedTurnId });
|
|
495
501
|
setPool(queueRef.current.map((q) => q.line.trim() || "🖼 (image)"));
|
|
496
502
|
return;
|
|
497
503
|
}
|
|
504
|
+
const interaction = forcedInteraction ?? newTurnInteraction();
|
|
505
|
+
activeTurnRef.current = interaction.turnId;
|
|
498
506
|
// Fold the previous turn's checklist NOW, at the natural boundary (a new task begins). The old
|
|
499
507
|
// 30s-idle timer yanked the input box UP by the panel's height while the user was reading/typing
|
|
500
508
|
// (anti-bob); folding on submit means the shrink coincides with the user's own action.
|
|
501
|
-
if (currentTodos().length) {
|
|
509
|
+
if (interaction.kind === "turn" && currentTodos().length) {
|
|
502
510
|
const list = currentTodos();
|
|
503
511
|
const done = list.filter((td) => td.status === "done").length;
|
|
504
512
|
setHistory((h) => [...h, { id: nid(), kind: "notice", text: ` ✓ Todos: ${done}/${list.length} done` }]);
|
|
@@ -609,7 +617,7 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
609
617
|
// so pressing Enter leaves the message stuck in the input box for seconds ("回车一直不动"). One tick.
|
|
610
618
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
611
619
|
try {
|
|
612
|
-
await onSubmit(t, { sink, confirm: confirmFn, select: selectFn, ask: askFn, pickModel: pickModelFn, setApproval: setApprovalFn, signal: ctrl.signal, exit, approval: statusRef.current.approval, drainQueue }, images);
|
|
620
|
+
await onSubmit(t, { sink, confirm: confirmFn, select: selectFn, ask: askFn, pickModel: pickModelFn, setApproval: setApprovalFn, signal: ctrl.signal, exit, approval: statusRef.current.approval, drainQueue }, images, interaction);
|
|
613
621
|
}
|
|
614
622
|
catch (e) {
|
|
615
623
|
pushCurrent("notice", `error: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -630,9 +638,12 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
630
638
|
setCurrent([]);
|
|
631
639
|
setWorking(false);
|
|
632
640
|
ctrlRef.current = null;
|
|
641
|
+
if (activeTurnRef.current === interaction.turnId)
|
|
642
|
+
activeTurnRef.current = null;
|
|
633
643
|
}, [working, prompt, askText, onSubmit, pushCurrent, model, exit, drainQueue, noteVisionIfNeeded]);
|
|
634
|
-
//
|
|
635
|
-
//
|
|
644
|
+
// A message can arrive after runAgent's final pending-input drain but before the view flips to idle. Keep
|
|
645
|
+
// its original expectedTurnId and run it as a STEER continuation of that task — never as an ordinary new
|
|
646
|
+
// task inferred from queue timing.
|
|
636
647
|
useEffect(() => {
|
|
637
648
|
if (working || prompt || askText || drainingRef.current || !queueRef.current.length)
|
|
638
649
|
return;
|
|
@@ -642,7 +653,8 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
642
653
|
setPool([]);
|
|
643
654
|
const line = batch.map((b) => b.line).join("\n\n");
|
|
644
655
|
const images = batch.flatMap((b) => b.images ?? []);
|
|
645
|
-
|
|
656
|
+
const expectedTurnId = batch[0].expectedTurnId;
|
|
657
|
+
void Promise.resolve(handleSubmit(line, images.length ? images : undefined, newSteerInteraction(expectedTurnId))).finally(() => {
|
|
646
658
|
drainingRef.current = false;
|
|
647
659
|
});
|
|
648
660
|
}, [working, prompt, askText, handleSubmit]);
|
package/dist/tui/InputBox.js
CHANGED
|
@@ -319,19 +319,40 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
|
|
|
319
319
|
const [mode, setMode] = useState("insert"); // vim only
|
|
320
320
|
const [pending, setPending] = useState(""); // vim operator-pending (d/c/g)
|
|
321
321
|
const [register, setRegister] = useState(""); // vim yank/delete register
|
|
322
|
+
// Ink may emit several logical inputs from one OS chunk (notably bracketed paste followed by Enter)
|
|
323
|
+
// inside one React batch. Refs are the authoritative event-time draft; state remains the render copy.
|
|
324
|
+
// Without this split, the second callback observes the previous render and can submit an empty draft.
|
|
325
|
+
const valueRef = useRef(value);
|
|
326
|
+
const cursorRef = useRef(cursor);
|
|
327
|
+
const imagesRef = useRef(images);
|
|
328
|
+
const pastesRef = useRef(pastes);
|
|
329
|
+
valueRef.current = value;
|
|
330
|
+
cursorRef.current = cursor;
|
|
331
|
+
imagesRef.current = images;
|
|
332
|
+
pastesRef.current = pastes;
|
|
322
333
|
const historyRef = useRef(null);
|
|
323
334
|
if (!historyRef.current)
|
|
324
335
|
historyRef.current = new ComposerHistory();
|
|
325
336
|
const inputHistory = historyRef.current;
|
|
326
337
|
const set = (v, c) => {
|
|
327
338
|
inputHistory.abandonNavigation();
|
|
339
|
+
valueRef.current = v;
|
|
340
|
+
cursorRef.current = c;
|
|
328
341
|
setValue(v);
|
|
329
342
|
setCursor(c);
|
|
330
343
|
setSel(0);
|
|
331
344
|
setDismissed(false);
|
|
332
345
|
};
|
|
333
|
-
const snapshot = () => ({
|
|
346
|
+
const snapshot = () => ({
|
|
347
|
+
value: valueRef.current,
|
|
348
|
+
attachments: imagesRef.current,
|
|
349
|
+
pastes: pastesRef.current,
|
|
350
|
+
});
|
|
334
351
|
const restore = (draft) => {
|
|
352
|
+
valueRef.current = draft.value;
|
|
353
|
+
cursorRef.current = draft.value.length;
|
|
354
|
+
imagesRef.current = draft.attachments;
|
|
355
|
+
pastesRef.current = draft.pastes;
|
|
335
356
|
setValue(draft.value);
|
|
336
357
|
setCursor(draft.value.length);
|
|
337
358
|
setImages(draft.attachments);
|
|
@@ -343,12 +364,21 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
|
|
|
343
364
|
// (codex / Claude-Code style). Backspace over the token removes both.
|
|
344
365
|
const addImage = (img) => {
|
|
345
366
|
inputHistory.abandonNavigation();
|
|
346
|
-
const
|
|
347
|
-
const
|
|
367
|
+
const currentValue = valueRef.current;
|
|
368
|
+
const currentCursor = cursorRef.current;
|
|
369
|
+
const currentImages = imagesRef.current;
|
|
370
|
+
const tok = `[Image #${currentImages.length + 1}]`;
|
|
371
|
+
const before = currentValue.slice(0, currentCursor);
|
|
348
372
|
const ins = (before && !/\s$/.test(before) ? " " : "") + tok + " ";
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
373
|
+
const nextValue = before + ins + currentValue.slice(currentCursor);
|
|
374
|
+
const nextCursor = (before + ins).length;
|
|
375
|
+
const nextImages = [...currentImages, img];
|
|
376
|
+
valueRef.current = nextValue;
|
|
377
|
+
cursorRef.current = nextCursor;
|
|
378
|
+
imagesRef.current = nextImages;
|
|
379
|
+
setValue(nextValue);
|
|
380
|
+
setCursor(nextCursor);
|
|
381
|
+
setImages(nextImages);
|
|
352
382
|
setSel(0);
|
|
353
383
|
setDismissed(false);
|
|
354
384
|
};
|
|
@@ -357,23 +387,36 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
|
|
|
357
387
|
// can no longer fire the newline-submit path mid-paste. Expanded back to the full text on submit.
|
|
358
388
|
const addPaste = (text) => {
|
|
359
389
|
inputHistory.abandonNavigation();
|
|
390
|
+
const currentValue = valueRef.current;
|
|
391
|
+
const currentCursor = cursorRef.current;
|
|
392
|
+
const currentPastes = pastesRef.current;
|
|
360
393
|
const lines = text.split("\n").length;
|
|
361
|
-
const tok = `[Paste #${
|
|
362
|
-
const before =
|
|
394
|
+
const tok = `[Paste #${currentPastes.length + 1} +${lines} lines]`;
|
|
395
|
+
const before = currentValue.slice(0, currentCursor);
|
|
363
396
|
const ins = (before && !/\s$/.test(before) ? " " : "") + tok + " ";
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
397
|
+
const nextValue = before + ins + currentValue.slice(currentCursor);
|
|
398
|
+
const nextCursor = (before + ins).length;
|
|
399
|
+
const nextPastes = [...currentPastes, text];
|
|
400
|
+
valueRef.current = nextValue;
|
|
401
|
+
cursorRef.current = nextCursor;
|
|
402
|
+
pastesRef.current = nextPastes;
|
|
403
|
+
setValue(nextValue);
|
|
404
|
+
setCursor(nextCursor);
|
|
405
|
+
setPastes(nextPastes);
|
|
367
406
|
setSel(0);
|
|
368
407
|
setDismissed(false);
|
|
369
408
|
};
|
|
370
|
-
const expandPastes = (text) => text.replace(/\[Paste #(\d+) \+\d+ lines\]/g, (m, d) =>
|
|
371
|
-
const submit = (
|
|
372
|
-
|
|
409
|
+
const expandPastes = (text) => text.replace(/\[Paste #(\d+) \+\d+ lines\]/g, (m, d) => pastesRef.current[Number(d) - 1] ?? m);
|
|
410
|
+
const submit = () => {
|
|
411
|
+
const text = valueRef.current;
|
|
412
|
+
const currentImages = imagesRef.current;
|
|
413
|
+
if (!text.trim() && currentImages.length === 0)
|
|
373
414
|
return; // nothing to send
|
|
374
415
|
inputHistory.record(snapshot());
|
|
375
|
-
onSubmit?.(expandPastes(text),
|
|
416
|
+
onSubmit?.(expandPastes(text), currentImages.length ? currentImages : undefined);
|
|
376
417
|
set("", 0);
|
|
418
|
+
imagesRef.current = [];
|
|
419
|
+
pastesRef.current = [];
|
|
377
420
|
setImages([]);
|
|
378
421
|
setPastes([]);
|
|
379
422
|
setMode("insert"); // a fresh prompt starts in insert
|
|
@@ -422,7 +465,7 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
|
|
|
422
465
|
// vim NORMAL mode: printable keys are commands, not text (Enter/arrows/backspace still navigate/submit)
|
|
423
466
|
if (vim && mode === "normal") {
|
|
424
467
|
if (key.return)
|
|
425
|
-
return submit(
|
|
468
|
+
return submit();
|
|
426
469
|
if (key.leftArrow)
|
|
427
470
|
return setCursor((c) => Math.max(0, c - 1));
|
|
428
471
|
if (key.rightArrow)
|
|
@@ -472,7 +515,7 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
|
|
|
472
515
|
set(value.slice(0, cursor) + "\n" + value.slice(cursor), cursor + 1);
|
|
473
516
|
return;
|
|
474
517
|
}
|
|
475
|
-
submit(
|
|
518
|
+
submit();
|
|
476
519
|
return;
|
|
477
520
|
}
|
|
478
521
|
if (key.leftArrow)
|
|
@@ -536,7 +579,7 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
|
|
|
536
579
|
// A lone newline delivered through `input` (some terminals send Enter this way instead of
|
|
537
580
|
// setting key.return) = the user pressed Enter → submit.
|
|
538
581
|
if (/^[\r\n]+$/.test(input)) {
|
|
539
|
-
submit(
|
|
582
|
+
submit();
|
|
540
583
|
return;
|
|
541
584
|
}
|
|
542
585
|
// A PASTE containing newlines inserts as REAL MULTI-LINE TEXT (normalize CRLF/CR → LF) — the
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { StringDecoder } from "node:string_decoder";
|
|
2
|
+
import { Readable } from "node:stream";
|
|
3
|
+
export const BRACKETED_PASTE_START = "\u001b[200~";
|
|
4
|
+
export const BRACKETED_PASTE_END = "\u001b[201~";
|
|
5
|
+
export const ENABLE_BRACKETED_PASTE = "\u001b[?2004h";
|
|
6
|
+
export const DISABLE_BRACKETED_PASTE = "\u001b[?2004l";
|
|
7
|
+
export const MAX_BRACKETED_PASTE_CHARS = 2 * 1024 * 1024;
|
|
8
|
+
export const INCOMPLETE_PASTE_TIMEOUT_MS = 750;
|
|
9
|
+
const pasteTooLargeMessage = (limit) => {
|
|
10
|
+
const size = limit >= 1024 * 1024 ? `${Math.ceil(limit / (1024 * 1024))} MiB` : `${limit} characters`;
|
|
11
|
+
return `[Paste rejected: input exceeds ${size}]`;
|
|
12
|
+
};
|
|
13
|
+
function suffixPrefixLength(value, marker) {
|
|
14
|
+
const max = Math.min(value.length, marker.length - 1);
|
|
15
|
+
for (let length = max; length > 0; length--) {
|
|
16
|
+
if (value.endsWith(marker.slice(0, length)))
|
|
17
|
+
return length;
|
|
18
|
+
}
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Stateful decoder for xterm bracketed-paste framing. Terminals are free to split both markers and
|
|
23
|
+
* UTF-8 content across arbitrary stdin chunks, so no individual chunk can be treated as a paste.
|
|
24
|
+
* Completed paste contents are emitted as exactly one logical input event; framing bytes never reach
|
|
25
|
+
* Ink's key parser.
|
|
26
|
+
*/
|
|
27
|
+
export class BracketedPasteDecoder {
|
|
28
|
+
maxPasteChars;
|
|
29
|
+
mode = "normal";
|
|
30
|
+
buffer = "";
|
|
31
|
+
constructor(maxPasteChars = MAX_BRACKETED_PASTE_CHARS) {
|
|
32
|
+
this.maxPasteChars = maxPasteChars;
|
|
33
|
+
}
|
|
34
|
+
get hasIncompletePaste() {
|
|
35
|
+
return this.mode !== "normal";
|
|
36
|
+
}
|
|
37
|
+
get hasPendingMarker() {
|
|
38
|
+
return this.mode === "normal" && this.buffer.length > 0;
|
|
39
|
+
}
|
|
40
|
+
feed(input) {
|
|
41
|
+
if (!input)
|
|
42
|
+
return [];
|
|
43
|
+
this.buffer += input;
|
|
44
|
+
const output = [];
|
|
45
|
+
while (this.buffer.length > 0) {
|
|
46
|
+
if (this.mode === "normal") {
|
|
47
|
+
const start = this.buffer.indexOf(BRACKETED_PASTE_START);
|
|
48
|
+
if (start >= 0) {
|
|
49
|
+
if (start > 0)
|
|
50
|
+
output.push(this.buffer.slice(0, start));
|
|
51
|
+
this.buffer = this.buffer.slice(start + BRACKETED_PASTE_START.length);
|
|
52
|
+
this.mode = "paste";
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
// Retain only a suffix that could become a split start marker. Everything before it is
|
|
56
|
+
// ordinary keyboard input and can be forwarded immediately.
|
|
57
|
+
const retained = suffixPrefixLength(this.buffer, BRACKETED_PASTE_START);
|
|
58
|
+
const ready = this.buffer.slice(0, this.buffer.length - retained);
|
|
59
|
+
if (ready)
|
|
60
|
+
output.push(ready);
|
|
61
|
+
this.buffer = retained ? this.buffer.slice(-retained) : "";
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
const end = this.buffer.indexOf(BRACKETED_PASTE_END);
|
|
65
|
+
if (end >= 0) {
|
|
66
|
+
if (this.mode === "paste" && end <= this.maxPasteChars)
|
|
67
|
+
output.push(this.buffer.slice(0, end));
|
|
68
|
+
else
|
|
69
|
+
output.push(pasteTooLargeMessage(this.maxPasteChars));
|
|
70
|
+
this.buffer = this.buffer.slice(end + BRACKETED_PASTE_END.length);
|
|
71
|
+
this.mode = "normal";
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (this.mode === "paste" && this.buffer.length > this.maxPasteChars) {
|
|
75
|
+
// Fail closed instead of retaining an unbounded terminal stream or silently submitting a
|
|
76
|
+
// truncated source file. Keep only a possible split end marker while discarding the payload.
|
|
77
|
+
const retained = suffixPrefixLength(this.buffer, BRACKETED_PASTE_END);
|
|
78
|
+
this.buffer = retained ? this.buffer.slice(-retained) : "";
|
|
79
|
+
this.mode = "overflow";
|
|
80
|
+
}
|
|
81
|
+
else if (this.mode === "overflow") {
|
|
82
|
+
const retained = suffixPrefixLength(this.buffer, BRACKETED_PASTE_END);
|
|
83
|
+
this.buffer = retained ? this.buffer.slice(-retained) : "";
|
|
84
|
+
}
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
return output;
|
|
88
|
+
}
|
|
89
|
+
/** Recover when a terminal sent paste-start but never sent paste-end. */
|
|
90
|
+
flushIncomplete() {
|
|
91
|
+
if (this.mode === "normal") {
|
|
92
|
+
const pending = this.buffer;
|
|
93
|
+
this.buffer = "";
|
|
94
|
+
return pending ? [pending] : [];
|
|
95
|
+
}
|
|
96
|
+
const output = this.mode === "paste" ? this.buffer : pasteTooLargeMessage(this.maxPasteChars);
|
|
97
|
+
this.mode = "normal";
|
|
98
|
+
this.buffer = "";
|
|
99
|
+
return output ? [output] : [];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Readable proxy passed to Ink. It preserves the real TTY's raw-mode lifecycle while turning each
|
|
104
|
+
* completed bracketed paste into one readable event. Multiple logical events from one OS chunk are
|
|
105
|
+
* emitted on separate event-loop turns so `paste + Enter` cannot be coalesced back into one keypress.
|
|
106
|
+
*/
|
|
107
|
+
export class BracketedPasteInput extends Readable {
|
|
108
|
+
source;
|
|
109
|
+
isTTY;
|
|
110
|
+
utf8 = new StringDecoder("utf8");
|
|
111
|
+
decoder;
|
|
112
|
+
queue = [];
|
|
113
|
+
drainImmediate;
|
|
114
|
+
markerImmediate;
|
|
115
|
+
pasteTimer;
|
|
116
|
+
ending = false;
|
|
117
|
+
disposed = false;
|
|
118
|
+
constructor(source, options = {}) {
|
|
119
|
+
super();
|
|
120
|
+
this.source = source;
|
|
121
|
+
this.isTTY = Boolean(source.isTTY);
|
|
122
|
+
this.decoder = new BracketedPasteDecoder(options.maxPasteChars);
|
|
123
|
+
this.incompleteTimeoutMs = options.incompleteTimeoutMs ?? INCOMPLETE_PASTE_TIMEOUT_MS;
|
|
124
|
+
source.on("data", this.onData);
|
|
125
|
+
source.on("end", this.onEnd);
|
|
126
|
+
source.on("error", this.onError);
|
|
127
|
+
}
|
|
128
|
+
incompleteTimeoutMs;
|
|
129
|
+
_read() {
|
|
130
|
+
// Input is pushed by the wrapped terminal stream.
|
|
131
|
+
}
|
|
132
|
+
setRawMode(enabled) {
|
|
133
|
+
this.source.setRawMode?.(enabled);
|
|
134
|
+
return this;
|
|
135
|
+
}
|
|
136
|
+
ref() {
|
|
137
|
+
this.source.ref?.();
|
|
138
|
+
return this;
|
|
139
|
+
}
|
|
140
|
+
unref() {
|
|
141
|
+
this.source.unref?.();
|
|
142
|
+
return this;
|
|
143
|
+
}
|
|
144
|
+
onData = (chunk) => {
|
|
145
|
+
if (this.disposed)
|
|
146
|
+
return;
|
|
147
|
+
this.clearMarkerImmediate();
|
|
148
|
+
const text = Buffer.isBuffer(chunk) ? this.utf8.write(chunk) : String(chunk);
|
|
149
|
+
this.enqueue(this.decoder.feed(text));
|
|
150
|
+
if (this.decoder.hasIncompletePaste) {
|
|
151
|
+
this.resetPasteTimer();
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
this.clearPasteTimer();
|
|
155
|
+
// Like Ink's own input parser, retain a lone/split ESC sequence for only one event-loop turn.
|
|
156
|
+
if (this.decoder.hasPendingMarker) {
|
|
157
|
+
this.markerImmediate = setImmediate(() => {
|
|
158
|
+
this.markerImmediate = undefined;
|
|
159
|
+
this.enqueue(this.decoder.flushIncomplete());
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
onEnd = () => {
|
|
165
|
+
if (this.disposed)
|
|
166
|
+
return;
|
|
167
|
+
const tail = this.utf8.end();
|
|
168
|
+
if (tail)
|
|
169
|
+
this.enqueue(this.decoder.feed(tail));
|
|
170
|
+
this.enqueue(this.decoder.flushIncomplete());
|
|
171
|
+
this.ending = true;
|
|
172
|
+
this.finishIfDrained();
|
|
173
|
+
};
|
|
174
|
+
onError = (error) => {
|
|
175
|
+
this.destroy(error);
|
|
176
|
+
};
|
|
177
|
+
resetPasteTimer() {
|
|
178
|
+
this.clearPasteTimer();
|
|
179
|
+
this.pasteTimer = setTimeout(() => {
|
|
180
|
+
this.pasteTimer = undefined;
|
|
181
|
+
this.enqueue(this.decoder.flushIncomplete());
|
|
182
|
+
}, this.incompleteTimeoutMs);
|
|
183
|
+
this.pasteTimer.unref?.();
|
|
184
|
+
}
|
|
185
|
+
enqueue(values) {
|
|
186
|
+
for (const value of values)
|
|
187
|
+
if (value)
|
|
188
|
+
this.queue.push(value);
|
|
189
|
+
if (this.queue.length === 0 || this.drainImmediate)
|
|
190
|
+
return;
|
|
191
|
+
const first = this.queue.shift();
|
|
192
|
+
if (first !== undefined)
|
|
193
|
+
this.push(first);
|
|
194
|
+
if (this.queue.length > 0)
|
|
195
|
+
this.scheduleDrain();
|
|
196
|
+
else
|
|
197
|
+
this.finishIfDrained();
|
|
198
|
+
}
|
|
199
|
+
scheduleDrain() {
|
|
200
|
+
if (this.drainImmediate)
|
|
201
|
+
return;
|
|
202
|
+
this.drainImmediate = setImmediate(() => {
|
|
203
|
+
this.drainImmediate = undefined;
|
|
204
|
+
const next = this.queue.shift();
|
|
205
|
+
if (next !== undefined)
|
|
206
|
+
this.push(next);
|
|
207
|
+
if (this.queue.length > 0)
|
|
208
|
+
this.scheduleDrain();
|
|
209
|
+
else
|
|
210
|
+
this.finishIfDrained();
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
finishIfDrained() {
|
|
214
|
+
if (this.ending && this.queue.length === 0 && !this.drainImmediate)
|
|
215
|
+
this.push(null);
|
|
216
|
+
}
|
|
217
|
+
clearPasteTimer() {
|
|
218
|
+
if (this.pasteTimer)
|
|
219
|
+
clearTimeout(this.pasteTimer);
|
|
220
|
+
this.pasteTimer = undefined;
|
|
221
|
+
}
|
|
222
|
+
clearMarkerImmediate() {
|
|
223
|
+
if (this.markerImmediate)
|
|
224
|
+
clearImmediate(this.markerImmediate);
|
|
225
|
+
this.markerImmediate = undefined;
|
|
226
|
+
}
|
|
227
|
+
dispose() {
|
|
228
|
+
if (this.disposed)
|
|
229
|
+
return;
|
|
230
|
+
this.disposed = true;
|
|
231
|
+
this.clearPasteTimer();
|
|
232
|
+
this.clearMarkerImmediate();
|
|
233
|
+
if (this.drainImmediate)
|
|
234
|
+
clearImmediate(this.drainImmediate);
|
|
235
|
+
this.drainImmediate = undefined;
|
|
236
|
+
this.source.off("data", this.onData);
|
|
237
|
+
this.source.off("end", this.onEnd);
|
|
238
|
+
this.source.off("error", this.onError);
|
|
239
|
+
this.push(null);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/** Enable terminal-side paste framing and return an idempotent cleanup. */
|
|
243
|
+
export function enableBracketedPaste(output) {
|
|
244
|
+
if (!output.isTTY)
|
|
245
|
+
return () => { };
|
|
246
|
+
let enabled = false;
|
|
247
|
+
try {
|
|
248
|
+
output.write(ENABLE_BRACKETED_PASTE);
|
|
249
|
+
enabled = true;
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return () => { };
|
|
253
|
+
}
|
|
254
|
+
return () => {
|
|
255
|
+
if (!enabled)
|
|
256
|
+
return;
|
|
257
|
+
enabled = false;
|
|
258
|
+
try {
|
|
259
|
+
output.write(DISABLE_BRACKETED_PASTE);
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
// Terminal cleanup is best effort; never mask the original TUI exit/error.
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
}
|