@nanhara/hara 0.99.2 → 0.99.3

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,28 @@ 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.99.3 — TUI: rock-steady input box (constant-height chrome) + plan mode grows a real handshake
9
+
10
+ Built from a source-level study of codex-rs (bottom-anchored viewport, plan cells) and Claude Code
11
+ (flexShrink-pinned bottom region, ExitPlanMode handshake).
12
+
13
+ - **The input box no longer jumps at turn boundaries.** The bottom chrome is now a CONSTANT-height
14
+ stack: a permanent one-row status slot above the box swaps its content — spinner + verb + queue count
15
+ while working ⇄ dim key hints when idle — instead of appearing/disappearing (the old `Working` block +
16
+ `⌨ working` hint row cost ±3 rows at every turn start/end). The shift+tab picker (`ModeLine`, now ONE
17
+ row with short inline descriptions) swaps into the SAME slot, so popping/auto-hiding it moves nothing.
18
+ The todo panel no longer folds on a 30s timer (which yanked the box up while you read) — it folds to
19
+ its one-line summary when the NEXT turn starts, coinciding with your own submit.
20
+ - `tui/App.tsx`: `StatusRow`/`ModeLine` + the constant slot; fold-on-submit; `tui/InputBox.tsx`: the
21
+ working-hint row and two-row ModeBar are gone (mode still reads colored in the footer).
22
+ - **Plan mode: the MODEL now decides when the plan is ready** (Claude-Code style), instead of hara
23
+ nagging "proceed?" after every read-only turn. A run-scoped `exit_plan` tool (new `extraTools` option
24
+ on `runAgent` — per-run tools, never registered globally) is offered only in plan mode; when the model
25
+ calls it, the plan renders as a bordered `╭─ Plan` block (codex ProposedPlanCell-style) and THEN the
26
+ proceed picker appears. Investigation/Q&A turns end quietly, still in plan mode.
27
+ - `agent/loop.ts`: `opts.extraTools` (advertised post-filter, resolved before the registry);
28
+ `index.ts`: plan branch rewired + `PLAN_SYSTEM` teaches the exit_plan contract.
29
+
8
30
  ## 0.99.2 — TUI: steadier input box + transient approval selector
9
31
 
10
32
  - **Approval-mode selector is now transient, not always-on chrome.** The persistent two-row ModeBar under every
@@ -93,7 +93,10 @@ export async function runAgent(history, opts) {
93
93
  for (const m of await opts.pendingInput())
94
94
  history.push(m);
95
95
  }
96
- const specs = opts.toolFilter ? toolSpecs().filter((t) => opts.toolFilter(t.name)) : toolSpecs();
96
+ const baseSpecs = opts.toolFilter ? toolSpecs().filter((t) => opts.toolFilter(t.name)) : toolSpecs();
97
+ const specs = opts.extraTools?.length
98
+ ? [...baseSpecs, ...opts.extraTools.map((t) => ({ name: t.name, description: t.description, input_schema: t.input_schema }))]
99
+ : baseSpecs;
97
100
  const sink = ctx.ui; // TUI mode: route output to ink instead of stdout
98
101
  const tty = stdout.isTTY && !opts.quiet && !sink;
99
102
  const md = tty && process.env.HARA_MD !== "0" ? makeRenderer(out) : null;
