@nanhara/hara 0.101.0 → 0.102.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 CHANGED
@@ -5,6 +5,34 @@ 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.102.0 — a slow network never feels dead
9
+
10
+ Jeff + a designer colleague both hit the same thing: press Enter on a slow connection and hara
11
+ "looks stuck — thought it failed". Studied codex's handling (15s connect timeout, 2–9s stream-idle
12
+ timeout, Working[Xs·Esc] status machine) and closed the gaps:
13
+
14
+ - **Stall watchdog.** A model attempt that streams NOTHING for 120s (HARA_STALL_TIMEOUT to tune) is
15
+ aborted and routed through the normal error→failover path — `fallbackModel` picks it up
16
+ automatically, or you get a clear "model stream timeout — no output for 120s" instead of an
17
+ infinite spinner. A real Esc stays an interrupt (never rewritten).
18
+ - **"waiting for the model… Ns".** The status row now distinguishes the pre-first-token stretch from
19
+ actual work (a new turn-phase channel published by the loop) — on a slow route you can SEE the
20
+ request is out, ticking, interruptible.
21
+ - **Big pastes fold to a token.** Pasting a long/multi-line text used to flood the box AND could
22
+ auto-submit at the first newline mid-paste. Now ≥3 lines or ≥600 chars folds to a highlighted
23
+ `[Paste #1 +N lines]` token (Claude-Code style): the box stays small, typing stays smooth,
24
+ backspace deletes it whole, and the FULL text expands into the message on submit.
25
+
26
+ ## 0.101.1 — the input box stops running to the top
27
+
28
+ - **Live-region overflow guard.** A long streaming answer (or a big diff) used to grow the live region
29
+ past the terminal height — at which point ink's in-place repaint breaks and the input box "runs to
30
+ the top of the screen". Live blocks now render a bounded tail window (sized to your terminal, elided
31
+ lines counted in a dim header); the FULL text lands in scrollback the moment the block finalizes,
32
+ and ctrl+t shows it live. Same treatment reasoning got in 0.99.2, now for answers and diffs — the
33
+ dynamic region can no longer outgrow the viewport, which is the invariant that kept codex stable
34
+ (line-level commits) and pushed Claude Code to a fullscreen ScrollBox.
35
+
8
36
  ## 0.101.0 — startup update check
9
37
 
10
38
  - **`hara` tells you when it's out of date.** On launch (interactive TTY only), a one-line notice —
@@ -12,6 +12,16 @@ import { subdirHint } from "../context/subdir-hints.js";
12
12
  import { classifyError, failoverAction, errorHint } from "./failover.js";
13
13
  import { currentTodos, renderTodos } from "../tools/todo.js";
14
14
  import { drainReminders, wrapReminders, pushReminder, todoStaleReminder, TODO_STALE_ROUNDS } from "./reminders.js";
15
+ import { setTurnPhase } from "./phase.js";
16
+ /** Stall watchdog ceiling: a model attempt that streams NOTHING for this long is treated as a dead /
17
+ * stalled connection and aborted into the normal error→failover path — instead of hanging on
18
+ * "working Ns" forever (the "pressed Enter, thought it failed" report). Generous default because
19
+ * hidden-reasoning models can legitimately go quiet for a while; HARA_STALL_TIMEOUT (ms) tunes it,
20
+ * floor 1s (tests). codex's equivalent is its 2–9s stream-idle timeout. */
21
+ export function stallMs() {
22
+ const raw = Number(process.env.HARA_STALL_TIMEOUT ?? 120_000);
23
+ return Math.max(1_000, Number.isFinite(raw) && raw > 0 ? raw : 120_000);
24
+ }
15
25
  /** Spinner verb (terminal mode + reused by TUI tests): when the agent has an in_progress todo,
16
26
  * surface its activeForm/text so the bottom-of-screen line reads concretely ("▶ updating tests… 3s")
17
27
  * instead of "working 3s". Pure: takes a snapshot + elapsed seconds. */
@@ -141,53 +151,89 @@ export async function runAgent(history, opts) {
141
151
  out(`\r\x1b[K${c.dim(`${frames[fi++ % frames.length]} ${verb}`)}`);
142
152
  }, 100);
143
153
  }
