@nanhara/hara 0.101.1 → 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 +18 -0
- package/dist/agent/loop.js +84 -38
- package/dist/agent/phase.js +29 -0
- package/dist/tui/App.js +7 -1
- package/dist/tui/InputBox.js +37 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,24 @@ 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
|
+
|
|
8
26
|
## 0.101.1 — the input box stops running to the top
|
|
9
27
|
|
|
10
28
|
- **Live-region overflow guard.** A long streaming answer (or a big diff) used to grow the live region
|
package/dist/agent/loop.js
CHANGED
|
@@ -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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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.
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
:
|
|
189
|
-
|
|
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, "");
|
|
@@ -256,7 +257,9 @@ const SPINNER_FRAME_MS = 125;
|
|
|
256
257
|
const IDLE_HINTS = "⏎ send · @ file · ctrl+v image · ctrl+t transcript · shift+tab mode";
|
|
257
258
|
function StatusRow({ working, todos, queued }) {
|
|
258
259
|
const [frame, setFrame] = useState(0);
|
|
260
|
+
const [phase, setPhase] = useState(() => turnPhase());
|
|
259
261
|
const startRef = useRef(Date.now());
|
|
262
|
+
useEffect(() => onTurnPhase(setPhase), []); // waiting → streaming, published by the agent loop
|
|
260
263
|
useEffect(() => {
|
|
261
264
|
if (!working)
|
|
262
265
|
return;
|
|
@@ -269,7 +272,10 @@ function StatusRow({ working, todos, queued }) {
|
|
|
269
272
|
}
|
|
270
273
|
const frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
|
|
271
274
|
const elapsedSec = Math.floor((Date.now() - startRef.current) / 1000);
|
|
272
|
-
|
|
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})` : ""}` })] }));
|
|
273
279
|
}
|
|
274
280
|
// Short per-mode descriptions for the ONE-ROW mode line (the old two-row ModeBar's long sentences
|
|
275
281
|
// don't fit inline). Full behavior is documented in /help; this line is a switching aid, not a manual.
|
package/dist/tui/InputBox.js
CHANGED
|
@@ -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));
|