@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 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
- /** 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, " ");
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
- // Identity line — branches on kind. Personal collapses kind+model into one line;
174
- // org splits identity (org/label/id/route) from model (with its source).
175
- 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] }));
176
- 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] })) : 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" })] }));
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
  }
@@ -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,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
- 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" })] }));
31
- });
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
- const usage = `↑${tok(s.input)} ↓${tok(s.output)}${s.ctxPct > 0 ? ` · ctx ${s.ctxPct}%` : ""}`;
35
- const label = s.agents > 0 ? `${usage} · ⛁${s.agents}` : `${usage} · ⛁ idle`;
36
- const left = Math.max(2, width - label.length - 3);
37
- return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: `${"─".repeat(left)} ${label} ─` }) }));
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
- /** Top border (session) + prompt line + bottom border (usage) + ModeBar, with an @path popup. */
201
- 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", }) {
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
- 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 })] }));
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.98.3",
3
+ "version": "0.99.0",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"