144
- const r = await activeProvider.turn({
145
- system: composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory),
146
- history,
147
- tools: specs,
148
- onText: (d) => {
149
- if (opts.quiet)
150
- return;
151
- if (sink) {
152
- sink.text(d);
153
- return;
154
- }
155
- stopSpin();
156
- flushReasoningTail();
157
- if (md)
158
- md.push(d);
159
- else
160
- out(d);
161
- },
162
- onReasoning: sink || tty
163
- ? (d) => {
154
+ // Stall watchdog: any stream event resets the clock; STALL_MS of silence aborts THIS attempt via
155
+ // its own controller (the user's opts.signal chains into it, so Esc still interrupts). The abort
156
+ // is then rewritten from "interrupted" to a timeout-class error so failover can take over.
157
+ const STALL_MS = stallMs();
158
+ const attempt = new AbortController();
159
+ const onUserAbort = () => attempt.abort();
160
+ opts.signal?.addEventListener("abort", onUserAbort, { once: true });
161
+ let lastEvent = Date.now();
162
+ let stalled = false;
163
+ const stallTimer = setInterval(() => {
164
+ if (Date.now() - lastEvent > STALL_MS) {
165
+ stalled = true;
166
+ attempt.abort();
167
+ }
168
+ }, Math.min(2_000, Math.max(250, STALL_MS / 4)));
169
+ const alive = () => {
170
+ lastEvent = Date.now();
171
+ if (!opts.quiet)
172
+ setTurnPhase("streaming");
173
+ };
174
+ if (!opts.quiet)
175
+ setTurnPhase("waiting"); // request sent, nothing streamed yet — the status row shows it
176
+ let r;
177
+ try {
178
+ r = await activeProvider.turn({
179
+ system: composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory),
180
+ history,
181
+ tools: specs,
182
+ onText: (d) => {
183
+ alive();
164
184
  if (opts.quiet)
165
185
  return;
166
186
  if (sink) {
167
- sink.reasoning(d);
187
+ sink.text(d);
168
188
  return;
169
189
  }
170
- // Terminal mode: render reasoning on its own dim lines (prefix `│ ` per line). Each
171
- // line is committed once and never overwritten — so a subsequent spinner tick can't
172
- // clobber it (the old `out(c.dim(d))` bug). Multi-line deltas split cleanly; the
173
- // current line resumes mid-output when the next delta arrives.
174
190
  stopSpin();
175
- const lines = d.split("\n");
176
- for (let i = 0; i < lines.length; i++) {
177
- if (!reasoningOpen) {
178
- out(c.dim("│ "));
179
- reasoningOpen = true;
191
+ flushReasoningTail();
192
+ if (md)
193
+ md.push(d);
194
+ else
195
+ out(d);
196
+ },
197
+ onReasoning: sink || tty
198
+ ? (d) => {
199
+ alive();
200
+ if (opts.quiet)
201
+ return;
202
+ if (sink) {
203
+ sink.reasoning(d);
204
+ return;
180
205
  }
181
- out(c.dim(lines[i]));
182
- if (i < lines.length - 1) {
183
- out("\n");
184
- reasoningOpen = false;
206
+ // Terminal mode: render reasoning on its own dim lines (prefix `│ ` per line). Each
207
+ // line is committed once and never overwritten — so a subsequent spinner tick can't
208
+ // clobber it (the old `out(c.dim(d))` bug). Multi-line deltas split cleanly; the
209
+ // current line resumes mid-output when the next delta arrives.
210
+ stopSpin();
211
+ const lines = d.split("\n");
212
+ for (let i = 0; i < lines.length; i++) {
213
+ if (!reasoningOpen) {
214
+ out(c.dim("│ "));
215
+ reasoningOpen = true;
216
+ }
217
+ out(c.dim(lines[i]));
218
+ if (i < lines.length - 1) {
219
+ out("\n");
220
+ reasoningOpen = false;
221
+ }
185
222
  }
186
223
  }
187
- }
188
- : undefined,
189
- signal: opts.signal,
190
- });
224
+ : (d) => alive(), // quiet runs still feed the watchdog (reasoning-only stretches are progress)
225
+ signal: attempt.signal,
226
+ });
227
+ }
228
+ finally {
229
+ clearInterval(stallTimer);
230
+ opts.signal?.removeEventListener("abort", onUserAbort);
231
+ }
232
+ // A watchdog abort surfaces from the provider as "interrupted" — rewrite it to a timeout-class
233
+ // error (unless the USER really did interrupt) so classifyError → failover/fallback handles it.
234
+ if (stalled && r.stop === "error" && !opts.signal?.aborted) {
235
+ r = { ...r, errorMsg: `model stream timeout — no output for ${Math.round(STALL_MS / 1000)}s (stalled connection?)` };
236
+ }
191
237
  stopSpin();
192
238
  flushReasoningTail();
193
239
  md?.end();
@@ -0,0 +1,29 @@
1
+ // Turn phase for the status line (codex-parity): between "request sent" and "first stream event"
2
+ // the user should see *waiting for the model* — not a generic "working" that reads the same whether
3
+ // the model is thinking or the connection is dead. The MAIN loop publishes (quiet/sub-agent runs
4
+ // don't — they'd stomp the shared channel); the TUI status row subscribes like it does for todos.
5
+ let phase = "idle";
6
+ const listeners = new Set();
7
+ export function turnPhase() {
8
+ return phase;
9
+ }
10
+ export function setTurnPhase(p) {
11
+ if (p === phase)
12
+ return;
13
+ phase = p;
14
+ for (const fn of listeners) {
15
+ try {
16
+ fn(phase);
17
+ }
18
+ catch {
19
+ /* a listener must not break the loop */
20
+ }
21
+ }
22
+ }
23
+ /** Subscribe to phase changes. Returns an unsubscribe fn. */
24
+ export function onTurnPhase(fn) {
25
+ listeners.add(fn);
26
+ return () => {
27
+ listeners.delete(fn);
28
+ };
29
+ }
package/dist/tui/App.js CHANGED
@@ -17,6 +17,7 @@ import { ctxPctFor } from "../statusbar.js";
17
17
  import { accent } from "./theme.js";
18
18
  import { renderMarkdown } from "../md.js";
19
19
  import { clearTodos, currentTodos, onTodosChange } from "../tools/todo.js";
20
+ import { onTurnPhase, turnPhase } from "../agent/phase.js";
20
21
  let _id = 0;
21
22
  const nid = () => ++_id;
22
23
  const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
@@ -43,12 +44,33 @@ const MODE_SELECTOR_MS = 2500;
43
44
  // Memoized: a live block only re-renders when its own `item` (a fresh object when its text grows) or
44
45
  // `open` changes. So a spinner tick or an unrelated flush doesn't re-run `renderMarkdown` for every
45
46
  // live block, and non-tail live blocks stay put while only the streaming tail grows.
46
- const Block = memo(function Block({ item, open }) {
47
+ /** Tail-window a LIVE block's rendered lines so the dynamic region can never outgrow the terminal.
48
+ * ink repaints the whole dynamic region in place; once it's taller than the viewport the erase math
49
+ * breaks and the input box "runs to the top of the screen". Bounding the live view fixes the class:
50
+ * earlier lines are elided with a dim counter, and the FULL text lands in scrollback the moment the
51
+ * block finalizes (nothing is lost — ctrl+t shows it live too). `maxRows` comes from the terminal. */
52
+ function tailWindow(rendered, maxRows) {
53
+ const lines = rendered.replace(/\n+$/, "").split("\n");
54
+ if (lines.length <= maxRows)
55
+ return { header: null, body: rendered };
56
+ return {
57
+ header: `… +${lines.length - maxRows} earlier lines — full text lands above when this block finishes · ctrl+t to view now`,
58
+ body: lines.slice(-maxRows).join("\n"),
59
+ };
60
+ }
61
+ const Block = memo(function Block({ item, open, liveRows }) {
62
+ // Live streaming blocks get a bounded tail view (liveRows set); committed <Static> blocks render full.
63
+ const windowed = (rendered) => {
64
+ if (!liveRows)
65
+ return _jsx(Text, { children: rendered });
66
+ const w = tailWindow(rendered, liveRows);
67
+ return (_jsxs(Box, { flexDirection: "column", children: [w.header ? _jsx(Text, { dimColor: true, children: w.header }) : null, _jsx(Text, { children: w.body })] }));
68
+ };
47
69
  switch (item.kind) {
48
70
  case "user":
49
71
  return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: "\u203A " }), _jsx(Text, { children: item.text })] }));