@@ -211,14 +214,16 @@ export async function runAgent(history, opts) {
211
214
  if (r.stop !== "tool_use")
212
215
  return;
213
216
  const plans = [];
217
+ // Extra (per-run) tools win over the registry so a run-scoped tool can't be shadowed by a global one.
218
+ const resolveTool = (name) => opts.extraTools?.find((t) => t.name === name) ?? getTool(name);
214
219
  for (const tu of r.toolUses) {
215
220
  if (breakerHalt) {
216
221
  // Circuit-breaker halted the run: refuse every remaining call in this round with a clear message
217
222
  // (no hang, no further tools) so the model + user get a definitive stop.
218
- plans.push({ tu, tool: getTool(tu.name), denied: "Guardian circuit-breaker halted this run (too many high-risk actions blocked). Ask the user to review and re-run." });
223
+ plans.push({ tu, tool: resolveTool(tu.name), denied: "Guardian circuit-breaker halted this run (too many high-risk actions blocked). Ask the user to review and re-run." });
219
224
  continue;
220
225
  }
221
- const tool = getTool(tu.name);
226
+ const tool = resolveTool(tu.name);
222
227
  if (!tool) {
223
228
  plans.push({ tu, tool: undefined, denied: `Unknown tool: ${tu.name}` });
224
229
  continue;
package/dist/index.js CHANGED
@@ -691,9 +691,24 @@ async function nameSession(provider, history) {
691
691
  return titleFrom(history);
692
692
  }
693
693
  }
694
- const PLAN_SYSTEM = "You are in PLAN MODE. Investigate read-only (read_file / grep / glob / ls / web_fetch) and think, " +
695
- "then propose a concise step-by-step plan for the task. Do NOT edit files or run commands yet — only plan. " +
696
- "End your message with the plan as a short numbered list.";
694
+ /** Render a proposed plan as a bordered block for the transcript (codex ProposedPlanCell-style).
695
+ * Left-border-only frame right-edge alignment against variable-width content is brittle, and the
696
+ * open right side lets long lines wrap naturally. Emitted via the diff sink channel (renders verbatim,
697
+ * not dimmed like notice). */
698
+ const renderPlanBlock = (plan) => {
699
+ const lines = plan.replace(/\n+$/, "").split("\n");
700
+ const top = c.cyan("╭─ ") + c.bold(c.cyan("Plan")) + c.cyan(" " + "─".repeat(42));
701
+ const body = lines.map((l) => c.cyan("│ ") + l).join("\n");
702
+ return `${top}\n${body}\n${c.cyan("╰" + "─".repeat(48))}`;
703
+ };
704
+ // Plan mode's contract (Claude-Code style handshake): the MODEL decides when the plan is ready by
705
+ // calling `exit_plan` — we never nag with a proceed-prompt after turns that were just investigation
706
+ // or Q&A. codex's equivalent is the plan streaming to a dedicated cell + Enter-to-implement.
707
+ const PLAN_SYSTEM = "You are in PLAN MODE — a read-only investigation phase. Explore with read_file / grep / glob / ls / " +
708
+ "web tools and think. Do NOT edit files or run mutating commands — only investigate and plan. " +
709
+ "When (and only when) you have a complete, actionable plan, call the `exit_plan` tool with the full plan " +
710
+ "(concise markdown, short numbered steps), then stop and wait for the user's decision. " +
711
+ "If the user is asking a question, or the plan isn't ready yet, just answer normally WITHOUT calling exit_plan.";
697
712
  const DISTILL_SYSTEM = "The session is ending. Reflect and persist only durable, reusable learnings: memory_write for facts / " +
698
713
  "conventions / the user's preferences, skill_create for reusable how-tos. Be selective — skip the trivial. Then reply DONE.";
699
714
  const MEMORY_DISTILL_SYSTEM = "You consolidate an agent's short-term daily memory logs into its durable long-term memory. You're given " +
@@ -3029,7 +3044,9 @@ program.action(async (opts) => {
3029
3044
  };
3030
3045
  const turnStart = Date.now(); // for the task-done notification (gated on elapsed)
3031
3046
  if (appr === "plan") {
3032
- // PLAN MODE: read-only investigate propose a plan selectable proceed → execute.
3047
+ // PLAN MODE: read-only investigate; the MODEL signals plan-readiness by calling `exit_plan`
3048
+ // (Claude-Code style handshake) — only then do we pop the proceed prompt. Turns that were just
3049
+ // investigation / Q&A end back at the input, still in plan mode, with no nagging.
3033
3050
  const planImg = await resolveImages(images, h);
3034
3051
  if (planImg.skip)
3035
3052
  return;
@@ -3037,12 +3054,29 @@ program.action(async (opts) => {
3037
3054
  recalledContext = "";
3038
3055
  const pin = stats.input;
3039
3056
  const pout = stats.output;
3057
+ // Run-scoped tool (never in the registry, so no other mode can see it): captures the proposed
3058
+ // plan and renders it as a bordered block (codex's ProposedPlanCell equivalent) via the sink.
3059
+ let proposedPlan = null;
3060
+ const exitPlanTool = {
3061
+ name: "exit_plan",
3062
+ description: "Call when your plan is complete and ready for the user to approve. Pass the FULL plan as concise " +
3063
+ "markdown (short numbered steps). This ends the planning phase — after calling it, stop and wait.",
3064
+ input_schema: { type: "object", properties: { plan: { type: "string", description: "the complete plan (markdown, short numbered steps)" } }, required: ["plan"] },
3065
+ kind: "read", // never prompts — submitting a plan is not a mutation
3066
+ run: async (input, tctx) => {
3067
+ proposedPlan = String(input?.plan ?? "").trim();
3068
+ if (proposedPlan)
3069
+ tctx.ui?.diff(renderPlanBlock(proposedPlan));
3070
+ return "Plan submitted to the user for approval. Stop now and wait for their decision — do not keep working.";
3071
+ },
3072
+ };
3040
3073
  await runAgent(history, {
3041
3074
  provider,
3042
3075
  ctx: { cwd, sandbox, spawn, ui, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot },
3043
3076
  approval: "suggest",
3044
3077
  confirm: h.confirm,
3045
3078
  toolFilter: (n) => READONLY_TOOLS.has(n),
3079
+ extraTools: [exitPlanTool],
3046
3080
  systemOverride: PLAN_SYSTEM,
3047
3081
  memory: buildMemory(),
3048
3082
  projectContext,
@@ -3056,6 +3090,11 @@ program.action(async (opts) => {
3056
3090
  }
3057
3091
  h.sink.usage(stats.input - pin, stats.output - pout);
3058
3092
  saveSession(meta, history);
3093
+ if (!proposedPlan) {
3094
+ // No exit_plan this turn — the model was investigating or answering. Stay in plan mode quietly.
3095
+ notifyDone(cfg.notify, { message: meta.title || "plan turn complete", elapsedMs: Date.now() - turnStart });
3096
+ return;
3097
+ }
3059
3098
  const choice = await h.select("hara has a plan — proceed?", [
3060
3099
  { label: "Yes, and auto-apply edits", value: "auto-edit" },
3061
3100
  { label: "Yes, approve each edit", value: "suggest" },
package/dist/tui/App.js CHANGED
@@ -1,20 +1,22 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // The hara TUI (ink). Layout, top to bottom:
3
- // <Static> committed transcript — rendered once each, scrolls into native scrollback
4
- // current the in-progress turn's blocks (assistant text / reasoning / tool / diff), live
5
- // <Working> spinner while a turn runs (Esc interrupts)
6
- // <InputBox> the pinned, bordered prompt (or a confirm prompt when a tool needs approval)
3
+ // <Static> committed transcript — rendered once each, scrolls into native scrollback
4
+ // current the in-progress turn's blocks (assistant text / reasoning / tool / diff), live
5
+ // <TodoPanel> live checklist (when the agent keeps one)
6
+ // status slot ALWAYS one row: StatusRow (spinner while working / key hints idle) ModeLine
7
+ // (shift+tab picker) — constant height so the input box never bobs at turn boundaries
8
+ // <InputBox> the bordered prompt (or a confirm prompt when a tool needs approval)
7
9
  //
8
10
  // The agent machinery is injected via `onSubmit` (a turn runner) so this view is testable with
9
11
  // ink-testing-library against a fake runner — no provider/network needed.
10
12
  import { Box, Static, Text, useApp, useInput, useStdout } from "ink";
11
13
  import { memo, useCallback, useEffect, useRef, useState } from "react";
12
- import { InputBox } from "./InputBox.js";
14
+ import { InputBox, MODES, approvalColor } from "./InputBox.js";
13
15
  import { activity } from "../activity.js";
14
16
  import { ctxPctFor } from "../statusbar.js";
15
17
  import { accent } from "./theme.js";
16
18
  import { renderMarkdown } from "../md.js";
17
- import { currentTodos, onTodosChange } from "../tools/todo.js";
19
+ import { clearTodos, currentTodos, onTodosChange } from "../tools/todo.js";
18
20
  let _id = 0;
19
21
  const nid = () => ++_id;
20
22
  const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
@@ -218,25 +220,50 @@ export function spinnerVerb(list, elapsedSec) {
218
220
  }
219
221
  return `working ${elapsedSec}s · esc to interrupt`;
220
222
  }
223
+ // The status row — ALWAYS rendered, exactly one content row (codex-style: the bottom pane keeps a
224
+ // constant-height status slot and swaps its CONTENT). This is the anti-bob keystone: the old
225
+ // `Working` block appeared at turn start and vanished at turn end (±2 rows), so the input box
226
+ // jumped up 3 rows at every turn boundary (together with the ⌨-hint line, now folded in here).
227
+ // Working → spinner + verb + queue count; idle → dim key hints. Same height either way → zero shift.
228
+ //
221
229
  // The spinner is the only element that animates continuously while a turn runs — and because ink
222
- // redraws the WHOLE dynamic region (input box + mode bar included) on any change, its tick rate sets
223
- // a floor on full-frame redraws over the life of a turn. At ~8fps (125ms) the braille glyph still
224
- // reads as smooth motion but cuts those forced redraws ~20% vs 100ms meaningfully calmer over a
225
- // slow/remote link. Elapsed seconds come from a wall-clock start, so the "Ns" text stays exact and
226
- // stable (it only changes once per second, not coupled to the glyph frame).
230
+ // redraws the WHOLE dynamic region (input box included) on any change, its tick rate sets a floor on
231
+ // full-frame redraws over the life of a turn. At ~8fps (125ms) the braille glyph still reads as
232
+ // smooth motion meaningfully calmer over a slow/remote link. Elapsed seconds come from a
233
+ // wall-clock start, so the "Ns" text stays exact and stable.
227
234
  const SPINNER_FRAME_MS = 125;
228
- function Working({ todos }) {
235
+ const IDLE_HINTS = "⏎ send · @ file · ctrl+v image · ctrl+t transcript · shift+tab mode";
236
+ function StatusRow({ working, todos, queued }) {
229
237
  const [frame, setFrame] = useState(0);
230
238
  const startRef = useRef(Date.now());
231
239
  useEffect(() => {
240
+ if (!working)
241
+ return;
232
242
  startRef.current = Date.now();
233
243
  const id = setInterval(() => setFrame((x) => x + 1), SPINNER_FRAME_MS);
234
244
  return () => clearInterval(id);
235
- }, []);
245
+ }, [working]);
246
+ if (!working) {
247
+ return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: ` ${IDLE_HINTS}` }) }));
248
+ }
236
249
  const frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
237
250
  const elapsedSec = Math.floor((Date.now() - startRef.current) / 1000);
238
- return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: frames[frame % frames.length] }), _jsx(Text, { dimColor: true, children: ` ${spinnerVerb(todos, elapsedSec)}` })] }));
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})` : ""}` })] }));
239
252
  }
