@nanhara/hara 0.108.1 → 0.109.1
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 +44 -0
- package/dist/agent/compact.js +11 -0
- package/dist/index.js +10 -4
- package/dist/providers/anthropic.js +35 -2
- package/dist/sandbox.js +38 -2
- package/dist/tui/InputBox.js +52 -14
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,50 @@ All notable changes to `@nanhara/hara`.
|
|
|
5
5
|
> Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
|
|
6
6
|
> **patch** (last) number bumps for **optimizations/fixes of existing features**.
|
|
7
7
|
|
|
8
|
+
## 0.109.2 — long paste no longer freezes the input box
|
|
9
|
+
|
|
10
|
+
- **The input box now draws a bottom-anchored viewport, not every wrapped row.** A long multi-line
|
|
11
|
+
paste (a spec, a stack trace, a design brief) wraps to hundreds of rows; rendering *all* of them on
|
|
12
|
+
every keystroke floods ink's layout+diff and the box appears frozen ("卡着" — you type and nothing
|
|
13
|
+
moves). It now renders at most ~14 rows around the cursor with `⋯ N more lines above/below` markers,
|
|
14
|
+
so a huge paste stays smooth and the box stays a sane height. (This closes the freeze that 0.109.0's
|
|
15
|
+
"paste inserts as real multi-line text" could reach — the two ship as a pair.) A tip if a paste was
|
|
16
|
+
meant to *launch* a skill: a plain long message relies on the model to pick the skill; prefixing the
|
|
17
|
+
explicit command (e.g. `/design <brief>`) enters that mode deterministically and immediately.
|
|
18
|
+
|
|
19
|
+
## 0.109.1 — prompt caching + dynamic compaction (the "why is it slow" fix)
|
|
20
|
+
|
|
21
|
+
- **Prompt caching, finally on.** Every turn used to re-send *and re-process* the whole prompt —
|
|
22
|
+
system + all tool definitions + the entire growing history — with **no cache breakpoints**, so the
|
|
23
|
+
model re-billed and re-crunched everything from scratch each turn. The longer the session, the
|
|
24
|
+
slower and pricier each reply. hara now sets Anthropic `cache_control` breakpoints on the static
|
|
25
|
+
prefix (system, which covers tools+system in cache order) and two rolling points at the message tail,
|
|
26
|
+
so each turn reads the unchanged prefix **from cache** (~10% the cost, and far lower time-to-first-
|
|
27
|
+
token). This is the single biggest latency win as history grows. (Cache engages once the prefix
|
|
28
|
+
passes Anthropic's ~1024-token minimum — i.e. any real coding session.)
|
|
29
|
+
- **Auto-compaction now actually fires on big-window models.** It triggered at 85% of the context
|
|
30
|
+
window — but on a 1M-token model that's **850k tokens**, a size a session drags sluggishly toward and
|
|
31
|
+
realistically never hits, so it never compacted and the prompt just kept bloating. Added a **dynamic
|
|
32
|
+
absolute cap**: compact once the live context passes ~200k tokens *regardless* of window size (either
|
|
33
|
+
trigger — % of window OR the cap — fires). Override with `HARA_AUTO_COMPACT_TOKENS`; opt out as before
|
|
34
|
+
with `autoCompact: false` / `HARA_AUTO_COMPACT=0`.
|
|
35
|
+
- Studied codex (Rust `prompt_cache_key` + a 1000-char paste-fold threshold) and cc-haha (Claude Code's
|
|
36
|
+
session-stable cache TTL) to land the breakpoint layout and keep the TTL steady within a session.
|
|
37
|
+
|
|
38
|
+
## 0.109.0 — real multi-line input + Windows shell
|
|
39
|
+
|
|
40
|
+
- **Pasted multi-line text is now real, editable text in the box** — not a `[Paste]` token, not an
|
|
41
|
+
auto-submit. The input box renders `\n` as actual line breaks (deterministic wrap now treats a
|
|
42
|
+
newline as a hard row break), so a pasted paragraph / code / stack trace shows in full, you edit it,
|
|
43
|
+
and only a real **Enter** sends it. (0.108.1 over-corrected by folding every paste to a token; this
|
|
44
|
+
is codex's behavior — the composer is a multi-line textarea, a pasted newline is content.) A truly
|
|
45
|
+
enormous dump (>8000 chars) still folds to a token so it can't wall off the screen.
|
|
46
|
+
- **Windows: the shell no longer hard-fails.** The bash tool hardcoded `/bin/sh`, which doesn't exist
|
|
47
|
+
on Windows — so every command errored. Now on Windows hara prefers a real **bash** (Git Bash / WSL,
|
|
48
|
+
which it probes on PATH) so the POSIX commands the model writes keep working, and falls back to
|
|
49
|
+
`cmd.exe` with a one-time notice pointing at the fix. (Still no auto-`cron install` or sandbox on
|
|
50
|
+
Windows — those stay Unix-only, as before.)
|
|
51
|
+
|
|
8
52
|
## 0.108.1 — pasting no longer sends the message
|
|
9
53
|
|
|
10
54
|
- **A pasted newline is content, not "send".** Pasting multi-line text used to fire the message at
|
package/dist/agent/compact.js
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
// which reuses the manual /compact path; this just decides *when* to fire.
|
|
4
4
|
/** Auto-compact once the last turn used ≥ this % of the model's context window. */
|
|
5
5
|
export const AUTO_COMPACT_PCT = 85;
|
|
6
|
+
/** Dynamic absolute ceiling. On a 1M-window model, 85% == 850k tokens — a size a session drags for a
|
|
7
|
+
* long, sluggish while before ever reaching, so the %-trigger effectively never fires and every turn
|
|
8
|
+
* re-sends a bloated prompt. This cap makes auto-compaction actually engage at a snappy working size
|
|
9
|
+
* regardless of how large the window is. Overridable via `HARA_AUTO_COMPACT_TOKENS`. */
|
|
10
|
+
export const AUTO_COMPACT_TOKEN_CAP = 200_000;
|
|
6
11
|
/** The compaction brief (shared by /compact and auto-compaction). Eight sections, mirroring Claude
|
|
7
12
|
* Code's AU2 template — the two that matter most beyond the obvious: **All user messages** (the
|
|
8
13
|
* user's own words survive verbatim, so however hard the history is squeezed, intent never drifts)
|
|
@@ -47,3 +52,9 @@ export function buildFileRestore(paths, readFn, opts) {
|
|
|
47
52
|
export function shouldAutoCompact(ctxPct, historyLen, autoCompact, threshold = AUTO_COMPACT_PCT) {
|
|
48
53
|
return autoCompact && historyLen >= 4 && ctxPct >= threshold;
|
|
49
54
|
}
|
|
55
|
+
/** Absolute-size companion to shouldAutoCompact: fire once the last turn's real token count crosses the
|
|
56
|
+
* cap. This is what makes auto-compaction engage on huge-window models, where the %-trigger sits at an
|
|
57
|
+
* unreachable 850k. Either trigger (this OR the %-of-window one) compacts. */
|
|
58
|
+
export function shouldAutoCompactTokens(lastInputTokens, historyLen, autoCompact, cap = AUTO_COMPACT_TOKEN_CAP) {
|
|
59
|
+
return autoCompact && historyLen >= 4 && lastInputTokens >= cap;
|
|
60
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -24,7 +24,7 @@ import { clearEnrollment, enrollDevice, heartbeat, syncOrgRoles } from "./org-fl
|
|
|
24
24
|
import { loadActiveProfile, listProfiles, useProfile, addProfile, upsertProfile, removeProfile, setModel as setProfileModel, resetModel as resetProfileModel, getProfile, effectiveModel, routingLabel, routeHost, activeId, resolveActive, setFlagOverride, writePin, removePin, pinFilePath, DEFAULT_ORG_ID, PERSONAL_ID, } from "./profile/profile.js";
|
|
25
25
|
import { loadPermissionRules, scaffoldPermissions, globalPermissionsPath, projectPermissionsPath } from "./security/permissions.js";
|
|
26
26
|
import { routingProvider } from "./agent/route.js";
|
|
27
|
-
import { shouldAutoCompact, COMPACT_SYSTEM, buildFileRestore } from "./agent/compact.js";
|
|
27
|
+
import { shouldAutoCompact, shouldAutoCompactTokens, AUTO_COMPACT_TOKEN_CAP, COMPACT_SYSTEM, buildFileRestore } from "./agent/compact.js";
|
|
28
28
|
import { recentTouched } from "./agent/touched.js";
|
|
29
29
|
import { INTERJECT_PREFIX } from "./agent/reminders.js";
|
|
30
30
|
import { checkForUpdate } from "./update-check.js";
|
|
@@ -770,10 +770,16 @@ async function compactConversation(provider, history, meta, stats) {
|
|
|
770
770
|
* doesn't overflow. Opt-out via `autoCompact: false` / `HARA_AUTO_COMPACT=0`. Best-effort; `notify` surfaces
|
|
771
771
|
* a one-line status. Returns true if it compacted. */
|
|
772
772
|
async function maybeAutoCompact(provider, history, meta, stats, cfg, notify) {
|
|
773
|
-
const
|
|
774
|
-
|
|
773
|
+
const lastInput = stats.lastInput ?? 0;
|
|
774
|
+
const pct = bar.ctxPctFor(cfg.model, lastInput);
|
|
775
|
+
// Two triggers, whichever hits first: % of window (small-window models) OR an absolute token cap
|
|
776
|
+
// (huge-window models, where 85% is an unreachable 850k). Cap is overridable via env.
|
|
777
|
+
const cap = Number(process.env.HARA_AUTO_COMPACT_TOKENS) || AUTO_COMPACT_TOKEN_CAP;
|
|
778
|
+
const overPct = shouldAutoCompact(pct, history.length, cfg.autoCompact);
|
|
779
|
+
const overCap = shouldAutoCompactTokens(lastInput, history.length, cfg.autoCompact, cap);
|
|
780
|
+
if (!overPct && !overCap)
|
|
775
781
|
return false;
|
|
776
|
-
notify(`✻ Auto-compacting conversation (context ${pct}% full)…`);
|
|
782
|
+
notify(`✻ Auto-compacting conversation (context ${pct}% full, ~${Math.round(lastInput / 1000)}k tok)…`);
|
|
777
783
|
const summary = await compactConversation(provider, history, meta, stats);
|
|
778
784
|
notify(summary ? `(auto-compacted — context replaced with a summary; ${meta.workingSet?.length ?? 0} notes kept)` : "(auto-compact failed — use /compact or /clear)");
|
|
779
785
|
return !!summary;
|
|
@@ -51,6 +51,38 @@ export function toAnthropic(history) {
|
|
|
51
51
|
}
|
|
52
52
|
return msgs;
|
|
53
53
|
}
|
|
54
|
+
const CACHE = { type: "ephemeral" };
|
|
55
|
+
/** Attach Anthropic prompt-cache breakpoints so each turn re-reads the static prefix and the
|
|
56
|
+
* conversation prefix FROM CACHE instead of re-billing + re-processing the whole prompt — the single
|
|
57
|
+
* biggest latency + cost win as history grows (uncached, a long session pays full input every turn).
|
|
58
|
+
*
|
|
59
|
+
* Anthropic caps breakpoints at 4 and orders the cache prefix `tools → system → messages`, so a mark
|
|
60
|
+
* on `system` already caches tools+system (the big static block) in one shot. We then spend up to two
|
|
61
|
+
* rolling marks near the message tail: the last message (so THIS turn's full prefix is written) and one
|
|
62
|
+
* ~2 back (so the NEXT turn — which appends new messages — still finds a long cached prefix to read).
|
|
63
|
+
* Mutates `messages` in place (toAnthropic hands us a fresh array each turn); returns the request-shaped
|
|
64
|
+
* system. Exported pure for tests. */
|
|
65
|
+
export function applyCacheControl(system, messages) {
|
|
66
|
+
const mark = (m) => {
|
|
67
|
+
if (typeof m.content === "string") {
|
|
68
|
+
if (m.content.length)
|
|
69
|
+
m.content = [{ type: "text", text: m.content, cache_control: CACHE }];
|
|
70
|
+
}
|
|
71
|
+
else if (m.content.length) {
|
|
72
|
+
m.content[m.content.length - 1].cache_control = CACHE;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
const idxs = new Set();
|
|
76
|
+
if (messages.length)
|
|
77
|
+
idxs.add(messages.length - 1);
|
|
78
|
+
if (messages.length >= 3)
|
|
79
|
+
idxs.add(messages.length - 3);
|
|
80
|
+
for (const i of idxs)
|
|
81
|
+
mark(messages[i]);
|
|
82
|
+
// Only cache system when it's substantial enough to matter (an empty text block would 400).
|
|
83
|
+
const cachedSystem = system ? [{ type: "text", text: system, cache_control: CACHE }] : system;
|
|
84
|
+
return { system: cachedSystem, messages };
|
|
85
|
+
}
|
|
54
86
|
/** Anthropic models whose only valid `thinking` setting is `{type: "adaptive"}` — they reject any
|
|
55
87
|
* explicit `budget_tokens`. We detect them by id family so "off"/low/high still degrade gracefully
|
|
56
88
|
* (we just omit the field or stay on adaptive instead of sending a 400-triggering body). */
|
|
@@ -84,13 +116,14 @@ export function createAnthropicProvider(opts) {
|
|
|
84
116
|
model: opts.model,
|
|
85
117
|
async turn({ system, history, tools, onText, onReasoning, signal }) {
|
|
86
118
|
const thinking = buildThinkingParam(opts.model, opts.reasoningEffort);
|
|
119
|
+
const { system: cachedSystem, messages } = applyCacheControl(system, toAnthropic(history));
|
|
87
120
|
const stream = client.messages.stream({
|
|
88
121
|
model: opts.model,
|
|
89
122
|
max_tokens: 32000,
|
|
90
123
|
...(thinking ? { thinking } : {}),
|
|
91
|
-
system,
|
|
124
|
+
system: cachedSystem,
|
|
92
125
|
tools: tools,
|
|
93
|
-
messages
|
|
126
|
+
messages,
|
|
94
127
|
}, { signal });
|
|
95
128
|
stream.on("text", onText);
|
|
96
129
|
if (onReasoning)
|
package/dist/sandbox.js
CHANGED
|
@@ -5,10 +5,33 @@
|
|
|
5
5
|
// project, NOT a determined exfiltration. Other platforms run UNSANDBOXED (a one-time warning is
|
|
6
6
|
// emitted from runShell so every entry point — REPL, -p, org, cron — surfaces it). Only the `bash`
|
|
7
7
|
// shell is sandboxed; hara's own file tools are in-process, explicit, and gated by the approval flow.
|
|
8
|
-
import { spawn } from "node:child_process";
|
|
8
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
9
9
|
import { writeFileSync, mkdtempSync } from "node:fs";
|
|
10
10
|
import { tmpdir, platform } from "node:os";
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
+
// Windows shell resolution. hara (and the model) speak POSIX shell — the agent writes `ls`, `grep`,
|
|
13
|
+
// `cat`, pipes, `&&`. So on Windows we PREFER a real bash (Git Bash or WSL, which most Windows devs
|
|
14
|
+
// have) and only fall back to cmd.exe when none is found. Memoized: the PATH probe runs at most once.
|
|
15
|
+
let _winBash;
|
|
16
|
+
function findWindowsBash() {
|
|
17
|
+
if (_winBash !== undefined)
|
|
18
|
+
return _winBash;
|
|
19
|
+
// `where bash` finds Git Bash / WSL bash on PATH; also probe the default Git-for-Windows location.
|
|
20
|
+
const onPath = spawnSync("where", ["bash"], { encoding: "utf8" });
|
|
21
|
+
const hit = onPath.status === 0 ? String(onPath.stdout).split(/\r?\n/).find((l) => l.trim()) : "";
|
|
22
|
+
_winBash = (hit && hit.trim()) || null;
|
|
23
|
+
return _winBash;
|
|
24
|
+
}
|
|
25
|
+
/** Pure shell-argv resolution — split out so the platform branching is unit-testable without spawning.
|
|
26
|
+
* `plat` and `bash` are injected; production passes the real platform() + findWindowsBash(). */
|
|
27
|
+
export function resolveShellArgv(command, plat, bash) {
|
|
28
|
+
if (plat === "win32") {
|
|
29
|
+
// A real bash keeps POSIX commands working; cmd.exe is the last resort (most `ls/grep` will fail
|
|
30
|
+
// there — the model should be told, see maybeWarnWindowsShell).
|
|
31
|
+
return bash ? { cmd: bash, args: ["-c", command] } : { cmd: "cmd.exe", args: ["/d", "/s", "/c", command] };
|
|
32
|
+
}
|
|
33
|
+
return { cmd: "/bin/sh", args: ["-c", command] };
|
|
34
|
+
}
|
|
12
35
|
const sbQuote = (s) => '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
|
|
13
36
|
function seatbeltProfile(cwd, mode) {
|
|
14
37
|
const writable = [
|
|
@@ -43,7 +66,20 @@ export function shellCommand(command, cwd, mode) {
|
|
|
43
66
|
return { cmd: "sandbox-exec", args: ["-f", profileFile, "/bin/bash", "-lc", command] };
|
|
44
67
|
}
|
|
45
68
|
maybeWarnUnsandboxed(mode);
|
|
46
|
-
|
|
69
|
+
const plat = platform();
|
|
70
|
+
if (plat === "win32")
|
|
71
|
+
maybeWarnWindowsShell();
|
|
72
|
+
return resolveShellArgv(command, plat, plat === "win32" ? findWindowsBash() : null);
|
|
73
|
+
}
|
|
74
|
+
/** One-time notice on Windows when no bash is found — the POSIX commands the model writes won't run
|
|
75
|
+
* under cmd.exe. Points the user at the fix (install Git for Windows or run under WSL). */
|
|
76
|
+
let warnedWinShell = false;
|
|
77
|
+
function maybeWarnWindowsShell() {
|
|
78
|
+
if (warnedWinShell || findWindowsBash())
|
|
79
|
+
return;
|
|
80
|
+
warnedWinShell = true;
|
|
81
|
+
process.stderr.write("hara: no bash found on PATH — shell commands run under cmd.exe, where most Unix commands (ls, grep, cat) fail.\n" +
|
|
82
|
+
" Install Git for Windows (bundles bash) or run hara inside WSL for full command support.\n");
|
|
47
83
|
}
|
|
48
84
|
/** One-time-per-process notice that --sandbox is a no-op off macOS (covers every entry point). */
|
|
49
85
|
export function maybeWarnUnsandboxed(mode) {
|
package/dist/tui/InputBox.js
CHANGED
|
@@ -126,12 +126,13 @@ export function wrapRows(value, cols) {
|
|
|
126
126
|
pos += p.text.length;
|
|
127
127
|
}
|
|
128
128
|
else {
|
|
129
|
-
// Break the text run into
|
|
130
|
-
|
|
129
|
+
// Break the text run into: a hard newline (its own atom → forces a row break, so a pasted/typed
|
|
130
|
+
// `\n` renders as an actual line), a word + trailing NON-newline whitespace, or a run of spaces.
|
|
131
|
+
const re = /\n|\S+[^\S\n]*|[^\S\n]+/g;
|
|
131
132
|
let m;
|
|
132
|
-
|
|
133
|
+
const base = pos;
|
|
133
134
|
while ((m = re.exec(p.text))) {
|
|
134
|
-
atoms.push({ text: m[0], start: base + m.index, atomic: false });
|
|
135
|
+
atoms.push({ text: m[0], start: base + m.index, atomic: false, br: m[0] === "\n" });
|
|
135
136
|
}
|
|
136
137
|
pos += p.text.length;
|
|
137
138
|
}
|
|
@@ -147,6 +148,12 @@ export function wrapRows(value, cols) {
|
|
|
147
148
|
};
|
|
148
149
|
for (const a of atoms) {
|
|
149
150
|
const aEnd = a.start + a.text.length;
|
|
151
|
+
if (a.br) {
|
|
152
|
+
// A newline ends the current row: the `\n` sits at the row's end (renderRow strips it from the
|
|
153
|
+
// displayed text). The next row starts right after it — rows stay contiguous over the value.
|
|
154
|
+
flush(aEnd);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
150
157
|
if (a.atomic || a.text.length <= width) {
|
|
151
158
|
// A whole unit (image token OR a word chunk that fits within a full row): if it doesn't fit in
|
|
152
159
|
// the remaining room and the row already has content, wrap to a fresh row FIRST — never split it.
|
|
@@ -188,7 +195,9 @@ export function wrapRows(value, cols) {
|
|
|
188
195
|
* cursor (inverse cell) when it falls on this row. Splitting happens on precomputed rows so the
|
|
189
196
|
* whole prompt never reflows as a single <Text> — stable under wrapping and quick to diff. */
|
|
190
197
|
function renderRow(value, row, cursor, showCursor, isLastRow, keyPrefix) {
|
|
191
|
-
|
|
198
|
+
// `\n` chars are row SEPARATORS (wrapRows already broke the row on them) — never render them, or ink
|
|
199
|
+
// would insert a second line break and desync the deterministic layout.
|
|
200
|
+
const seg = (token, text, k) => token ? (_jsx(Text, { backgroundColor: "magenta", color: "white", children: text.replace(/\n/g, "") }, k)) : (_jsx(Text, { children: text.replace(/\n/g, "") }, k));
|
|
192
201
|
// Segments intersected with this row's [start,end) range.
|
|
193
202
|
const parts = segmentize(value);
|
|
194
203
|
const nodes = [];
|
|
@@ -208,7 +217,7 @@ function renderRow(value, row, cursor, showCursor, isLastRow, keyPrefix) {
|
|
|
208
217
|
const rel = cursor - from;
|
|
209
218
|
if (rel > 0)
|
|
210
219
|
nodes.push(seg(p.token, slice.slice(0, rel), `${keyPrefix}s${ki++}`));
|
|
211
|
-
nodes.push(_jsx(Text, { inverse: true, children: slice[rel] }, `${keyPrefix}c${ki++}`));
|
|
220
|
+
nodes.push(_jsx(Text, { inverse: true, children: slice[rel] === "\n" ? " " : slice[rel] }, `${keyPrefix}c${ki++}`));
|
|
212
221
|
if (rel + 1 < slice.length)
|
|
213
222
|
nodes.push(seg(p.token, slice.slice(rel + 1), `${keyPrefix}e${ki++}`));
|
|
214
223
|
}
|
|
@@ -222,6 +231,26 @@ function renderRow(value, row, cursor, showCursor, isLastRow, keyPrefix) {
|
|
|
222
231
|
}
|
|
223
232
|
return nodes;
|
|
224
233
|
}
|
|
234
|
+
/** Max input rows drawn at once. A long multi-line paste (a spec, a stack trace, a design brief) wraps
|
|
235
|
+
* to hundreds/thousands of rows; rendering them ALL every keystroke floods ink's layout+diff and the
|
|
236
|
+
* box appears frozen ("卡着"). So we draw a bottom-anchored viewport (codex-style) around the cursor. */
|
|
237
|
+
export const MAX_INPUT_ROWS = 14;
|
|
238
|
+
/** The [first, last) slice of rows to render so the cursor stays visible without drawing the whole
|
|
239
|
+
* input. Bottom-anchored: when the cursor is at the end (typing), you see the last MAX rows; when
|
|
240
|
+
* editing mid-text, the cursor's row + a little context below stays on screen. Exported pure for tests. */
|
|
241
|
+
export function windowRows(rowCount, cursorRow, max = MAX_INPUT_ROWS) {
|
|
242
|
+
if (rowCount <= max)
|
|
243
|
+
return { first: 0, last: rowCount };
|
|
244
|
+
const last = Math.min(rowCount, Math.max(cursorRow + 2, max));
|
|
245
|
+
return { first: Math.max(0, last - max), last };
|
|
246
|
+
}
|
|
247
|
+
/** Index of the wrapped row that holds the cursor (last row if past the end). */
|
|
248
|
+
export function cursorRowIndex(rows, cursor) {
|
|
249
|
+
for (let i = 0; i < rows.length; i++)
|
|
250
|
+
if (cursor >= rows[i].start && cursor <= rows[i].end)
|
|
251
|
+
return i;
|
|
252
|
+
return Math.max(0, rows.length - 1);
|
|
253
|
+
}
|
|
225
254
|
/** The prompt: gutter + wrapped input rows (or a placeholder when empty). Each wrapped continuation
|
|
226
255
|
* row is indented under the gutter so the text column is stable. Memoized so it only re-renders when
|
|
227
256
|
* the value/cursor/width/gutter actually change (not when unrelated status ticks over). */
|
|
@@ -231,7 +260,11 @@ const InputLine = memo(function InputLine({ value, cursor, width, gutter, gutter
|
|
|
231
260
|
if (value.length === 0) {
|
|
232
261
|
return (_jsxs(Box, { children: [_jsx(Text, { color: gutterColor, children: gutter }), _jsxs(Text, { children: [_jsx(Text, { inverse: true, children: " " }), _jsx(Text, { dimColor: true, children: placeholder })] })] }));
|
|
233
262
|
}
|
|
234
|
-
|
|
263
|
+
const { first, last } = windowRows(rows.length, cursorRowIndex(rows, cursor));
|
|
264
|
+
return (_jsxs(Box, { flexDirection: "column", children: [first > 0 && _jsx(Text, { dimColor: true, children: ` ⋯ ${first} more line${first > 1 ? "s" : ""} above` }), rows.slice(first, last).map((row, k) => {
|
|
265
|
+
const i = first + k;
|
|
266
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: gutterColor, children: i === 0 ? gutter : " " }), _jsx(Text, { children: renderRow(value, row, cursor, true, i === rows.length - 1, `r${i}_`) })] }, i));
|
|
267
|
+
}), last < rows.length && _jsx(Text, { dimColor: true, children: ` ⋯ ${rows.length - last} more line${rows.length - last > 1 ? "s" : ""} below` })] }));
|
|
235
268
|
});
|
|
236
269
|
/** Bordered prompt box + one dim status footer (model · approval · route · cwd · usage · ctx),
|
|
237
270
|
* with an @path popup. */
|
|
@@ -410,13 +443,18 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
|
|
|
410
443
|
submit(value);
|
|
411
444
|
return;
|
|
412
445
|
}
|
|
413
|
-
// A PASTE
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
|
|
418
|
-
if (
|
|
419
|
-
addPaste(input);
|
|
446
|
+
// A PASTE containing newlines inserts as REAL MULTI-LINE TEXT (normalize CRLF/CR → LF) — the
|
|
447
|
+
// user sees and can edit it, and a pasted newline is content, NOT "send" (only a real Enter,
|
|
448
|
+
// above / key.return, submits). Only a truly ENORMOUS dump folds to a `[Paste #N]` token, so a
|
|
449
|
+
// 500-line paste doesn't turn the box into a wall — normal multi-line pastes stay visible.
|
|
450
|
+
const hasNL = /[\r\n]/.test(input);
|
|
451
|
+
if (input.length >= 8000) {
|
|
452
|
+
addPaste(input.replace(/\r\n?/g, "\n"));
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (hasNL) {
|
|
456
|
+
const text = input.replace(/\r\n?/g, "\n");
|
|
457
|
+
set(value.slice(0, cursor) + text + value.slice(cursor), cursor + text.length);
|
|
420
458
|
return;
|
|
421
459
|
}
|
|
422
460
|
// a dragged-in / pasted image file path attaches instead of inserting literal text
|