50
72
  case "assistant":
51
- return _jsx(Text, { children: renderMarkdown(item.text) }); // headers/bold/inline-code/bullets + verbatim fences
73
+ return windowed(renderMarkdown(item.text)); // headers/bold/inline-code/bullets + verbatim fences
52
74
  case "reasoning": {
53
75
  // A streaming reasoning block lives in the dynamic region ABOVE the input box, and the instant the
54
76
  // model stops thinking it FOLDS to a single "✻ thought · N lines" notice in scrollback. If we streamed
@@ -66,7 +88,7 @@ const Block = memo(function Block({ item, open }) {
66
88
  case "tool":
67
89
  return _jsx(Text, { dimColor: true, children: " " + item.text });
68
90
  case "diff":
69
- return _jsx(Text, { children: item.text });
91
+ return windowed(item.text); // a big diff must not blow the live region either
70
92
  case "notice":
71
93
  return _jsx(Text, { dimColor: true, children: item.text });
72
94
  }
@@ -235,7 +257,9 @@ const SPINNER_FRAME_MS = 125;
235
257
  const IDLE_HINTS = "⏎ send · @ file · ctrl+v image · ctrl+t transcript · shift+tab mode";
236
258
  function StatusRow({ working, todos, queued }) {
237
259
  const [frame, setFrame] = useState(0);
260
+ const [phase, setPhase] = useState(() => turnPhase());
238
261
  const startRef = useRef(Date.now());
262
+ useEffect(() => onTurnPhase(setPhase), []); // waiting → streaming, published by the agent loop
239
263
  useEffect(() => {
240
264
  if (!working)
241
265
  return;
@@ -248,7 +272,10 @@ function StatusRow({ working, todos, queued }) {
248
272
  }
249
273
  const frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
250
274
  const elapsedSec = Math.floor((Date.now() - startRef.current) / 1000);
251
- return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: frames[frame % frames.length] }), _jsx(Text, { dimColor: true, children: ` ${spinnerVerb(todos, elapsedSec)} · ⏎ queues${queued ? ` (${queued})` : ""}` })] }));
275
+ // Pre-first-token honesty (codex-parity): "waiting for the model" reads very differently from a
276
+ // generic "working" when the network is slow — the user knows the request is out, not dead.
277
+ const verb = phase === "waiting" ? `waiting for the model… ${elapsedSec}s · esc to interrupt` : spinnerVerb(todos, elapsedSec);
278
+ return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: frames[frame % frames.length] }), _jsx(Text, { dimColor: true, children: ` ${verb} · ⏎ queues${queued ? ` (${queued})` : ""}` })] }));
252
279
  }