253
+ // Short per-mode descriptions for the ONE-ROW mode line (the old two-row ModeBar's long sentences
254
+ // don't fit inline). Full behavior is documented in /help; this line is a switching aid, not a manual.
255
+ const MODE_HINT = {
256
+ suggest: "confirms edits+cmds",
257
+ "auto-edit": "auto edits · asks cmds",
258
+ "full-auto": "no prompts ⚠",
259
+ plan: "read-only → plan",
260
+ };
261
+ // Transient approval-mode line: popped by shift+tab in PLACE of the StatusRow (equal-height swap —
262
+ // one row for one row, so the picker appearing/auto-hiding never moves the input box). All modes
263
+ // listed, the active one colored, with the active mode's short description inline.
264
+ const ModeLine = memo(function ModeLine({ approval }) {
265
+ return (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [MODES.map((m, i) => (_jsxs(Text, { children: [i > 0 ? " " : " ", m === approval ? _jsx(Text, { color: approvalColor(m), bold: true, children: `◆ ${m}` }) : _jsx(Text, { dimColor: true, children: m })] }, m))), _jsx(Text, { dimColor: true, children: ` · ${MODE_HINT[approval]} · ⇄ shift+tab` })] }) }));
266
+ });
240
267
  // Live task panel: renders the current todo_write checklist between the in-progress turn output
