@nanhara/hara 0.98.4 → 0.99.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/tui/App.js +33 -16
- package/dist/tui/InputBox.js +49 -21
- package/package.json +1 -1
package/dist/tui/App.js
CHANGED
|
@@ -160,28 +160,45 @@ export function shortenSession(uuid) {
|
|
|
160
160
|
return "";
|
|
161
161
|
return uuid.slice(0, 8);
|
|
162
162
|
}
|
|
163
|
-
/**
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
*
|
|
163
|
+
/** Data-driven label column (mirrors codex's `FieldFormatter::from_labels`): pad every label to the
|
|
164
|
+
* width of the WIDEST label actually shown this render, so values line up without a hard-coded column.
|
|
165
|
+
* Returns a `(label) => padded` closure. A 3-space gap after the padded label separates label↔value. */
|
|
166
|
+
export function fieldFormatter(labels) {
|
|
167
|
+
const width = labels.reduce((w, l) => Math.max(w, l.length), 0);
|
|
168
|
+
return (label) => label.padEnd(width, " ");
|
|
169
|
+
}
|
|
170
|
+
/** Dim trailing clause for the model line: the vision sidecar (only when configured). Pure so the
|
|
171
|
+
* composition (spacing, silence-when-unset) can be pinned in a unit test without rendering React.
|
|
172
|
+
* Returns "" when no describer is configured (native-vision main models stay silent — 顾雅 spec).
|
|
173
|
+
* The actionable `/model ↹` hint is rendered separately (in green) by the view. */
|
|
172
174
|
export function modelLineSuffix(visionModel) {
|
|
173
|
-
|
|
174
|
-
return `${vision} · /model to change`;
|
|
175
|
+
return visionModel ? ` · vision ${visionModel}` : "";
|
|
175
176
|
}
|
|
177
|
+
// The header is emitted ONCE into <Static> (App's id:-1 sentinel). A rounded, dim-bordered card
|
|
178
|
+
// (codex polish) that HUGS its content via alignSelf="flex-start" — so it neither spans the full
|
|
179
|
+
// width nor blows out on resize. Keeps hara's identity: seal-red ◆ glyph + title, the org/profile
|
|
180
|
+
// grid, and the vision sidecar clause. One accent (◆ + title); one green affordance (/model ↹).
|
|
176
181
|
function HeaderCard(props) {
|
|
177
182
|
const { version, modelLabel, cwd, agentsMdLoaded, session, kind } = props;
|
|
178
183
|
const home = process.env.HOME ?? "";
|
|
179
184
|
const cwdShort = shortenHome(cwd, home);
|
|
180
185
|
const sessionShort = shortenSession(session);
|
|
181
|
-
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
186
|
+
const isOrg = kind === "org";
|
|
187
|
+
// Data-driven label column: the first grid row is `org`/`profile`, then `model`, `cwd`, and
|
|
188
|
+
// `session` (only when present). Pad to the widest of exactly the labels we render this pass.
|
|
189
|
+
const labels = [isOrg ? "org" : "profile", "model", "cwd", ...(sessionShort ? ["session"] : [])];
|
|
190
|
+
const pad = fieldFormatter(labels);
|
|
191
|
+
const GAP = " "; // 3-space label↔value gap (codex spacing)
|
|
192
|
+
// No leading indent here: paddingX={1} on the card already insets content 1 cell, and the title
|
|
193
|
+
// glyph starts at that same column — so labels stay flush-left with `◆ hara` (codex alignment).
|
|
194
|
+
const row = (label, body) => (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: `${pad(label)}${GAP}` }), body] }));
|
|
195
|
+
// First grid row: personal → `profile personal[:<id>]`; org → `org <label> · <id> → <host>`.
|
|
196
|
+
const identityRow = isOrg
|
|
197
|
+
? row("org", (_jsxs(Text, { children: [_jsx(Text, { children: props.orgLabel ?? props.orgId ?? "(unnamed)" }), props.orgId && props.orgLabel ? _jsx(Text, { dimColor: true, children: ` · ${props.orgId}` }) : null, props.routeHost ? _jsx(Text, { dimColor: true, children: ` → ${props.routeHost}` }) : null] })))
|
|
198
|
+
: row("profile", _jsx(Text, { children: props.profileId ? `personal:${props.profileId}` : "personal" }));
|
|
199
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", borderDimColor: true, paddingX: 1, alignSelf: "flex-start", marginBottom: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: accent(), bold: true, children: "◆ hara" }), _jsx(Text, { dimColor: true, children: ` v${version} · the agent that runs like an org` })] }), _jsx(Text, { children: " " }), identityRow, row("model", (_jsxs(Text, { children: [_jsx(Text, { children: modelLabel }), isOrg
|
|
200
|
+
? props.modelSource ? _jsx(Text, { dimColor: true, children: ` · from ${props.modelSource}` }) : null
|
|
201
|
+
: props.visionModel ? _jsx(Text, { dimColor: true, children: modelLineSuffix(props.visionModel) }) : null, _jsx(Text, { children: " " }), _jsx(Text, { color: "green", children: "/model ↹" })] }))), row("cwd", (_jsxs(Text, { children: [_jsx(Text, { children: cwdShort }), agentsMdLoaded ? _jsx(Text, { dimColor: true, children: " · AGENTS.md" }) : null] }))), sessionShort ? row("session", _jsx(Text, { children: sessionShort })) : null] }), _jsx(Text, { dimColor: true, children: " Tip: @ attach file · ctrl+t transcript · ctrl+r reasoning · shift+tab approval · esc interrupt" })] }));
|
|
185
202
|
}
|
|
186
203
|
// Spinner verb: while a turn is running, prefer the in_progress todo's activeForm (or its text),
|
|
187
204
|
// so the bottom line reads "▶ updating tests…" instead of an abstract "working". Falls back to
|
|
@@ -540,5 +557,5 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
540
557
|
});
|
|
541
558
|
if (showTranscript)
|
|
542
559
|
return _jsx(Transcript, { items: [...history, ...current], onClose: () => setShowTranscript(false) });
|
|
543
|
-
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, isActive: !prompt, working: working && !askText, queued: pool.length, vim: vim, onSubmit: handleSubmit, onClipboardImage: onClipboardImage })] }));
|
|
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))), !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, onSubmit: handleSubmit, onClipboardImage: onClipboardImage })] }));
|
|
544
561
|
}
|
package/dist/tui/InputBox.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
// The framed input box (ink): a
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// where we want it. Pure-ish: pass `width` to make rendering deterministic in tests.
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
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.
|
|
6
5
|
//
|
|
7
6
|
// Render-stability principles (codex-style, for slow/remote terminals): ink erases and rewrites the
|
|
8
7
|
// ENTIRE dynamic region on every frame, so the box's cost scales with (a) how many lines it occupies
|
|
9
8
|
// and (b) how often any of them change. Two levers here:
|
|
10
|
-
// 1. The static chrome (
|
|
9
|
+
// 1. The static chrome (footer, mode bar, mention popup) is memoized so a keystroke that only
|
|
11
10
|
// changes the prompt text doesn't force React to reconcile the unchanged rows.
|
|
12
11
|
// 2. Line-wrapping + cursor are computed deterministically from (value, cursor, width) in one memo,
|
|
13
12
|
// so long input wraps under a stable continuation indent and the cursor never drifts — instead
|
|
@@ -24,19 +23,41 @@ const tok = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`);
|
|
|
24
23
|
// The prompt gutter ("› " / "◆ ") is 2 cells wide; wrapped continuation lines indent by the same
|
|
25
24
|
// amount so the text column is stable across visual rows.
|
|
26
25
|
const GUTTER = 2;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
// Tilde-collapse HOME and keep the project tail — a compact cwd for the one-line footer. Local (tiny)
|
|
27
|
+
// copy so InputBox doesn't reach back into App.tsx (App imports InputBox — avoid the cycle).
|
|
28
|
+
export function footerCwd(abs, home = process.env.HOME ?? "", maxLen = 28) {
|
|
29
|
+
let p = abs;
|
|
30
|
+
if (home && (p === home || p.startsWith(home + "/")))
|
|
31
|
+
p = "~" + p.slice(home.length);
|
|
32
|
+
if (p.length <= maxLen)
|
|
33
|
+
return p;
|
|
34
|
+
const tail = p.slice(-(maxLen - 1));
|
|
35
|
+
const slash = tail.indexOf("/");
|
|
36
|
+
return "…" + (slash > 0 ? tail.slice(slash) : tail);
|
|
37
|
+
}
|
|
38
|
+
/** Compose the single dim footer line rendered below the box. Pure so the ordering/spacing (and the
|
|
39
|
+
* optional route segment) can be pinned without rendering React. Shape (codex-style status line):
|
|
40
|
+
* `<model> · <approval>[ · <route>] · <cwd> · ↑<in> ↓<out> · ctx <pct>%`. The session name is NOT here —
|
|
41
|
+
* it rides the input box's top-right border (see TopBorder). `ctx N%` is always present (from 0 on) so
|
|
42
|
+
* the field never pops in mid-session and shifts the layout. */
|
|
43
|
+
export function footerLine(model, s, cwdShort, route) {
|
|
44
|
+
const routeSeg = route ? ` · ${route}` : "";
|
|
45
|
+
return ` ${model} · ${s.approval}${routeSeg} · ${cwdShort} · ↑${tok(s.input)} ↓${tok(s.output)} · ctx ${s.ctxPct}%`;
|
|
46
|
+
}
|
|
47
|
+
// The merged status footer (was split across the old top/bottom dash-rules): model · approval ·
|
|
48
|
+
// route · cwd · usage · ctx. Memoized so a prompt keystroke doesn't reconcile it.
|
|
49
|
+
const Footer = memo(function Footer({ model, s, cwdShort, route }) {
|
|
50
|
+
return _jsx(Text, { dimColor: true, children: footerLine(model, s, cwdShort, route) });
|
|
31
51
|
});
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
52
|
+
// The rounded TOP edge of the input box, carrying the session name in the right corner (it "rides"
|
|
53
|
+
// the border — codex-style titled panel, and where hara has always shown it). Drawn by hand because
|
|
54
|
+
// ink's <Box borderStyle> has no title slot; the box below it renders with `borderTop={false}` and a
|
|
55
|
+
// fixed `width`, so this line supplies the top with its two corners and everything lines up exactly.
|
|
56
|
+
// Layout (total = width): `╭` + left dashes + ` ● <name> ` + `─╮`. `●` (U+25CF) is a hard 1-cell glyph
|
|
57
|
+
// (unlike the emoji-presentation `⏺`, which some terminals render 2 cells wide and would skew the corner).
|
|
58
|
+
const TopBorder = memo(function TopBorder({ name, width }) {
|
|
59
|
+
const left = Math.max(2, width - name.length - 7); // ╭(1)+ " "(1)+●(1)+" name "(len+2)+"─╮"(2)
|
|
60
|
+
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: "─╮" })] }));
|
|
40
61
|
});
|
|
41
62
|
const MODE_DESC = {
|
|
42
63
|
suggest: "confirms edits & commands",
|
|
@@ -199,8 +220,9 @@ const InputLine = memo(function InputLine({ value, cursor, width, gutter, gutter
|
|
|
199
220
|
}
|
|
200
221
|
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))) }));
|
|
201
222
|
});
|
|
202
|
-
/**
|
|
203
|
-
|
|
223
|
+
/** Bordered prompt box + one dim status footer (model · approval · route · cwd · usage · ctx) +
|
|
224
|
+
* ModeBar, with an @path popup. */
|
|
225
|
+
export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboardImage, isActive = true, working = false, queued = 0, vim = false, placeholder = "Type a task · /help · @file · Ctrl+V paste image · shift+tab mode · Esc interrupts", }) {
|
|
204
226
|
const { stdout } = useStdout();
|
|
205
227
|
const w = width ?? stdout?.columns ?? 80;
|
|
206
228
|
const [value, setValue] = useState("");
|
|
@@ -358,5 +380,11 @@ export function InputBox({ status, cwd, width, onSubmit, onClipboardImage, isAct
|
|
|
358
380
|
}, { isActive });
|
|
359
381
|
const gutter = vim && mode === "normal" ? "◆ " : "› ";
|
|
360
382
|
const gutterColor = vim ? (mode === "normal" ? "yellow" : "green") : "cyan";
|
|
361
|
-
|
|
383
|
+
// The box borders (1 each side) + paddingX (1 each side) subtract 4 cells; InputLine's deterministic
|
|
384
|
+
// wrap needs the INNER width so the cursor/continuation gutter stay exact. The box is a fixed `width={w}`
|
|
385
|
+
// with `borderTop={false}` so the hand-drawn TopBorder (with the session title) supplies the top edge
|
|
386
|
+
// + corners and everything aligns column-for-column.
|
|
387
|
+
const innerW = Math.max(1, w - 4);
|
|
388
|
+
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 }), 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, _jsx(ModeBar, { approval: status.approval })] }));
|
|
362
390
|
}
|