@nanhara/hara 0.98.3 → 0.99.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/dist/index.js +1 -0
- package/dist/tui/App.js +35 -10
- package/dist/tui/InputBox.js +37 -20
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -2743,6 +2743,7 @@ program.action(async (opts) => {
|
|
|
2743
2743
|
orgId: __activeP.kind === "gateway" ? __activeP.deviceId || __activeP.id : undefined,
|
|
2744
2744
|
routeHost: __routeForHeader?.host,
|
|
2745
2745
|
modelSource: __modelSource,
|
|
2746
|
+
visionModel: cfg.visionModel,
|
|
2746
2747
|
},
|
|
2747
2748
|
visionNotice: __visionNotice,
|
|
2748
2749
|
cycleApproval: (m) => cycleMode(m),
|
package/dist/tui/App.js
CHANGED
|
@@ -160,20 +160,45 @@ export function shortenSession(uuid) {
|
|
|
160
160
|
return "";
|
|
161
161
|
return uuid.slice(0, 8);
|
|
162
162
|
}
|
|
163
|
-
/**
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
|
|
167
|
-
const
|
|
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. */
|
|
174
|
+
export function modelLineSuffix(visionModel) {
|
|
175
|
+
return visionModel ? ` · vision ${visionModel}` : "";
|
|
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 ↹).
|
|
168
181
|
function HeaderCard(props) {
|
|
169
182
|
const { version, modelLabel, cwd, agentsMdLoaded, session, kind } = props;
|
|
170
183
|
const home = process.env.HOME ?? "";
|
|
171
184
|
const cwdShort = shortenHome(cwd, home);
|
|
172
185
|
const sessionShort = shortenSession(session);
|
|
173
|
-
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
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" })] }));
|
|
177
202
|
}
|
|
178
203
|
// Spinner verb: while a turn is running, prefer the in_progress todo's activeForm (or its text),
|
|
179
204
|
// so the bottom line reads "▶ updating tests…" instead of an abstract "working". Falls back to
|
|
@@ -532,5 +557,5 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
532
557
|
});
|
|
533
558
|
if (showTranscript)
|
|
534
559
|
return _jsx(Transcript, { items: [...history, ...current], onClose: () => setShowTranscript(false) });
|
|
535
|
-
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 })] }));
|
|
536
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,17 +23,30 @@ 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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
return
|
|
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>%`. `ctx N%` is always present
|
|
41
|
+
* (from 0 on) so the field never pops in mid-session and shifts the layout. */
|
|
42
|
+
export function footerLine(model, s, cwdShort, route) {
|
|
43
|
+
const routeSeg = route ? ` · ${route}` : "";
|
|
44
|
+
return ` ${model} · ${s.approval}${routeSeg} · ${cwdShort} · ↑${tok(s.input)} ↓${tok(s.output)} · ctx ${s.ctxPct}%`;
|
|
45
|
+
}
|
|
46
|
+
// The merged status footer (was split across the old top/bottom dash-rules): model · approval ·
|
|
47
|
+
// route · cwd · usage · ctx. Memoized so a prompt keystroke doesn't reconcile it.
|
|
48
|
+
const Footer = memo(function Footer({ model, s, cwdShort, route }) {
|
|
49
|
+
return _jsx(Text, { dimColor: true, children: footerLine(model, s, cwdShort, route) });
|
|
38
50
|
});
|
|
39
51
|
const MODE_DESC = {
|
|
40
52
|
suggest: "confirms edits & commands",
|
|
@@ -197,8 +209,9 @@ const InputLine = memo(function InputLine({ value, cursor, width, gutter, gutter
|
|
|
197
209
|
}
|
|
198
210
|
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))) }));
|
|
199
211
|
});
|
|
200
|
-
/**
|
|
201
|
-
|
|
212
|
+
/** Bordered prompt box + one dim status footer (model · approval · route · cwd · usage · ctx) +
|
|
213
|
+
* ModeBar, with an @path popup. */
|
|
214
|
+
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", }) {
|
|
202
215
|
const { stdout } = useStdout();
|
|
203
216
|
const w = width ?? stdout?.columns ?? 80;
|
|
204
217
|
const [value, setValue] = useState("");
|
|
@@ -356,5 +369,9 @@ export function InputBox({ status, cwd, width, onSubmit, onClipboardImage, isAct
|
|
|
356
369
|
}, { isActive });
|
|
357
370
|
const gutter = vim && mode === "normal" ? "◆ " : "› ";
|
|
358
371
|
const gutterColor = vim ? (mode === "normal" ? "yellow" : "green") : "cyan";
|
|
359
|
-
|
|
372
|
+
// The prompt box borders subtract 2 cells (1 each side) from the usable text width; InputLine's
|
|
373
|
+
// deterministic wrap needs the INNER width so the cursor/continuation gutter stay exact.
|
|
374
|
+
const innerW = Math.max(1, w - 2);
|
|
375
|
+
const cwdShort = footerCwd(cwd);
|
|
376
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { borderStyle: "round", borderColor: "gray", borderDimColor: true, paddingX: 1, 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 })] }));
|
|
360
377
|
}
|