253
280
  // Short per-mode descriptions for the ONE-ROW mode line (the old two-row ModeBar's long sentences
254
281
  // don't fit inline). Full behavior is documented in /help; this line is a switching aid, not a manual.
@@ -301,6 +328,11 @@ const TodoPanel = memo(function TodoPanel({ todos }) {
301
328
  });
302
329
  export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval, onClipboardImage, vim, visionNotice }) {
303
330
  const { exit } = useApp();
331
+ const { stdout: termOut } = useStdout();
332
+ // Live tail budget: terminal rows minus the rest of the dynamic chrome (todo panel ≤10, status slot,
333
+ // input box + footer, margins). Keeps the WHOLE dynamic region under the viewport — the invariant that
334
+ // stops ink's repaint from "running to the top" when a long answer/diff streams. Floor 8 for tiny panes.
335
+ const liveRows = Math.max(8, (termOut?.rows ?? 30) - 20);
304
336
  const [history, setHistory] = useState([]);
305
337
  const [current, setCurrent] = useState([]);
306
338
  const [working, setWorking] = useState(false);
@@ -587,5 +619,5 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
587
619
  });
588
620
  if (showTranscript)
589
621
  return _jsx(Transcript, { items: [...history, ...current], onClose: () => setShowTranscript(false) });
