@nanhara/hara 0.99.1 → 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 +39 -0
- package/dist/agent/loop.js +8 -3
- package/dist/index.js +43 -4
- package/dist/tui/App.js +83 -53
- package/dist/tui/InputBox.js +34 -25
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,45 @@ 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
|
+
|
|
30
|
+
## 0.99.2 — TUI: steadier input box + transient approval selector
|
|
31
|
+
|
|
32
|
+
- **Approval-mode selector is now transient, not always-on chrome.** The persistent two-row ModeBar under every
|
|
33
|
+
frame is gone; the current mode reads inline in the status footer (colored: red=full-auto, cyan=plan,
|
|
34
|
+
green=edit), and **shift+tab** pops the full picker + descriptions, which auto-hides after ~2.5s. Reclaims two
|
|
35
|
+
rows and cuts per-frame redraw during streaming — matching codex (transient approval overlay + compact status
|
|
36
|
+
line) and Claude Code.
|
|
37
|
+
- `tui/InputBox.tsx`: `footerParts`/`approvalColor` (colored mode in the footer); `ModeBar` gated on a new
|
|
38
|
+
`showModeSelector` prop. `tui/App.tsx`: shift+tab arms a 2.5s auto-hide timer.
|
|
39
|
+
- **Input box no longer bobs up/down while the model thinks.** A streaming reasoning block used to render up to
|
|
40
|
+
~11 live rows above the input, then fold to a single "✻ thought · N lines" line on finalize — that N→1
|
|
41
|
+
collapse yanked the box up every time. Reasoning now shows a **compact 1-line header by default** (same height
|
|
42
|
+
as the folded form → zero jump); **ctrl+r** expands the full streaming body, and **ctrl+t** always has the full
|
|
43
|
+
text. (ink can't bottom-pin the composer the way codex's ratatui viewport does, so this removes the dominant
|
|
44
|
+
jump; minor spinner/panel shifts at turn boundaries remain.)
|
|
45
|
+
- `tui/App.tsx`: the reasoning `Block` renders header-only unless `open` (ctrl+r).
|
|
46
|
+
|
|
8
47
|
## 0.94.1 — unreleased (gateway: relay is on-inbound, not a noisy push)
|
|
9
48
|
|
|
10
49
|
- **Fix the bind output relay to be quiet + platform-correct.** 0.94.0's continuous 3s timer-push flooded chat
|
package/dist/agent/loop.js
CHANGED
|
@@ -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
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
|
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>
|
|
4
|
-
// current
|
|
5
|
-
// <
|
|
6
|
-
//
|
|
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, "");
|
|
@@ -34,6 +36,10 @@ function foldForHistory(it) {
|
|
|
34
36
|
* block on every token, which over a laggy link leaves stale duplicate lines + jitter. 33ms clamps
|
|
35
37
|
* the live re-render rate while state itself stays exact (nothing is dropped, only coalesced). */
|
|
36
38
|
const LIVE_FRAME_MS = 33;
|
|
39
|
+
// How long the transient approval-mode selector stays up after a shift+tab before it auto-hides. Long
|
|
40
|
+
// enough to read the descriptions + tap shift+tab again to keep cycling; short enough that it isn't
|
|
41
|
+
// always-on chrome (codex/Claude-Code keep the mode compact in the status line, not a permanent bar).
|
|
42
|
+
const MODE_SELECTOR_MS = 2500;
|
|
37
43
|
// Memoized: a live block only re-renders when its own `item` (a fresh object when its text grows) or
|
|
38
44
|
// `open` changes. So a spinner tick or an unrelated flush doesn't re-run `renderMarkdown` for every
|
|
39
45
|
// live block, and non-tail live blocks stay put while only the streaming tail grows.
|
|
@@ -44,15 +50,18 @@ const Block = memo(function Block({ item, open }) {
|
|
|
44
50
|
case "assistant":
|
|
45
51
|
return _jsx(Text, { children: renderMarkdown(item.text) }); // headers/bold/inline-code/bullets + verbatim fences
|
|
46
52
|
case "reasoning": {
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
53
|
+
// A streaming reasoning block lives in the dynamic region ABOVE the input box, and the instant the
|
|
54
|
+
// model stops thinking it FOLDS to a single "✻ thought · N lines" notice in scrollback. If we streamed
|
|
55
|
+
// the body live (up to ~11 rows) and then folded to 1, the input box would jump UP by that many rows
|
|
56
|
+
// every time — the "bobbing" you saw. So by default we show only the compact 1-line header (same height
|
|
57
|
+
// as the folded form → the box holds still). ctrl+r opts into the full streaming body (its own taller
|
|
58
|
+
// view), and the FULL text is always in the ctrl+t transcript regardless — nothing is lost.
|
|
50
59
|
const lines = item.text.replace(/\n+$/, "").split("\n");
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
60
|
+
const n = lines.length;
|
|
61
|
+
const hint = open ? " · ctrl-r collapse" : " · ctrl-r expand";
|
|
62
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: accent(), dimColor: true, children: `✻ thinking … ${n} line${n === 1 ? "" : "s"}${hint}` }), open
|
|
63
|
+
? lines.map((l, i) => (_jsx(Text, { dimColor: true, italic: true, children: `${i === 0 ? "• " : " "}${l}` }, i)))
|
|
64
|
+
: null] }));
|
|
56
65
|
}
|
|
57
66
|
case "tool":
|
|
58
67
|
return _jsx(Text, { dimColor: true, children: " " + item.text });
|
|
@@ -211,25 +220,50 @@ export function spinnerVerb(list, elapsedSec) {
|
|
|
211
220
|
}
|
|
212
221
|
return `working ${elapsedSec}s · esc to interrupt`;
|
|
213
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
|
+
//
|
|
214
229
|
// The spinner is the only element that animates continuously while a turn runs — and because ink
|
|
215
|
-
// redraws the WHOLE dynamic region (input box
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
// 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.
|
|
220
234
|
const SPINNER_FRAME_MS = 125;
|
|
221
|
-
|
|
235
|
+
const IDLE_HINTS = "⏎ send · @ file · ctrl+v image · ctrl+t transcript · shift+tab mode";
|
|
236
|
+
function StatusRow({ working, todos, queued }) {
|
|
222
237
|
const [frame, setFrame] = useState(0);
|
|
223
238
|
const startRef = useRef(Date.now());
|
|
224
239
|
useEffect(() => {
|
|
240
|
+
if (!working)
|
|
241
|
+
return;
|
|
225
242
|
startRef.current = Date.now();
|
|
226
243
|
const id = setInterval(() => setFrame((x) => x + 1), SPINNER_FRAME_MS);
|
|
227
244
|
return () => clearInterval(id);
|
|
228
|
-
}, []);
|
|
245
|
+
}, [working]);
|
|
246
|
+
if (!working) {
|
|
247
|
+
return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: ` ${IDLE_HINTS}` }) }));
|
|
248
|
+
}
|
|
229
249
|
const frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
|
|
230
250
|
const elapsedSec = Math.floor((Date.now() - startRef.current) / 1000);
|
|
231
|
-
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})` : ""}` })] }));
|
|
232
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
|
+
});
|
|
233
267
|
// Live task panel: renders the current todo_write checklist between the in-progress turn output
|
|
234
268
|
// and the input box. Highlights the in_progress item; caps at 8 rows and folds the rest into
|
|
235
269
|
// `… +N pending/done`. Hidden when the list is empty.
|
|
@@ -278,13 +312,11 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
278
312
|
const [askText, setAskText] = useState(null);
|
|
279
313
|
const [reasoningOpen, setReasoningOpen] = useState(false);
|
|
280
314
|
const [showTranscript, setShowTranscript] = useState(false); // Ctrl+T full-transcript overlay
|
|
315
|
+
const [modeSelector, setModeSelector] = useState(false); // transient approval selector: shift+tab pops it, auto-hides
|
|
316
|
+
const modeSelectorTimerRef = useRef(null);
|
|
281
317
|
// Live checklist mirror: TodoPanel reads this, and `Working` derives its spinner verb from the
|
|
282
318
|
// in_progress item. The tool emits on every todo_write — keeps the UI in lockstep with the agent.
|
|
283
319
|
const [todos, setTodos] = useState(() => currentTodos());
|
|
284
|
-
// Collapse-after-turn: once a turn ends, leave the panel visible briefly (so the user sees the
|
|
285
|
-
// final state) then fold it into a single-line "Todos: N/M done" notice in history. Cleared if
|
|
286
|
-
// a new turn starts before the timer fires.
|
|
287
|
-
const collapseTimerRef = useRef(null);
|
|
288
320
|
const ctrlRef = useRef(null);
|
|
289
321
|
const queueRef = useRef([]); // type-ahead: FIFO of messages entered while working
|
|
290
322
|
const [pool, setPool] = useState([]); // type-ahead pool: queued message lines, shown above the input
|
|
@@ -303,26 +335,19 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
303
335
|
activity.onChange(fn);
|
|
304
336
|
return () => activity.onChange(null);
|
|
305
337
|
}, []);
|
|
306
|
-
// Cancel any pending
|
|
338
|
+
// Cancel any pending timers on unmount so they can't fire after teardown.
|
|
307
339
|
useEffect(() => () => {
|
|
308
340
|
if (flushTimerRef.current)
|
|
309
341
|
clearTimeout(flushTimerRef.current);
|
|
342
|
+
if (modeSelectorTimerRef.current)
|
|
343
|
+
clearTimeout(modeSelectorTimerRef.current);
|
|
310
344
|
}, []);
|
|
311
345
|
// Subscribe to todo_write updates so the panel re-renders when the agent edits the checklist.
|
|
312
346
|
useEffect(() => {
|
|
313
347
|
const unsub = onTodosChange((list) => {
|
|
314
348
|
setTodos([...list]); // copy so React sees a new array (the tool reuses one)
|
|
315
|
-
// A change mid-turn cancels any pending collapse — the user is still working with this list.
|
|
316
|
-
if (collapseTimerRef.current) {
|
|
317
|
-
clearTimeout(collapseTimerRef.current);
|
|
318
|
-
collapseTimerRef.current = null;
|
|
319
|
-
}
|
|
320
349
|
});
|
|
321
|
-
return
|
|
322
|
-
unsub();
|
|
323
|
-
if (collapseTimerRef.current)
|
|
324
|
-
clearTimeout(collapseTimerRef.current);
|
|
325
|
-
};
|
|
350
|
+
return unsub;
|
|
326
351
|
}, []);
|
|
327
352
|
// Reconcile the synchronously-mutated live buffer into React state, at most once per ~33ms. First
|
|
328
353
|
// append any finalized blocks to <Static> (once, in order), then publish the remaining live tail.
|
|
@@ -407,6 +432,15 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
407
432
|
setPool(queueRef.current.map((q) => q.line.trim() || "🖼 (image)"));
|
|
408
433
|
return;
|
|
409
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
|
+
}
|
|
410
444
|
if (images?.length)
|
|
411
445
|
noteVisionIfNeeded(); // one-shot inline notice on first image of the session
|
|
412
446
|
setHistory((h) => [...h, { id: nid(), kind: "user", text: t }]); // t already carries any [Image #N] tokens
|
|
@@ -471,20 +505,6 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
471
505
|
setCurrent([]);
|
|
472
506
|
setWorking(false);
|
|
473
507
|
ctrlRef.current = null;
|
|
474
|
-
// Schedule a panel collapse: if there was a checklist this turn, fold it to a one-line summary
|
|
475
|
-
// in scrollback after ~30s of quiet (i.e. no new todo_write or new turn).
|
|
476
|
-
if (collapseTimerRef.current)
|
|
477
|
-
clearTimeout(collapseTimerRef.current);
|
|
478
|
-
if (currentTodos().length) {
|
|
479
|
-
collapseTimerRef.current = setTimeout(() => {
|
|
480
|
-
const list = currentTodos();
|
|
481
|
-
if (!list.length)
|
|
482
|
-
return;
|
|
483
|
-
const done = list.filter((t) => t.status === "done").length;
|
|
484
|
-
setHistory((h) => [...h, { id: nid(), kind: "notice", text: ` ✓ Todos: ${done}/${list.length} done` }]);
|
|
485
|
-
collapseTimerRef.current = null;
|
|
486
|
-
}, 30_000);
|
|
487
|
-
}
|
|
488
508
|
}, [working, prompt, askText, onSubmit, pushCurrent, model, exit, drainQueue, noteVisionIfNeeded]);
|
|
489
509
|
// Drain the type-ahead pool: when the turn finishes (working → false) and nothing awaits a choice, COALESCE
|
|
490
510
|
// every pooled message into ONE turn and send it — additions/clarifications go to the agent together, in order.
|
|
@@ -552,10 +572,20 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
552
572
|
}
|
|
553
573
|
ctrlRef.current?.abort();
|
|
554
574
|
}
|
|
555
|
-
else if (key.tab && key.shift && cycleApproval)
|
|
575
|
+
else if (key.tab && key.shift && cycleApproval) {
|
|
556
576
|
setStatus((s) => ({ ...s, approval: cycleApproval(s.approval) }));
|
|
577
|
+
// Pop the approval selector transiently (codex-style) and (re)arm the auto-hide — tapping shift+tab
|
|
578
|
+
// again keeps it up while cycling; it folds away on its own so it isn't permanent chrome.
|
|
579
|
+
setModeSelector(true);
|
|
580
|
+
if (modeSelectorTimerRef.current)
|
|
581
|
+
clearTimeout(modeSelectorTimerRef.current);
|
|
582
|
+
modeSelectorTimerRef.current = setTimeout(() => {
|
|
583
|
+
setModeSelector(false);
|
|
584
|
+
modeSelectorTimerRef.current = null;
|
|
585
|
+
}, MODE_SELECTOR_MS);
|
|
586
|
+
}
|
|
557
587
|
});
|
|
558
588
|
if (showTranscript)
|
|
559
589
|
return _jsx(Transcript, { items: [...history, ...current], onClose: () => setShowTranscript(false) });
|
|
560
|
-
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))),
|
|
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 })] }));
|
|
561
591
|
}
|
package/dist/tui/InputBox.js
CHANGED
|
@@ -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
|
-
//
|
|
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,19 +36,39 @@ 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
|
-
/**
|
|
39
|
-
*
|
|
39
|
+
/** Approval-mode accent color, shared by the footer indicator + App's transient ModeLine. full-auto is
|
|
40
|
+
* the dangerous one (red), plan is read-only (cyan), the edit modes are green. */
|
|
41
|
+
export function approvalColor(a) {
|
|
42
|
+
return a === "full-auto" ? "red" : a === "plan" ? "cyan" : "green";
|
|
43
|
+
}
|
|
44
|
+
/** The footer split into three parts so the ACTIVE approval mode can be colored inline. The always-on
|
|
45
|
+
* ModeBar was removed — the mode now lives, glanceable and colored, right here in the footer, and
|
|
46
|
+
* shift+tab pops a transient selector. `prefix`+`mode`+`suffix` concatenates to exactly the old
|
|
47
|
+
* `footerLine` (same text, same spacing) — only the color of the `mode` token differs. Pure so the
|
|
48
|
+
* ordering/spacing can be pinned without rendering React. Shape (codex-style status line):
|
|
40
49
|
* `<model> · <approval>[ · <route>] · <cwd> · ↑<in> ↓<out> · ctx <pct>%`. The session name is NOT here —
|
|
41
50
|
* it rides the input box's top-right border (see TopBorder). `ctx N%` is always present (from 0 on) so
|
|
42
51
|
* the field never pops in mid-session and shifts the layout. */
|
|
43
|
-
export function
|
|
52
|
+
export function footerParts(model, s, cwdShort, route) {
|
|
44
53
|
const routeSeg = route ? ` · ${route}` : "";
|
|
45
|
-
return
|
|
54
|
+
return {
|
|
55
|
+
prefix: ` ${model} · `,
|
|
56
|
+
mode: s.approval,
|
|
57
|
+
suffix: `${routeSeg} · ${cwdShort} · ↑${tok(s.input)} ↓${tok(s.output)} · ctx ${s.ctxPct}%`,
|
|
58
|
+
};
|
|
46
59
|
}
|
|
47
|
-
|
|
48
|
-
|
|
60
|
+
/** Back-compat: the full footer as one string (prefix+mode+suffix). Kept for any pure consumer/test. */
|
|
61
|
+
export function footerLine(model, s, cwdShort, route) {
|
|
62
|
+
const p = footerParts(model, s, cwdShort, route);
|
|
63
|
+
return p.prefix + p.mode + p.suffix;
|
|
64
|
+
}
|
|
65
|
+
// The merged status footer: model · approval · route · cwd · usage · ctx. The active approval mode is
|
|
66
|
+
// colored inline (the always-on ModeBar is gone — shift+tab now pops a transient selector instead), so
|
|
67
|
+
// the outer <Text> is NOT dim: only the prefix/suffix are dimmed and the mode token stays bright.
|
|
68
|
+
// Memoized so a prompt keystroke doesn't reconcile it.
|
|
49
69
|
const Footer = memo(function Footer({ model, s, cwdShort, route }) {
|
|
50
|
-
|
|
70
|
+
const { prefix, mode, suffix } = footerParts(model, s, cwdShort, route);
|
|
71
|
+
return (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: prefix }), _jsx(Text, { color: approvalColor(s.approval), bold: true, children: mode }), _jsx(Text, { dimColor: true, children: suffix })] }));
|
|
51
72
|
});
|
|
52
73
|
// The rounded TOP edge of the input box, carrying the session name in the right corner (it "rides"
|
|
53
74
|
// the border — codex-style titled panel, and where hara has always shown it). Drawn by hand because
|
|
@@ -59,18 +80,6 @@ const TopBorder = memo(function TopBorder({ name, width }) {
|
|
|
59
80
|
const left = Math.max(2, width - name.length - 7); // ╭(1)+ " "(1)+●(1)+" name "(len+2)+"─╮"(2)
|
|
60
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: "─╮" })] }));
|
|
61
82
|
});
|
|
62
|
-
const MODE_DESC = {
|
|
63
|
-
suggest: "confirms edits & commands",
|
|
64
|
-
"auto-edit": "auto-applies edits · asks before commands",
|
|
65
|
-
"full-auto": "runs everything — no prompts ⚠",
|
|
66
|
-
plan: "investigate read-only, then propose a plan to approve",
|
|
67
|
-
};
|
|
68
|
-
// Prominent approval-mode selector below the box: all three listed, the active one highlighted (red
|
|
69
|
-
// for the dangerous full-auto) with a one-line description and the shift+tab hint.
|
|
70
|
-
const ModeBar = memo(function ModeBar({ approval }) {
|
|
71
|
-
const warn = approval === "full-auto";
|
|
72
|
-
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 ⇄` })] }));
|
|
73
|
-
});
|
|
74
83
|
/** The active `@mention` token immediately left of the cursor (for the file popup), or null. */
|
|
75
84
|
function activeMention(value, cursor) {
|
|
76
85
|
const m = /(?:^|\s)@([^\s@]*)$/.exec(value.slice(0, cursor));
|
|
@@ -220,9 +229,9 @@ const InputLine = memo(function InputLine({ value, cursor, width, gutter, gutter
|
|
|
220
229
|
}
|
|
221
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))) }));
|
|
222
231
|
});
|
|
223
|
-
/** Bordered prompt box + one dim status footer (model · approval · route · cwd · usage · ctx)
|
|
224
|
-
*
|
|
225
|
-
export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboardImage, isActive = true,
|
|
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", }) {
|
|
226
235
|
const { stdout } = useStdout();
|
|
227
236
|
const w = width ?? stdout?.columns ?? 80;
|
|
228
237
|
const [value, setValue] = useState("");
|
|
@@ -386,5 +395,5 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
|
|
|
386
395
|
// + corners and everything aligns column-for-column.
|
|
387
396
|
const innerW = Math.max(1, w - 4);
|
|
388
397
|
const cwdShort = footerCwd(cwd);
|
|
389
|
-
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 }),
|
|
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] }));
|
|
390
399
|
}
|