241
268
  // and the input box. Highlights the in_progress item; caps at 8 rows and folds the rest into
242
269
  // `… +N pending/done`. Hidden when the list is empty.
@@ -290,10 +317,6 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
290
317
  // Live checklist mirror: TodoPanel reads this, and `Working` derives its spinner verb from the
291
318
  // in_progress item. The tool emits on every todo_write — keeps the UI in lockstep with the agent.
292
319
  const [todos, setTodos] = useState(() => currentTodos());
293
- // Collapse-after-turn: once a turn ends, leave the panel visible briefly (so the user sees the
294
- // final state) then fold it into a single-line "Todos: N/M done" notice in history. Cleared if
295
- // a new turn starts before the timer fires.
296
- const collapseTimerRef = useRef(null);
297
320
  const ctrlRef = useRef(null);
298
321
  const queueRef = useRef([]); // type-ahead: FIFO of messages entered while working
299
322
  const [pool, setPool] = useState([]); // type-ahead pool: queued message lines, shown above the input
@@ -323,17 +346,8 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
323
346
  useEffect(() => {
324
347
  const unsub = onTodosChange((list) => {
325
348
  setTodos([...list]); // copy so React sees a new array (the tool reuses one)
326
- // A change mid-turn cancels any pending collapse — the user is still working with this list.
327
- if (collapseTimerRef.current) {
328
- clearTimeout(collapseTimerRef.current);
329
- collapseTimerRef.current = null;
330
- }
331
349
  });
332
- return () => {
333
- unsub();
334
- if (collapseTimerRef.current)
335
- clearTimeout(collapseTimerRef.current);
336
- };
350
+ return unsub;
337
351
  }, []);
