@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 CHANGED
@@ -160,28 +160,45 @@ export function shortenSession(uuid) {
160
160
  return "";
161
161
  return uuid.slice(0, 8);
162
162
  }
163
- /** Layout constants for the field grid. Field names live in a 10-char column;
164
- * values start at column 12 (after 2 spaces). The view uses `padField` to
165
- * pad a label so the values align vertically across rows. */
166
- const FIELD_PAD = 10;
167
- const padField = (name) => name.padEnd(FIELD_PAD, " ");
168
- /** Dim trailing clause for the model line: the vision sidecar (only when configured) then a subtle
169
- * codex-style `/model` hint. Pure so the composition (spacing, ordering, silence-when-unset) can be
170
- * pinned in a unit test without rendering React. Returns "" when there is nothing to show beyond
171
- * the hint the hint itself is always present (it's the discoverability affordance). */
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
- const vision = visionModel ? ` · vision ${visionModel}` : "";
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
- // Identity line — branches on kind. Personal collapses kind+model into one line;
182
- // org splits identity (org/label/id/route) from model (with its source).
183
- const identity = kind === "org" ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: ` ${padField("org")}` }), _jsx(Text, { children: props.orgLabel ?? props.orgId ?? "(unnamed)" }), props.orgId && props.orgLabel ? _jsx(Text, { dimColor: true, children: ` · ${props.orgId}` }) : null, props.routeHost ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: " → " }), _jsx(Text, { dimColor: true, children: props.routeHost })] })) : null] })) : (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: ` ${padField(props.profileId ? `personal:${props.profileId}` : "personal")}` }), _jsx(Text, { children: modelLabel }), props.routeHost ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: " → " }), _jsx(Text, { dimColor: true, children: props.routeHost })] })) : null, _jsx(Text, { dimColor: true, children: modelLineSuffix(props.visionModel) })] }));
184
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: accent(), children: "> " }), _jsx(Text, { color: accent(), bold: true, children: `hara` }), _jsx(Text, { dimColor: true, children: ` · v${version} — the coding agent that runs like an org` })] }), _jsx(Text, { children: " " }), identity, kind === "org" ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: ` ${padField("model")}` }), _jsx(Text, { children: modelLabel }), props.modelSource ? _jsx(Text, { dimColor: true, children: ` · from ${props.modelSource}` }) : null, _jsx(Text, { dimColor: true, children: modelLineSuffix(props.visionModel) })] })) : null, _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: ` ${padField("cwd")}` }), _jsx(Text, { dimColor: true, children: cwdShort }), agentsMdLoaded ? _jsx(Text, { dimColor: true, children: " · AGENTS.md" }) : null] }), sessionShort ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: ` ${padField("session")}` }), _jsx(Text, { dimColor: true, children: sessionShort })] })) : null, _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: " /help · @file · shift+tab · esc · ctrl+t transcript · ctrl+r reasoning" })] }));
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
  }
@@ -1,13 +1,12 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- // The framed input box (ink): a top border carrying the session name in the right corner, the
3
- // prompt line in the middle, and a bottom border carrying the approval modes + token usage +
4
- // concurrency. Composed from <Text> rows (no ink border fork needed) so the title sits exactly
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 (borders, mode bar, mention popup) is memoized so a keystroke that only
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
- const TopBorder = memo(function TopBorder({ name, width }) {
28
- const labelLen = name.length + 2; // "⏺ " + name
29
- const left = Math.max(2, width - labelLen - 3);
30
- return (_jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: ["─".repeat(left), " "] }), _jsx(Text, { color: "cyan", children: "\u23FA" }), _jsxs(Text, { bold: true, children: [" ", name] }), _jsx(Text, { dimColor: true, children: " \u2500" })] }));
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
- // Bottom border carries token usage + concurrency at the right corner (modes moved to ModeBar below).
33
- const BottomBorder = memo(function BottomBorder({ s, width }) {
34
- // Always render `ctx N%` (from 0 on) so the field is present from the first frame — otherwise it
35
- // pops in mid-session the moment ctx first exceeds 0, shifting the whole bottom-border layout.
36
- const usage = `↑${tok(s.input)} ↓${tok(s.output)} · ctx ${s.ctxPct}%`;
37
- const label = s.agents > 0 ? `${usage} · ⛁${s.agents}` : `${usage} · idle`;
38
- const left = Math.max(2, width - label.length - 3);
39
- return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: `${"".repeat(left)} ${label} ─` }) }));
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
- /** Top border (session) + prompt line + bottom border (usage) + ModeBar, with an @path popup. */
203
- export function InputBox({ status, cwd, 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", }) {
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
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TopBorder, { name: status.sessionName || "session", width: w }), _jsx(InputLine, { value: value, cursor: cursor, width: w, 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(BottomBorder, { s: status, width: w }), 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 })] }));
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.98.4",
3
+ "version": "0.99.1",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"