590
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: header ? [{ id: -1, kind: "notice", text: "" }, ...history] : history, children: (item) => (item.id === -1 ? _jsx(HeaderCard, { ...header }, "hdr") : _jsx(Block, { item: item }, item.id)) }), current.map((item) => (_jsx(Block, { item: item, open: reasoningOpen }, item.id))), _jsx(TodoPanel, { todos: todos }), prompt && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: ` ${stripAnsi(prompt.title)}` }), prompt.options.map((o, i) => (_jsx(Text, { color: i === promptSel ? "cyan" : undefined, bold: i === promptSel, children: (i === promptSel ? " ❯ " : " ") + `${i + 1}. ` + o.label }, i))), _jsx(Text, { dimColor: true, children: ` ↑↓ or 1–${prompt.options.length} to choose · Enter · Esc cancels` })] })), askText && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: ` ? ${stripAnsi(askText.title)}` }), _jsx(Text, { dimColor: true, children: " type your answer below · Enter to send · Esc cancels" })] })), pool.length > 0 && !prompt && !askText && (_jsx(Box, { flexDirection: "column", children: pool.map((l, i) => (_jsx(Text, { color: accent(), children: ` › ${l.length > 72 ? l.slice(0, 72) + "…" : l}` }, i))) })), modeSelector ? _jsx(ModeLine, { approval: status.approval }) : _jsx(StatusRow, { working: working, todos: todos, queued: pool.length }), _jsx(InputBox, { status: status, cwd: cwd, model: model, route: header?.routeHost, isActive: !prompt, vim: vim, onSubmit: handleSubmit, onClipboardImage: onClipboardImage })] }));
622
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: header ? [{ id: -1, kind: "notice", text: "" }, ...history] : history, children: (item) => (item.id === -1 ? _jsx(HeaderCard, { ...header }, "hdr") : _jsx(Block, { item: item }, item.id)) }), current.map((item) => (_jsx(Block, { item: item, open: reasoningOpen, liveRows: liveRows }, item.id))), _jsx(TodoPanel, { todos: todos }), prompt && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: ` ${stripAnsi(prompt.title)}` }), prompt.options.map((o, i) => (_jsx(Text, { color: i === promptSel ? "cyan" : undefined, bold: i === promptSel, children: (i === promptSel ? " ❯ " : " ") + `${i + 1}. ` + o.label }, i))), _jsx(Text, { dimColor: true, children: ` ↑↓ or 1–${prompt.options.length} to choose · Enter · Esc cancels` })] })), askText && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: ` ? ${stripAnsi(askText.title)}` }), _jsx(Text, { dimColor: true, children: " type your answer below · Enter to send · Esc cancels" })] })), pool.length > 0 && !prompt && !askText && (_jsx(Box, { flexDirection: "column", children: pool.map((l, i) => (_jsx(Text, { color: accent(), children: ` › ${l.length > 72 ? l.slice(0, 72) + "…" : l}` }, i))) })), modeSelector ? _jsx(ModeLine, { approval: status.approval }) : _jsx(StatusRow, { working: working, todos: todos, queued: pool.length }), _jsx(InputBox, { status: status, cwd: cwd, model: model, route: header?.routeHost, isActive: !prompt, vim: vim, onSubmit: handleSubmit, onClipboardImage: onClipboardImage })] }));
591
623
  }
@@ -89,7 +89,7 @@ function activeMention(value, cursor) {
89
89
  const MentionPopup = memo(function MentionPopup({ items, selected, query }) {
90
90
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: ` @${query} · ${items.length} match${items.length === 1 ? "" : "es"} — ↑↓ select · Tab/Enter insert · Esc dismiss` }), items.map((it, i) => (_jsxs(Text, { children: [i === selected ? _jsx(Text, { color: "cyan", children: " ▸ " }) : _jsx(Text, { children: " " }), _jsx(Text, { color: it.endsWith("/") ? "blue" : undefined, dimColor: i !== selected, bold: i === selected, children: it })] }, it)))] }));
91
91
  });