338
352
  // Reconcile the synchronously-mutated live buffer into React state, at most once per ~33ms. First
339
353
  // append any finalized blocks to <Static> (once, in order), then publish the remaining live tail.
@@ -418,6 +432,15 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
418
432
  setPool(queueRef.current.map((q) => q.line.trim() || "🖼 (image)"));
419
433
  return;
420
434
  }
435
+ // Fold the previous turn's checklist NOW, at the natural boundary (a new task begins). The old
436
+ // 30s-idle timer yanked the input box UP by the panel's height while the user was reading/typing
437
+ // (anti-bob); folding on submit means the shrink coincides with the user's own action.
438
+ if (currentTodos().length) {
439
+ const list = currentTodos();
440
+ const done = list.filter((td) => td.status === "done").length;
441
+ setHistory((h) => [...h, { id: nid(), kind: "notice", text: ` ✓ Todos: ${done}/${list.length} done` }]);
442
+ clearTodos(); // emits → the panel unmounts via onTodosChange
443
+ }
421
444
  if (images?.length)
422
445
  noteVisionIfNeeded(); // one-shot inline notice on first image of the session
423
446
  setHistory((h) => [...h, { id: nid(), kind: "user", text: t }]); // t already carries any [Image #N] tokens
@@ -482,20 +505,6 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
482
505
  setCurrent([]);
483
506
  setWorking(false);
484
507
  ctrlRef.current = null;
485
- // Schedule a panel collapse: if there was a checklist this turn, fold it to a one-line summary
486
- // in scrollback after ~30s of quiet (i.e. no new todo_write or new turn).
487
- if (collapseTimerRef.current)
488
- clearTimeout(collapseTimerRef.current);
489
- if (currentTodos().length) {
490
- collapseTimerRef.current = setTimeout(() => {
491
- const list = currentTodos();
492
- if (!list.length)
493
- return;
494
- const done = list.filter((t) => t.status === "done").length;
495
- setHistory((h) => [...h, { id: nid(), kind: "notice", text: ` ✓ Todos: ${done}/${list.length} done` }]);
496
- collapseTimerRef.current = null;
497
- }, 30_000);
498
- }
499
508
  }, [working, prompt, askText, onSubmit, pushCurrent, model, exit, drainQueue, noteVisionIfNeeded]);
