@nanhara/hara 0.119.2 → 0.121.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 +32 -0
- package/dist/context/mentions.js +17 -7
- package/dist/desk.js +64 -0
- package/dist/feedback.js +55 -0
- package/dist/fs-read.js +157 -0
- package/dist/fs-write.js +105 -0
- package/dist/index.js +134 -0
- package/dist/tools/builtin.js +34 -11
- package/dist/tools/edit.js +10 -2
- package/dist/tools/patch.js +45 -15
- package/dist/tools/registry.js +20 -6
- package/dist/tools/result-limit.js +37 -0
- package/dist/tui/App.js +1 -1
- package/dist/tui/InputBox.js +62 -4
- package/dist/tui/input-history.js +167 -0
- package/dist/undo.js +6 -4
- package/package.json +6 -1
package/dist/tools/builtin.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
import { readFile,
|
|
2
|
-
import {
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { resolve, isAbsolute } from "node:path";
|
|
3
3
|
import { stdout as procOut } from "node:process";
|
|
4
4
|
import { registerTool } from "./registry.js";
|
|
5
5
|
import { runShell } from "../sandbox.js";
|
|
6
|
-
import { nearestPaths } from "../fs-walk.js";
|
|
6
|
+
import { isProbablyBinary, nearestPaths } from "../fs-walk.js";
|
|
7
7
|
import { emitDiff } from "../diff.js";
|
|
8
8
|
import { recordEdit } from "../undo.js";
|
|
9
|
+
import { atomicWriteText } from "../fs-write.js";
|
|
10
|
+
import { invalidateFileCandidates } from "../context/mentions.js";
|
|
11
|
+
import { BinaryFileError, streamFileSlice } from "../fs-read.js";
|
|
9
12
|
import { startJob, listJobs, tailJob, killJob } from "../exec/jobs.js";
|
|
10
13
|
import { hostsInCommand, isNetworkGitOp, hostFromConnectError, isConnectFailure, markHostUnreachable, isHostUnreachable, unreachableHostsSnapshot, } from "./net-reachability.js";
|
|
11
14
|
const MAX = 100_000;
|
|
@@ -43,8 +46,9 @@ export function capHeadTail(s, max = MAX) {
|
|
|
43
46
|
const head = Math.floor(max * 0.6);
|
|
44
47
|
return s.slice(0, head) + `\n…[${s.length - max} chars truncated]…\n` + s.slice(s.length - (max - head));
|
|
45
48
|
}
|
|
46
|
-
const READ_LINES =
|
|
49
|
+
const READ_LINES = 300; // sized to stay useful under the global 24k-char tool-result context boundary
|
|
47
50
|
const LINE_CAP = 2000; // chars per line before truncation (minified bundles / data lines)
|
|
51
|
+
const BUFFERED_READ_BYTES = 4 * 1024 * 1024;
|
|
48
52
|
/** Render a line slice of a file, cat -n style. The old read_file dumped the WHOLE file (100K-char cap,
|
|
49
53
|
* tail simply lost) — on long files that both flooded the context (~25k tokens per read, again on every
|
|
50
54
|
* re-read) and made everything past the cap unreachable. Now: line numbers (anchor for edits and for
|
|
@@ -71,22 +75,31 @@ export function renderFileSlice(text, offset, limit) {
|
|
|
71
75
|
}
|
|
72
76
|
registerTool({
|
|
73
77
|
name: "read_file",
|
|
74
|
-
description: "Read a UTF-8 text file; returns cat -n style numbered lines. Reads up to
|
|
78
|
+
description: "Read a UTF-8 text file; returns cat -n style numbered lines. Reads up to 300 lines by default — for a longer file pass offset/limit to read the next slice (the header tells you where to continue). Large files are streamed instead of loaded whole. Prefer grep to locate, then read just that region.",
|
|
75
79
|
input_schema: {
|
|
76
80
|
type: "object",
|
|
77
81
|
properties: {
|
|
78
82
|
path: { type: "string", description: "File path, relative to cwd or absolute" },
|
|
79
83
|
offset: { type: "number", description: "1-based line number to start from (for long files)" },
|
|
80
|
-
limit: { type: "number", description: "max lines to return (default
|
|
84
|
+
limit: { type: "number", description: "max lines to return (default 300)" },
|
|
81
85
|
},
|
|
82
86
|
required: ["path"],
|
|
83
87
|
},
|
|
84
88
|
kind: "read",
|
|
85
89
|
async run(input, ctx) {
|
|
90
|
+
const p = abs(input.path, ctx.cwd);
|
|
86
91
|
try {
|
|
87
|
-
|
|
92
|
+
const info = await stat(p);
|
|
93
|
+
if (info.size > BUFFERED_READ_BYTES)
|
|
94
|
+
return cap(await streamFileSlice(p, input.offset, input.limit ?? READ_LINES, { lineCap: LINE_CAP }));
|
|
95
|
+
const buf = await readFile(p);
|
|
96
|
+
if (isProbablyBinary(buf))
|
|
97
|
+
throw new BinaryFileError(p);
|
|
98
|
+
return cap(renderFileSlice(buf.toString("utf8"), input.offset, input.limit));
|
|
88
99
|
}
|
|
89
100
|
catch (e) {
|
|
101
|
+
if (e instanceof BinaryFileError)
|
|
102
|
+
return `Error: cannot read ${input.path}: file appears binary; use an image/media-specific tool or inspect it with \`file\`.`;
|
|
90
103
|
const near = nearestPaths(ctx.cwd, input.path);
|
|
91
104
|
return `Error: cannot read ${input.path}: ${e.code ?? e.message}.` + (near.length ? ` Did you mean: ${near.join(", ")}?` : "");
|
|
92
105
|
}
|
|
@@ -106,17 +119,27 @@ registerTool({
|
|
|
106
119
|
kind: "edit",
|
|
107
120
|
async run(input, ctx) {
|
|
108
121
|
const p = abs(input.path, ctx.cwd);
|
|
122
|
+
if (typeof input.content !== "string")
|
|
123
|
+
return "Error: write_file `content` must be a string. No changes written.";
|
|
109
124
|
let prev = null;
|
|
110
125
|
try {
|
|
111
126
|
prev = await readFile(p, "utf8");
|
|
112
127
|
}
|
|
113
|
-
catch {
|
|
114
|
-
|
|
128
|
+
catch (error) {
|
|
129
|
+
if (error?.code !== "ENOENT")
|
|
130
|
+
return `Error: cannot inspect ${input.path}: ${error?.code ?? error?.message}. No changes written.`;
|
|
131
|
+
}
|
|
132
|
+
if (prev === input.content)
|
|
133
|
+
return `Unchanged ${p} (${input.content.length} chars already match).`;
|
|
134
|
+
try {
|
|
135
|
+
await atomicWriteText(p, input.content, { expected: prev });
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
return `Error: cannot write ${input.path}: ${error?.message ?? String(error)} No changes written.`;
|
|
115
139
|
}
|
|
116
|
-
await mkdir(dirname(p), { recursive: true });
|
|
117
|
-
await writeFile(p, input.content, "utf8");
|
|
118
140
|
emitDiff(input.path, prev ?? "", input.content, ctx.ui);
|
|
119
141
|
recordEdit([{ path: input.path, absPath: p, before: prev }]);
|
|
142
|
+
invalidateFileCandidates(ctx.cwd);
|
|
120
143
|
return `Wrote ${String(input.content).length} chars to ${p}`;
|
|
121
144
|
},
|
|
122
145
|
});
|
package/dist/tools/edit.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { readFile
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { isAbsolute, resolve } from "node:path";
|
|
3
3
|
import { registerTool } from "./registry.js";
|
|
4
4
|
import { nearestPaths } from "../fs-walk.js";
|
|
5
5
|
import { emitDiff } from "../diff.js";
|
|
6
6
|
import { applyEdits } from "./apply-core.js";
|
|
7
7
|
import { recordEdit } from "../undo.js";
|
|
8
|
+
import { atomicWriteText } from "../fs-write.js";
|
|
9
|
+
import { invalidateFileCandidates } from "../context/mentions.js";
|
|
8
10
|
registerTool({
|
|
9
11
|
name: "edit_file",
|
|
10
12
|
description: "Edit an existing file by replacing exact strings. Provide a single `old_string`/`new_string`, " +
|
|
@@ -53,9 +55,15 @@ registerTool({
|
|
|
53
55
|
const res = applyEdits(text, edits);
|
|
54
56
|
if ("error" in res)
|
|
55
57
|
return `Error: ${res.error} in ${input.path}. No changes written.`;
|
|
56
|
-
|
|
58
|
+
try {
|
|
59
|
+
await atomicWriteText(p, res.text, { expected: text });
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
return `Error: cannot edit ${input.path}: ${error?.message ?? String(error)} No changes written.`;
|
|
63
|
+
}
|
|
57
64
|
emitDiff(input.path, text, res.text, ctx.ui);
|
|
58
65
|
recordEdit([{ path: input.path, absPath: p, before: text }]);
|
|
66
|
+
invalidateFileCandidates(ctx.cwd);
|
|
59
67
|
const note = res.fuzzy ? " (quote-normalized)" : "";
|
|
60
68
|
const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
|
|
61
69
|
return `Edited ${input.path}: ${plural(edits.length, "edit")}, ${plural(res.total, "replacement")}${note}.`;
|
package/dist/tools/patch.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// apply_patch — change MULTIPLE files atomically (all-or-nothing). Everything is validated and
|
|
2
2
|
// computed in memory first; nothing is written unless every change applies cleanly.
|
|
3
|
-
import {
|
|
4
|
-
import { isAbsolute, resolve
|
|
3
|
+
import { lstat, readFile, unlink } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute, resolve } from "node:path";
|
|
5
5
|
import { registerTool } from "./registry.js";
|
|
6
6
|
import { applyEdits } from "./apply-core.js";
|
|
7
7
|
import { emitDiff } from "../diff.js";
|
|
8
8
|
import { recordEdit } from "../undo.js";
|
|
9
|
+
import { atomicWriteText, FileChangedError } from "../fs-write.js";
|
|
10
|
+
import { invalidateFileCandidates } from "../context/mentions.js";
|
|
9
11
|
registerTool({
|
|
10
12
|
name: "apply_patch",
|
|
11
13
|
description: "Change SEVERAL files in one atomic step (all-or-nothing). `changes` is an array of " +
|
|
@@ -51,13 +53,29 @@ registerTool({
|
|
|
51
53
|
const abs = (pth) => (isAbsolute(pth) ? pth : resolve(ctx.cwd, pth));
|
|
52
54
|
// PHASE 1 — validate + compute every change in memory; bail before writing anything.
|
|
53
55
|
const plans = [];
|
|
56
|
+
const plannedPaths = new Set();
|
|
54
57
|
for (let i = 0; i < changes.length; i++) {
|
|
55
58
|
const ch = changes[i];
|
|
56
59
|
const tag = `change ${i + 1}/${changes.length}`;
|
|
57
60
|
if (typeof ch.path !== "string" || !ch.path)
|
|
58
61
|
return `Error: ${tag} is missing a path. Nothing written.`;
|
|
59
62
|
const p = abs(ch.path);
|
|
60
|
-
|
|
63
|
+
if (plannedPaths.has(p))
|
|
64
|
+
return `Error: ${tag} repeats path ${ch.path}. Combine edits for one file into a single change. Nothing written.`;
|
|
65
|
+
plannedPaths.add(p);
|
|
66
|
+
let type = ch.type ?? (ch.edits ? "update" : "create");
|
|
67
|
+
// Backward-compatible shorthand: {path, content} updates an existing file and creates a missing
|
|
68
|
+
// one. An EXPLICIT type:create is stricter and never clobbers an existing path.
|
|
69
|
+
if (!ch.type && !ch.edits) {
|
|
70
|
+
try {
|
|
71
|
+
await lstat(p);
|
|
72
|
+
type = "update";
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (error?.code !== "ENOENT")
|
|
76
|
+
return `Error: ${tag} cannot inspect ${ch.path}: ${error?.message ?? String(error)}. Nothing written.`;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
61
79
|
if (type === "delete") {
|
|
62
80
|
let before;
|
|
63
81
|
try {
|
|
@@ -71,16 +89,15 @@ registerTool({
|
|
|
71
89
|
else if (type === "create") {
|
|
72
90
|
if (typeof ch.content !== "string")
|
|
73
91
|
return `Error: ${tag} create ${ch.path} needs \`content\`. Nothing written.`;
|
|
74
|
-
let before = "";
|
|
75
|
-
let existed = false;
|
|
76
92
|
try {
|
|
77
|
-
|
|
78
|
-
|
|
93
|
+
await lstat(p);
|
|
94
|
+
return `Error: ${tag} create ${ch.path}: path already exists (use type:update to replace it). Nothing written.`;
|
|
79
95
|
}
|
|
80
|
-
catch {
|
|
81
|
-
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (error?.code !== "ENOENT")
|
|
98
|
+
return `Error: ${tag} create ${ch.path}: ${error?.message ?? String(error)}. Nothing written.`;
|
|
82
99
|
}
|
|
83
|
-
plans.push({ path: ch.path, abs: p, type, before, after: ch.content, existed });
|
|
100
|
+
plans.push({ path: ch.path, abs: p, type, before: "", after: ch.content, existed: false });
|
|
84
101
|
}
|
|
85
102
|
else {
|
|
86
103
|
// update
|
|
@@ -108,27 +125,39 @@ registerTool({
|
|
|
108
125
|
try {
|
|
109
126
|
for (const pl of plans) {
|
|
110
127
|
if (pl.type === "delete") {
|
|
128
|
+
let current;
|
|
129
|
+
try {
|
|
130
|
+
current = await readFile(pl.abs, "utf8");
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
throw new FileChangedError(pl.path);
|
|
134
|
+
}
|
|
135
|
+
if (current !== pl.before)
|
|
136
|
+
throw new FileChangedError(pl.path);
|
|
111
137
|
await unlink(pl.abs);
|
|
112
138
|
}
|
|
113
139
|
else {
|
|
114
|
-
await
|
|
115
|
-
await writeFile(pl.abs, pl.after, "utf8");
|
|
140
|
+
await atomicWriteText(pl.abs, pl.after, { expected: pl.existed ? pl.before : null });
|
|
116
141
|
}
|
|
117
142
|
applied.push(pl);
|
|
118
143
|
}
|
|
119
144
|
}
|
|
120
145
|
catch (e) {
|
|
146
|
+
const rollbackFailures = [];
|
|
121
147
|
for (const pl of applied.reverse()) {
|
|
122
148
|
try {
|
|
123
149
|
if (pl.type === "create" && !pl.existed)
|
|
124
150
|
await unlink(pl.abs); // remove a file we created
|
|
125
151
|
else
|
|
126
|
-
await
|
|
152
|
+
await atomicWriteText(pl.abs, pl.before); // restore an updated/deleted file's prior content
|
|
127
153
|
}
|
|
128
|
-
catch {
|
|
129
|
-
|
|
154
|
+
catch (rollbackError) {
|
|
155
|
+
rollbackFailures.push(`${pl.path}: ${rollbackError?.message ?? String(rollbackError)}`);
|
|
130
156
|
}
|
|
131
157
|
}
|
|
158
|
+
if (rollbackFailures.length) {
|
|
159
|
+
return `Error: apply_patch failed (${e instanceof Error ? e.message : String(e)}); rollback was INCOMPLETE: ${rollbackFailures.join("; ")}. Inspect these files before continuing.`;
|
|
160
|
+
}
|
|
132
161
|
return `Error: apply_patch failed writing a file (${e instanceof Error ? e.message : String(e)}) — rolled back, nothing left changed.`;
|
|
133
162
|
}
|
|
134
163
|
// All writes succeeded → now show diffs + record the undo snapshot.
|
|
@@ -137,6 +166,7 @@ registerTool({
|
|
|
137
166
|
return pl.type === "delete" ? `deleted ${pl.path}` : `${pl.type === "create" ? "created" : "updated"} ${pl.path}`;
|
|
138
167
|
});
|
|
139
168
|
recordEdit(plans.map((pl) => ({ path: pl.path, absPath: pl.abs, before: pl.existed ? pl.before : null })));
|
|
169
|
+
invalidateFileCandidates(ctx.cwd);
|
|
140
170
|
return `apply_patch: ${plans.length} file(s) — ${summary.join("; ")}.`;
|
|
141
171
|
},
|
|
142
172
|
});
|
package/dist/tools/registry.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { limitToolResult } from "./result-limit.js";
|
|
1
2
|
/** Names of required parameters that are ABSENT (undefined/null) in a tool call's input. Defends
|
|
2
3
|
* against models that drop parameters outright (observed: qwen3.7-plus losing write_file's
|
|
3
4
|
* path/content mid-stream) — the loop rejects the call with a precise error instead of executing
|
|
@@ -9,8 +10,16 @@ export function missingRequired(tool, input) {
|
|
|
9
10
|
return req.filter((k) => obj[k] === undefined || obj[k] === null);
|
|
10
11
|
}
|
|
11
12
|
const registry = new Map();
|
|
13
|
+
let specsCache = null;
|
|
12
14
|
export function registerTool(t) {
|
|
13
|
-
|
|
15
|
+
const run = t.run;
|
|
16
|
+
registry.set(t.name, {
|
|
17
|
+
...t,
|
|
18
|
+
// Apply the context boundary at registration so every caller (main loop, tests, embedders) gets
|
|
19
|
+
// identical behavior instead of relying on one orchestration path to remember the cap.
|
|
20
|
+
run: async (input, ctx) => limitToolResult(await run(input, ctx)),
|
|
21
|
+
});
|
|
22
|
+
specsCache = null;
|
|
14
23
|
}
|
|
15
24
|
export function getTool(name) {
|
|
16
25
|
return registry.get(name);
|
|
@@ -20,9 +29,14 @@ export function getTools() {
|
|
|
20
29
|
}
|
|
21
30
|
/** Provider-neutral tool specs derived from the registry. */
|
|
22
31
|
export function toolSpecs() {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
32
|
+
if (!specsCache) {
|
|
33
|
+
specsCache = getTools().map((t) => ({
|
|
34
|
+
name: t.name,
|
|
35
|
+
description: t.description,
|
|
36
|
+
input_schema: t.input_schema,
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
// Callers commonly filter the array for a role. Return a shallow copy so that never mutates the
|
|
40
|
+
// stable cached snapshot shared by subsequent agent rounds.
|
|
41
|
+
return specsCache.slice();
|
|
28
42
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// One invariant for every registered tool: no single result may monopolize the model context. Individual
|
|
2
|
+
// tools can use tighter domain-specific limits, but this final boundary also covers plugins/new tools.
|
|
3
|
+
export const MAX_TOOL_RESULT_CHARS = 24_000;
|
|
4
|
+
function safeHead(value, end) {
|
|
5
|
+
let at = Math.max(0, Math.min(value.length, end));
|
|
6
|
+
if (at > 0 && /[\uD800-\uDBFF]/.test(value[at - 1] ?? ""))
|
|
7
|
+
at--;
|
|
8
|
+
return value.slice(0, at);
|
|
9
|
+
}
|
|
10
|
+
function safeTail(value, start) {
|
|
11
|
+
let at = Math.max(0, Math.min(value.length, start));
|
|
12
|
+
if (at < value.length && /[\uDC00-\uDFFF]/.test(value[at] ?? ""))
|
|
13
|
+
at++;
|
|
14
|
+
return value.slice(at);
|
|
15
|
+
}
|
|
16
|
+
/** Keep actionable beginnings and endings while bounding the exact string persisted in history. */
|
|
17
|
+
export function limitToolResult(value, max = MAX_TOOL_RESULT_CHARS) {
|
|
18
|
+
const text = typeof value === "string" ? value : String(value ?? "");
|
|
19
|
+
const cap = Math.max(0, Math.floor(max));
|
|
20
|
+
if (text.length <= cap)
|
|
21
|
+
return text;
|
|
22
|
+
if (cap === 0)
|
|
23
|
+
return "";
|
|
24
|
+
let omitted = text.length - cap;
|
|
25
|
+
let fullMarker = "";
|
|
26
|
+
// The marker consumes part of the budget too. Iterate twice so its count reflects the actual payload
|
|
27
|
+
// removed rather than understating it by roughly the marker's own length.
|
|
28
|
+
for (let i = 0; i < 2; i++) {
|
|
29
|
+
fullMarker = `\n…[hara: ${omitted} chars omitted; narrow the query or continue read_file with offset/limit]…\n`;
|
|
30
|
+
omitted = text.length - Math.max(0, cap - fullMarker.length);
|
|
31
|
+
}
|
|
32
|
+
const marker = fullMarker.length < cap ? fullMarker : "…[truncated]…".slice(0, cap);
|
|
33
|
+
const room = cap - marker.length;
|
|
34
|
+
const headChars = Math.floor(room * 0.6);
|
|
35
|
+
const tailChars = room - headChars;
|
|
36
|
+
return safeHead(text, headChars) + marker + safeTail(text, text.length - tailChars);
|
|
37
|
+
}
|
package/dist/tui/App.js
CHANGED
|
@@ -516,7 +516,7 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
516
516
|
const askTextFn = (title) => new Promise((resolve) => setAskText({ title, resolve }));
|
|
517
517
|
// ask_user: when options are given, offer them as a select + a "type my own" escape hatch; otherwise (or
|
|
518
518
|
// when the user chooses to type their own) capture a free-text line. Returns the chosen/typed answer.
|
|
519
|
-
const OTHER = "
|
|
519
|
+
const OTHER = "\\0__ask_other__"; // escaped sentinel keeps this TypeScript file text-only
|
|
520
520
|
const askFn = async (question, options) => {
|
|
521
521
|
if (options && options.length) {
|
|
522
522
|
const choice = await openPrompt(question, [
|
package/dist/tui/InputBox.js
CHANGED
|
@@ -14,10 +14,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
14
14
|
// of leaning on ink's soft-wrap of a single <Text>, which mis-aligns wrapped rows against the
|
|
15
15
|
// "› " prompt gutter and reflows unpredictably as you type.
|
|
16
16
|
import { Box, Text, useInput, useStdout } from "ink";
|
|
17
|
-
import { memo, useMemo, useState } from "react";
|
|
17
|
+
import { memo, useMemo, useRef, useState } from "react";
|
|
18
18
|
import { fileCandidates } from "../context/mentions.js";
|
|
19
19
|
import { imagePathFromPaste } from "../images.js";
|
|
20
20
|
import { vimNormal } from "./vim.js";
|
|
21
|
+
import { ComposerHistory, moveCursorLine, nextGraphemeIndex, previousGraphemeIndex, previousWordIndex, } from "./input-history.js";
|
|
21
22
|
export const MODES = ["suggest", "auto-edit", "full-auto", "plan"];
|
|
22
23
|
export const nextMode = (m) => MODES[(MODES.indexOf(m) + 1) % MODES.length];
|
|
23
24
|
const tok = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`);
|
|
@@ -312,15 +313,30 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
|
|
|
312
313
|
const [mode, setMode] = useState("insert"); // vim only
|
|
313
314
|
const [pending, setPending] = useState(""); // vim operator-pending (d/c/g)
|
|
314
315
|
const [register, setRegister] = useState(""); // vim yank/delete register
|
|
316
|
+
const historyRef = useRef(null);
|
|
317
|
+
if (!historyRef.current)
|
|
318
|
+
historyRef.current = new ComposerHistory();
|
|
319
|
+
const inputHistory = historyRef.current;
|
|
315
320
|
const set = (v, c) => {
|
|
321
|
+
inputHistory.abandonNavigation();
|
|
316
322
|
setValue(v);
|
|
317
323
|
setCursor(c);
|
|
318
324
|
setSel(0);
|
|
319
325
|
setDismissed(false);
|
|
320
326
|
};
|
|
327
|
+
const snapshot = () => ({ value, attachments: images, pastes });
|
|
328
|
+
const restore = (draft) => {
|
|
329
|
+
setValue(draft.value);
|
|
330
|
+
setCursor(draft.value.length);
|
|
331
|
+
setImages(draft.attachments);
|
|
332
|
+
setPastes(draft.pastes);
|
|
333
|
+
setSel(0);
|
|
334
|
+
setDismissed(false);
|
|
335
|
+
};
|
|
321
336
|
// Attach an image: drop a highlighted `[Image #N]` token inline at the cursor and track the file
|
|
322
337
|
// (codex / Claude-Code style). Backspace over the token removes both.
|
|
323
338
|
const addImage = (img) => {
|
|
339
|
+
inputHistory.abandonNavigation();
|
|
324
340
|
const tok = `[Image #${images.length + 1}]`;
|
|
325
341
|
const before = value.slice(0, cursor);
|
|
326
342
|
const ins = (before && !/\s$/.test(before) ? " " : "") + tok + " ";
|
|
@@ -334,6 +350,7 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
|
|
|
334
350
|
// the box: typing stays smooth (the VALUE stays short), the box stays small, and a multi-line paste
|
|
335
351
|
// can no longer fire the newline-submit path mid-paste. Expanded back to the full text on submit.
|
|
336
352
|
const addPaste = (text) => {
|
|
353
|
+
inputHistory.abandonNavigation();
|
|
337
354
|
const lines = text.split("\n").length;
|
|
338
355
|
const tok = `[Paste #${pastes.length + 1} +${lines} lines]`;
|
|
339
356
|
const before = value.slice(0, cursor);
|
|
@@ -348,6 +365,7 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
|
|
|
348
365
|
const submit = (text) => {
|
|
349
366
|
if (!text.trim() && images.length === 0)
|
|
350
367
|
return; // nothing to send
|
|
368
|
+
inputHistory.record(snapshot());
|
|
351
369
|
onSubmit?.(expandPastes(text), images.length ? images : undefined);
|
|
352
370
|
set("", 0);
|
|
353
371
|
setImages([]);
|
|
@@ -365,6 +383,7 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
|
|
|
365
383
|
const complete = (cand) => {
|
|
366
384
|
if (!mention)
|
|
367
385
|
return;
|
|
386
|
+
inputHistory.abandonNavigation();
|
|
368
387
|
const before = value.slice(0, mention.start); // includes the leading '@'
|
|
369
388
|
const after = value.slice(cursor);
|
|
370
389
|
const insert = cand.endsWith("/") ? cand : cand + " "; // dirs keep drilling; files end the mention
|
|
@@ -406,6 +425,8 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
|
|
|
406
425
|
return setCursor((c) => Math.max(0, c - 1));
|
|
407
426
|
if (input && !key.ctrl && !key.meta) {
|
|
408
427
|
const st = vimNormal({ value, cursor, mode, pending, register }, input);
|
|
428
|
+
if (st.value !== value)
|
|
429
|
+
inputHistory.abandonNavigation();
|
|
409
430
|
setValue(st.value);
|
|
410
431
|
setCursor(st.cursor);
|
|
411
432
|
setMode(st.mode);
|
|
@@ -416,20 +437,54 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
|
|
|
416
437
|
}
|
|
417
438
|
return;
|
|
418
439
|
}
|
|
440
|
+
if (key.upArrow) {
|
|
441
|
+
const onFirstLine = cursor === 0 || value.lastIndexOf("\n", cursor - 1) < 0;
|
|
442
|
+
if (onFirstLine) {
|
|
443
|
+
const draft = inputHistory.older(snapshot());
|
|
444
|
+
if (draft)
|
|
445
|
+
restore(draft);
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
setCursor(moveCursorLine(value, cursor, -1));
|
|
449
|
+
}
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (key.downArrow) {
|
|
453
|
+
const onLastLine = value.indexOf("\n", cursor) < 0;
|
|
454
|
+
if (onLastLine) {
|
|
455
|
+
const draft = inputHistory.newer();
|
|
456
|
+
if (draft)
|
|
457
|
+
restore(draft);
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
setCursor(moveCursorLine(value, cursor, 1));
|
|
461
|
+
}
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
419
464
|
if (key.return) {
|
|
465
|
+
if (key.shift || key.meta) {
|
|
466
|
+
set(value.slice(0, cursor) + "\n" + value.slice(cursor), cursor + 1);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
420
469
|
submit(value);
|
|
421
470
|
return;
|
|
422
471
|
}
|
|
423
472
|
if (key.leftArrow)
|
|
424
|
-
return setCursor((c) =>
|
|
473
|
+
return setCursor((c) => previousGraphemeIndex(value, c));
|
|
425
474
|
if (key.rightArrow)
|
|
426
|
-
return setCursor((c) =>
|
|
475
|
+
return setCursor((c) => nextGraphemeIndex(value, c));
|
|
427
476
|
if (key.ctrl && input === "a")
|
|
428
477
|
return setCursor(0);
|
|
429
478
|
if (key.ctrl && input === "e")
|
|
430
479
|
return setCursor(value.length);
|
|
431
480
|
if (key.ctrl && input === "u")
|
|
432
481
|
return set(value.slice(cursor), 0);
|
|
482
|
+
if (key.ctrl && input === "w") {
|
|
483
|
+
const start = previousWordIndex(value, cursor);
|
|
484
|
+
return set(value.slice(0, start) + value.slice(cursor), start);
|
|
485
|
+
}
|
|
486
|
+
if (key.ctrl && input === "k")
|
|
487
|
+
return set(value.slice(0, cursor), cursor);
|
|
433
488
|
if (key.ctrl && input === "v") {
|
|
434
489
|
// paste a screenshot / image from the OS clipboard
|
|
435
490
|
const img = onClipboardImage?.();
|
|
@@ -442,6 +497,7 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
|
|
|
442
497
|
const head = value.slice(0, cursor);
|
|
443
498
|
const pm = /\[Paste #(\d+) \+\d+ lines\]\s?$/.exec(head); // paste token deletes whole + renumbers
|
|
444
499
|
if (pm) {
|
|
500
|
+
inputHistory.abandonNavigation();
|
|
445
501
|
const n = Number(pm[1]);
|
|
446
502
|
const kept = head.slice(0, pm.index) + value.slice(cursor);
|
|
447
503
|
const renumbered = kept.replace(/\[Paste #(\d+)( \+\d+ lines\])/g, (m2, d, tail) => (Number(d) > n ? `[Paste #${Number(d) - 1}${tail}` : m2));
|
|
@@ -454,6 +510,7 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
|
|
|
454
510
|
}
|
|
455
511
|
const tm = /\[Image #(\d+)\]\s?$/.exec(head); // backspacing over an attachment token removes it whole
|
|
456
512
|
if (tm) {
|
|
513
|
+
inputHistory.abandonNavigation();
|
|
457
514
|
const n = Number(tm[1]);
|
|
458
515
|
const kept = head.slice(0, tm.index) + value.slice(cursor);
|
|
459
516
|
const renumbered = kept.replace(/\[Image #(\d+)\]/g, (m2, d) => (Number(d) > n ? `[Image #${Number(d) - 1}]` : m2));
|
|
@@ -464,7 +521,8 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
|
|
|
464
521
|
setDismissed(false);
|
|
465
522
|
return;
|
|
466
523
|
}
|
|
467
|
-
|
|
524
|
+
const previous = previousGraphemeIndex(value, cursor);
|
|
525
|
+
set(value.slice(0, previous) + value.slice(cursor), previous);
|
|
468
526
|
}
|
|
469
527
|
return;
|
|
470
528
|
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Pure composer state: bounded shell-style history plus Unicode-safe cursor helpers. Keeping this out
|
|
2
|
+
// of the Ink component makes navigation deterministic and avoids copying a growing history on each key.
|
|
3
|
+
const cloneDraft = (draft) => ({
|
|
4
|
+
value: draft.value,
|
|
5
|
+
attachments: [...draft.attachments],
|
|
6
|
+
pastes: [...draft.pastes],
|
|
7
|
+
});
|
|
8
|
+
const draftChars = (draft) => draft.value.length + draft.pastes.reduce((sum, paste) => sum + paste.length, 0) + draft.attachments.length * 128;
|
|
9
|
+
/** Bounded in-process history. The draft that existed before Up is restored after navigating Down. */
|
|
10
|
+
export class ComposerHistory {
|
|
11
|
+
maxEntries;
|
|
12
|
+
maxChars;
|
|
13
|
+
entries = [];
|
|
14
|
+
cursor = null;
|
|
15
|
+
scratch = null;
|
|
16
|
+
totalChars = 0;
|
|
17
|
+
constructor(maxEntries = 100, maxChars = 2_000_000) {
|
|
18
|
+
this.maxEntries = maxEntries;
|
|
19
|
+
this.maxChars = maxChars;
|
|
20
|
+
}
|
|
21
|
+
get browsing() {
|
|
22
|
+
return this.cursor !== null;
|
|
23
|
+
}
|
|
24
|
+
get length() {
|
|
25
|
+
return this.entries.length;
|
|
26
|
+
}
|
|
27
|
+
record(draft) {
|
|
28
|
+
this.abandonNavigation();
|
|
29
|
+
const chars = draftChars(draft);
|
|
30
|
+
// A giant paste is already retained by the conversation history. Do not duplicate an entry larger
|
|
31
|
+
// than the entire composer-history budget just to support Up-arrow recall.
|
|
32
|
+
if (chars > this.maxChars || this.maxEntries <= 0)
|
|
33
|
+
return;
|
|
34
|
+
this.entries.push({ draft: cloneDraft(draft), chars });
|
|
35
|
+
this.totalChars += chars;
|
|
36
|
+
while (this.entries.length > this.maxEntries || this.totalChars > this.maxChars) {
|
|
37
|
+
this.totalChars -= this.entries.shift().chars;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
older(current) {
|
|
41
|
+
if (!this.entries.length)
|
|
42
|
+
return null;
|
|
43
|
+
if (this.cursor === null) {
|
|
44
|
+
this.scratch = cloneDraft(current);
|
|
45
|
+
this.cursor = this.entries.length - 1;
|
|
46
|
+
}
|
|
47
|
+
else if (this.cursor > 0) {
|
|
48
|
+
this.cursor--;
|
|
49
|
+
}
|
|
50
|
+
return cloneDraft(this.entries[this.cursor].draft);
|
|
51
|
+
}
|
|
52
|
+
newer() {
|
|
53
|
+
if (this.cursor === null)
|
|
54
|
+
return null;
|
|
55
|
+
if (this.cursor < this.entries.length - 1) {
|
|
56
|
+
this.cursor++;
|
|
57
|
+
return cloneDraft(this.entries[this.cursor].draft);
|
|
58
|
+
}
|
|
59
|
+
const draft = this.scratch ?? { value: "", attachments: [], pastes: [] };
|
|
60
|
+
this.cursor = null;
|
|
61
|
+
this.scratch = null;
|
|
62
|
+
return cloneDraft(draft);
|
|
63
|
+
}
|
|
64
|
+
abandonNavigation() {
|
|
65
|
+
this.cursor = null;
|
|
66
|
+
this.scratch = null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
let segmenter;
|
|
70
|
+
function graphemeSegmenter() {
|
|
71
|
+
// Constructing Intl.Segmenter loads ICU data. Defer that cost until the first actual cursor edit so
|
|
72
|
+
// lightweight commands such as `hara --version` do not pay for TUI Unicode support.
|
|
73
|
+
if (segmenter === undefined)
|
|
74
|
+
segmenter = typeof Intl.Segmenter === "function" ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) : null;
|
|
75
|
+
return segmenter;
|
|
76
|
+
}
|
|
77
|
+
/** Previous user-perceived character boundary (keeps emoji ZWJ sequences/combining marks intact). */
|
|
78
|
+
export function previousGraphemeIndex(value, cursor) {
|
|
79
|
+
const at = Math.max(0, Math.min(value.length, cursor));
|
|
80
|
+
if (at === 0)
|
|
81
|
+
return 0;
|
|
82
|
+
const segments = graphemeSegmenter();
|
|
83
|
+
if (!segments) {
|
|
84
|
+
const low = value.charCodeAt(at - 1);
|
|
85
|
+
const paired = low >= 0xdc00 && low <= 0xdfff && at > 1 && value.charCodeAt(at - 2) >= 0xd800 && value.charCodeAt(at - 2) <= 0xdbff;
|
|
86
|
+
return Math.max(0, at - (paired ? 2 : 1));
|
|
87
|
+
}
|
|
88
|
+
let previous = 0;
|
|
89
|
+
for (const part of segments.segment(value)) {
|
|
90
|
+
if (part.index >= at)
|
|
91
|
+
break;
|
|
92
|
+
previous = part.index;
|
|
93
|
+
}
|
|
94
|
+
return previous;
|
|
95
|
+
}
|
|
96
|
+
/** Next user-perceived character boundary. */
|
|
97
|
+
export function nextGraphemeIndex(value, cursor) {
|
|
98
|
+
const at = Math.max(0, Math.min(value.length, cursor));
|
|
99
|
+
if (at >= value.length)
|
|
100
|
+
return value.length;
|
|
101
|
+
const segments = graphemeSegmenter();
|
|
102
|
+
if (!segments) {
|
|
103
|
+
const cp = value.codePointAt(at);
|
|
104
|
+
return Math.min(value.length, at + (cp !== undefined && cp > 0xffff ? 2 : 1));
|
|
105
|
+
}
|
|
106
|
+
for (const part of segments.segment(value)) {
|
|
107
|
+
const end = part.index + part.segment.length;
|
|
108
|
+
if (end > at)
|
|
109
|
+
return end;
|
|
110
|
+
}
|
|
111
|
+
return value.length;
|
|
112
|
+
}
|
|
113
|
+
/** Start of the previous shell-style word, used by Ctrl+W. */
|
|
114
|
+
export function previousWordIndex(value, cursor) {
|
|
115
|
+
let at = Math.max(0, Math.min(value.length, cursor));
|
|
116
|
+
while (at > 0) {
|
|
117
|
+
const previous = previousGraphemeIndex(value, at);
|
|
118
|
+
if (!/\s/u.test(value.slice(previous, at)))
|
|
119
|
+
break;
|
|
120
|
+
at = previous;
|
|
121
|
+
}
|
|
122
|
+
while (at > 0) {
|
|
123
|
+
const previous = previousGraphemeIndex(value, at);
|
|
124
|
+
if (/\s/u.test(value.slice(previous, at)))
|
|
125
|
+
break;
|
|
126
|
+
at = previous;
|
|
127
|
+
}
|
|
128
|
+
return at;
|
|
129
|
+
}
|
|
130
|
+
function graphemeFloor(value, cursor) {
|
|
131
|
+
const at = Math.max(0, Math.min(value.length, cursor));
|
|
132
|
+
if (at === value.length)
|
|
133
|
+
return at;
|
|
134
|
+
const segments = graphemeSegmenter();
|
|
135
|
+
if (!segments)
|
|
136
|
+
return at > 0 && /[\uDC00-\uDFFF]/.test(value[at]) ? at - 1 : at;
|
|
137
|
+
let floor = 0;
|
|
138
|
+
for (const part of segments.segment(value)) {
|
|
139
|
+
if (part.index > at)
|
|
140
|
+
break;
|
|
141
|
+
floor = part.index;
|
|
142
|
+
if (part.index + part.segment.length === at)
|
|
143
|
+
return at;
|
|
144
|
+
}
|
|
145
|
+
return floor;
|
|
146
|
+
}
|
|
147
|
+
/** Move between logical pasted/typed lines while retaining the closest possible column. */
|
|
148
|
+
export function moveCursorLine(value, cursor, direction) {
|
|
149
|
+
const at = Math.max(0, Math.min(value.length, cursor));
|
|
150
|
+
const lineStart = (at === 0 ? -1 : value.lastIndexOf("\n", at - 1)) + 1;
|
|
151
|
+
const lineEndAt = value.indexOf("\n", at);
|
|
152
|
+
const lineEnd = lineEndAt < 0 ? value.length : lineEndAt;
|
|
153
|
+
const column = Math.min(at - lineStart, lineEnd - lineStart);
|
|
154
|
+
if (direction < 0) {
|
|
155
|
+
if (lineStart === 0)
|
|
156
|
+
return at;
|
|
157
|
+
const previousEnd = lineStart - 1;
|
|
158
|
+
const previousStart = value.lastIndexOf("\n", Math.max(0, previousEnd - 1)) + 1;
|
|
159
|
+
return graphemeFloor(value, previousStart + Math.min(column, previousEnd - previousStart));
|
|
160
|
+
}
|
|
161
|
+
if (lineEnd === value.length)
|
|
162
|
+
return at;
|
|
163
|
+
const nextStart = lineEnd + 1;
|
|
164
|
+
const nextEndAt = value.indexOf("\n", nextStart);
|
|
165
|
+
const nextEnd = nextEndAt < 0 ? value.length : nextEndAt;
|
|
166
|
+
return graphemeFloor(value, nextStart + Math.min(column, nextEnd - nextStart));
|
|
167
|
+
}
|