92
- const TOKEN_RE = /\[Image #\d+\]/g;
92
+ const TOKEN_RE = /\[Image #\d+\]|\[Paste #\d+ \+\d+ lines\]/g;
93
93
  /** Split the value into text/image-token segments (image tokens render highlighted, codex-style). */
94
94
  function segmentize(value) {
95
95
  const parts = [];
@@ -239,6 +239,7 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
239
239
  const [sel, setSel] = useState(0);
240
240
  const [dismissed, setDismissed] = useState(false);
241
241
  const [images, setImages] = useState([]);
242
+ const [pastes, setPastes] = useState([]); // full text behind each [Paste #N] token
242
243
  const [mode, setMode] = useState("insert"); // vim only
243
244
  const [pending, setPending] = useState(""); // vim operator-pending (d/c/g)
244
245
  const [register, setRegister] = useState(""); // vim yank/delete register
@@ -260,12 +261,28 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
260
261
  setSel(0);
261
262
  setDismissed(false);
262
263
  };
264
+ // A big paste folds into a [Paste #N +L lines] token (Claude-Code/codex style) instead of flooding
265
+ // the box: typing stays smooth (the VALUE stays short), the box stays small, and a multi-line paste
266
+ // can no longer fire the newline-submit path mid-paste. Expanded back to the full text on submit.
267
+ const addPaste = (text) => {
268
+ const lines = text.split("\n").length;
269
+ const tok = `[Paste #${pastes.length + 1} +${lines} lines]`;
270
+ const before = value.slice(0, cursor);
271
+ const ins = (before && !/\s$/.test(before) ? " " : "") + tok + " ";
272
+ setValue(before + ins + value.slice(cursor));
273
+ setCursor((before + ins).length);
274
+ setPastes((xs) => [...xs, text]);
275
+ setSel(0);
276
+ setDismissed(false);
277
+ };
278
+ const expandPastes = (text) => text.replace(/\[Paste #(\d+) \+\d+ lines\]/g, (m, d) => pastes[Number(d) - 1] ?? m);
263
279
  const submit = (text) => {
264
280
  if (!text.trim() && images.length === 0)
265
281
  return; // nothing to send
266
- onSubmit?.(text, images.length ? images : undefined);
282
+ onSubmit?.(expandPastes(text), images.length ? images : undefined);
267
283
  set("", 0);
268
284
  setImages([]);
285
+ setPastes([]);
269
286
  setMode("insert"); // a fresh prompt starts in insert
270
287
  setPending("");
271
288
  };
@@ -354,6 +371,18 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
354
371
  if (key.backspace || key.delete) {
355
372
  if (cursor > 0) {
356
373
  const head = value.slice(0, cursor);
374
+ const pm = /\[Paste #(\d+) \+\d+ lines\]\s?$/.exec(head); // paste token deletes whole + renumbers
375
+ if (pm) {
376
+ const n = Number(pm[1]);
377
+ const kept = head.slice(0, pm.index) + value.slice(cursor);
378
+ const renumbered = kept.replace(/\[Paste #(\d+)( \+\d+ lines\])/g, (m2, d, tail) => (Number(d) > n ? `[Paste #${Number(d) - 1}${tail}` : m2));
379
+ setPastes((xs) => xs.filter((_, i) => i !== n - 1));
380
+ setValue(renumbered);
381
+ setCursor(pm.index);
382
+ setSel(0);
383
+ setDismissed(false);
384
+ return;
385
+ }
357
386
  const tm = /\[Image #(\d+)\]\s?$/.exec(head); // backspacing over an attachment token removes it whole
358
387
  if (tm) {
359
388
  const n = Number(tm[1]);
@@ -371,6 +400,12 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
371
400
  return;
372
401
  }
373
402
  if (input && !key.ctrl && !key.meta) {
403
+ // A LARGE paste (many lines or lots of text in one chunk) folds to a token — never submits,
404
+ // never floods the box. Small chunks keep the existing paste-to-run newline behavior.
405
+ if (input.length >= 600 || (input.match(/\n/g)?.length ?? 0) >= 3) {
406
+ addPaste(input);
407
+ return;
408
+ }
374
409
  const nl = input.search(/[\r\n]/); // a chunk carrying a newline (paste / fed input) submits
375
410
  if (nl >= 0) {
376
411
  submit(value.slice(0, cursor) + input.slice(0, nl) + value.slice(cursor));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.101.0",
3
+ "version": "0.102.0",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"