500
509
  // Drain the type-ahead pool: when the turn finishes (working → false) and nothing awaits a choice, COALESCE
501
510
  // every pooled message into ONE turn and send it — additions/clarifications go to the agent together, in order.
@@ -578,5 +587,5 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
578
587
  });
579
588
  if (showTranscript)
580
589
  return _jsx(Transcript, { items: [...history, ...current], onClose: () => setShowTranscript(false) });
581
- 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))), !prompt && _jsx(TodoPanel, { todos: todos }), working && !prompt && _jsx(Working, { 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))) })), _jsx(InputBox, { status: status, cwd: cwd, model: model, route: header?.routeHost, isActive: !prompt, working: working && !askText, queued: pool.length, vim: vim, showModeSelector: modeSelector, onSubmit: handleSubmit, onClipboardImage: onClipboardImage })] }));
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 })] }));
582
591
  }
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // The framed input box (ink): a rounded, dim-bordered box (codex polish) wrapping the prompt line,
3
- // with a single dim footer line rendered BELOW the box (model · approval · route · cwd · usage · ctx)
4
- // and the ModeBar under that. Pure-ish: pass `width` to make rendering deterministic in tests.
3
+ // with a single dim footer line rendered BELOW the box (model · approval · route · cwd · usage · ctx).
4
+ // The approval-mode picker and working/queue status live OUTSIDE this component (App's constant-height
5
+ // StatusRow/ModeLine slot above the box). Pure-ish: pass `width` for deterministic tests.
5
6
  //
6
7
  // Render-stability principles (codex-style, for slow/remote terminals): ink erases and rewrites the
7
8
  // ENTIRE dynamic region on every frame, so the box's cost scales with (a) how many lines it occupies
@@ -35,7 +36,7 @@ export function footerCwd(abs, home = process.env.HOME ?? "", maxLen = 28) {
35
36
  const slash = tail.indexOf("/");
36
37
  return "…" + (slash > 0 ? tail.slice(slash) : tail);
37
38
  }
38
- /** Approval-mode accent color, shared by the footer indicator + the transient ModeBar. full-auto is
39
+ /** Approval-mode accent color, shared by the footer indicator + App's transient ModeLine. full-auto is
39
40
  * the dangerous one (red), plan is read-only (cyan), the edit modes are green. */
40
41
  export function approvalColor(a) {
41
42
  return a === "full-auto" ? "red" : a === "plan" ? "cyan" : "green";
@@ -79,20 +80,6 @@ const TopBorder = memo(function TopBorder({ name, width }) {
79
80
  const left = Math.max(2, width - name.length - 7); // ╭(1)+ " "(1)+●(1)+" name "(len+2)+"─╮"(2)
80
81
  return (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "╭" + "─".repeat(left) + " " }), _jsx(Text, { color: "cyan", children: "\u25CF" }), _jsx(Text, { bold: true, children: ` ${name} ` }), _jsx(Text, { dimColor: true, children: "─╮" })] }));
81
82
  });
82
- const MODE_DESC = {
83
- suggest: "confirms edits & commands",
84
- "auto-edit": "auto-applies edits · asks before commands",
85
- "full-auto": "runs everything — no prompts ⚠",
86
- plan: "investigate read-only, then propose a plan to approve",
87
- };
88
- // Transient approval-mode selector: popped by shift+tab and auto-hidden after a beat (App owns the
89
- // timer) so it isn't always-on chrome. All modes listed, the active one highlighted (red for the
90
- // dangerous full-auto) with a one-line description and the shift+tab hint. When hidden, the current
91
- // mode still reads (colored) from the footer line — this just adds the full picker + descriptions.
92
- const ModeBar = memo(function ModeBar({ approval }) {
93
- const warn = approval === "full-auto";
94
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { children: MODES.map((m, i) => (_jsxs(Text, { children: [i > 0 ? " " : " ", m === approval ? _jsx(Text, { color: warn ? "red" : m === "plan" ? "cyan" : "green", bold: true, children: `◆ ${m}` }) : _jsx(Text, { dimColor: true, children: m })] }, m))) }), _jsx(Text, { dimColor: true, children: ` ${MODE_DESC[approval]} · shift+tab ⇄` })] }));
95
- });
96
83
  /** The active `@mention` token immediately left of the cursor (for the file popup), or null. */
97
84
  function activeMention(value, cursor) {
98
85
  const m = /(?:^|\s)@([^\s@]*)$/.exec(value.slice(0, cursor));
@@ -242,9 +229,9 @@ const InputLine = memo(function InputLine({ value, cursor, width, gutter, gutter
242
229
  }
243
230
  return (_jsx(Box, { flexDirection: "column", children: rows.map((row, i) => (_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))) }));
244
231
  });
245
- /** Bordered prompt box + one dim status footer (model · approval · route · cwd · usage · ctx) +
246
- * ModeBar, with an @path popup. */
247
- export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboardImage, isActive = true, working = false, queued = 0, vim = false, showModeSelector = false, placeholder = "Type a task · /help · @file · Ctrl+V paste image · shift+tab mode · Esc interrupts", }) {
232
+ /** Bordered prompt box + one dim status footer (model · approval · route · cwd · usage · ctx),
233
+ * with an @path popup. */
234
+ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboardImage, isActive = true, vim = false, placeholder = "Type a task · /help · @file · Ctrl+V paste image · shift+tab mode · Esc interrupts", }) {
248
235
  const { stdout } = useStdout();
249
236
  const w = width ?? stdout?.columns ?? 80;
250
237
  const [value, setValue] = useState("");
@@ -408,5 +395,5 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
408
395
  // + corners and everything aligns column-for-column.
409
396
  const innerW = Math.max(1, w - 4);
410
397
  const cwdShort = footerCwd(cwd);
411
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TopBorder, { name: status.sessionName || "session", width: w }), _jsx(Box, { borderStyle: "round", borderTop: false, borderColor: "gray", borderDimColor: true, paddingX: 1, width: w, children: _jsx(InputLine, { value: value, cursor: cursor, width: innerW, gutter: gutter, gutterColor: gutterColor, placeholder: placeholder }) }), vim ? _jsx(Text, { dimColor: true, children: mode === "normal" ? " -- NORMAL -- i/a insert · h l 0 $ w b e move · x dd D cw p edit" : " -- INSERT -- Esc → normal" }) : null, _jsx(Footer, { model: model, s: status, cwdShort: cwdShort, route: route }), working ? _jsx(Text, { dimColor: true, children: ` ⌨ working — Enter queues your message${queued ? ` · ${queued} queued` : ""} · Esc interrupts` }) : null, popupOpen ? _jsx(MentionPopup, { items: candidates, selected: selIdx, query: mention.query }) : null, showModeSelector ? _jsx(ModeBar, { approval: status.approval }) : null] }));
398
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TopBorder, { name: status.sessionName || "session", width: w }), _jsx(Box, { borderStyle: "round", borderTop: false, borderColor: "gray", borderDimColor: true, paddingX: 1, width: w, children: _jsx(InputLine, { value: value, cursor: cursor, width: innerW, gutter: gutter, gutterColor: gutterColor, placeholder: placeholder }) }), vim ? _jsx(Text, { dimColor: true, children: mode === "normal" ? " -- NORMAL -- i/a insert · h l 0 $ w b e move · x dd D cw p edit" : " -- INSERT -- Esc → normal" }) : null, _jsx(Footer, { model: model, s: status, cwdShort: cwdShort, route: route }), popupOpen ? _jsx(MentionPopup, { items: candidates, selected: selIdx, query: mention.query }) : null] }));
412
399
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.99.2",
3
+ "version": "0.99.3",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"