@nanhara/hara 0.98.1 → 0.98.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/dist/index.js +5 -1
- package/dist/tui/App.js +23 -8
- package/dist/tui/InputBox.js +146 -28
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -3118,7 +3118,11 @@ program.action(async (opts) => {
|
|
|
3118
3118
|
await maybeAutoCompact(provider, history, meta, stats, cfg, (m) => h.sink.notice(m));
|
|
3119
3119
|
},
|
|
3120
3120
|
});
|
|
3121
|
-
|
|
3121
|
+
// Only claim "saved · resume" if a turn actually persisted the session. A zero-turn session (opened,
|
|
3122
|
+
// then exited without submitting anything) is never written by saveSession — so printing the resume
|
|
3123
|
+
// hint would mislead and `hara resume <id>` would fail with "no session matching".
|
|
3124
|
+
if (loadSession(meta.id))
|
|
3125
|
+
out("\n" + c.dim("Session ") + c.bold(shortId(meta.id)) + c.dim(" saved · resume: ") + c.cyan(`hara resume ${shortId(meta.id)}`) + "\n");
|
|
3122
3126
|
await closeMcp();
|
|
3123
3127
|
process.exit(0); // TUI done — exit cleanly (ink can leave stdin referenced)
|
|
3124
3128
|
}
|
package/dist/tui/App.js
CHANGED
|
@@ -8,7 +8,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
8
8
|
// The agent machinery is injected via `onSubmit` (a turn runner) so this view is testable with
|
|
9
9
|
// ink-testing-library against a fake runner — no provider/network needed.
|
|
10
10
|
import { Box, Static, Text, useApp, useInput, useStdout } from "ink";
|
|
11
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
11
|
+
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
|
12
12
|
import { InputBox } from "./InputBox.js";
|
|
13
13
|
import { activity } from "../activity.js";
|
|
14
14
|
import { ctxPctFor } from "../statusbar.js";
|
|
@@ -34,7 +34,10 @@ function foldForHistory(it) {
|
|
|
34
34
|
* block on every token, which over a laggy link leaves stale duplicate lines + jitter. 33ms clamps
|
|
35
35
|
* the live re-render rate while state itself stays exact (nothing is dropped, only coalesced). */
|
|
36
36
|
const LIVE_FRAME_MS = 33;
|
|
37
|
-
|
|
37
|
+
// Memoized: a live block only re-renders when its own `item` (a fresh object when its text grows) or
|
|
38
|
+
// `open` changes. So a spinner tick or an unrelated flush doesn't re-run `renderMarkdown` for every
|
|
39
|
+
// live block, and non-tail live blocks stay put while only the streaming tail grows.
|
|
40
|
+
const Block = memo(function Block({ item, open }) {
|
|
38
41
|
switch (item.kind) {
|
|
39
42
|
case "user":
|
|
40
43
|
return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "cyan", children: "\u203A " }), _jsx(Text, { children: item.text })] }));
|
|
@@ -58,7 +61,7 @@ function Block({ item, open }) {
|
|
|
58
61
|
case "notice":
|
|
59
62
|
return _jsx(Text, { dimColor: true, children: item.text });
|
|
60
63
|
}
|
|
61
|
-
}
|
|
64
|
+
});
|
|
62
65
|
function flattenTranscript(items) {
|
|
63
66
|
const out = [];
|
|
64
67
|
for (const it of items) {
|
|
@@ -183,21 +186,33 @@ export function spinnerVerb(list, elapsedSec) {
|
|
|
183
186
|
}
|
|
184
187
|
return `working ${elapsedSec}s · esc to interrupt`;
|
|
185
188
|
}
|
|
189
|
+
// The spinner is the only element that animates continuously while a turn runs — and because ink
|
|
190
|
+
// redraws the WHOLE dynamic region (input box + mode bar included) on any change, its tick rate sets
|
|
191
|
+
// a floor on full-frame redraws over the life of a turn. At ~8fps (125ms) the braille glyph still
|
|
192
|
+
// reads as smooth motion but cuts those forced redraws ~20% vs 100ms — meaningfully calmer over a
|
|
193
|
+
// slow/remote link. Elapsed seconds come from a wall-clock start, so the "Ns" text stays exact and
|
|
194
|
+
// stable (it only changes once per second, not coupled to the glyph frame).
|
|
195
|
+
const SPINNER_FRAME_MS = 125;
|
|
186
196
|
function Working({ todos }) {
|
|
187
|
-
const [
|
|
197
|
+
const [frame, setFrame] = useState(0);
|
|
198
|
+
const startRef = useRef(Date.now());
|
|
188
199
|
useEffect(() => {
|
|
189
|
-
|
|
200
|
+
startRef.current = Date.now();
|
|
201
|
+
const id = setInterval(() => setFrame((x) => x + 1), SPINNER_FRAME_MS);
|
|
190
202
|
return () => clearInterval(id);
|
|
191
203
|
}, []);
|
|
192
204
|
const frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
|
|
193
|
-
|
|
205
|
+
const elapsedSec = Math.floor((Date.now() - startRef.current) / 1000);
|
|
206
|
+
return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: frames[frame % frames.length] }), _jsx(Text, { dimColor: true, children: ` ${spinnerVerb(todos, elapsedSec)}` })] }));
|
|
194
207
|
}
|
|
195
208
|
// Live task panel: renders the current todo_write checklist between the in-progress turn output
|
|
196
209
|
// and the input box. Highlights the in_progress item; caps at 8 rows and folds the rest into
|
|
197
210
|
// `… +N pending/done`. Hidden when the list is empty.
|
|
198
211
|
const PANEL_MAX_ROWS = 8;
|
|
199
212
|
const TODO_MARK = { pending: "☐", in_progress: "▶", done: "☑" };
|
|
200
|
-
|
|
213
|
+
// Memoized on the `todos` array reference (stable between todo_write updates), so spinner ticks and
|
|
214
|
+
// streaming flushes don't re-run the sort/slice every frame.
|
|
215
|
+
const TodoPanel = memo(function TodoPanel({ todos }) {
|
|
201
216
|
if (!todos.length)
|
|
202
217
|
return null;
|
|
203
218
|
const doneCount = todos.filter((t) => t.status === "done").length;
|
|
@@ -224,7 +239,7 @@ function TodoPanel({ todos }) {
|
|
|
224
239
|
const done = t.status === "done";
|
|
225
240
|
return (_jsx(Text, { color: inProg ? accent() : undefined, bold: inProg, dimColor: done, children: ` ${TODO_MARK[t.status]} ${t.text}` }, i));
|
|
226
241
|
}), hiddenSummary ? _jsx(Text, { dimColor: true, children: hiddenSummary }) : null] }));
|
|
227
|
-
}
|
|
242
|
+
});
|
|
228
243
|
export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval, onClipboardImage, vim, visionNotice }) {
|
|
229
244
|
const { exit } = useApp();
|
|
230
245
|
const [history, setHistory] = useState([]);
|
package/dist/tui/InputBox.js
CHANGED
|
@@ -3,26 +3,39 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
|
3
3
|
// prompt line in the middle, and a bottom border carrying the approval modes + token usage +
|
|
4
4
|
// concurrency. Composed from <Text> rows (no ink border fork needed) so the title sits exactly
|
|
5
5
|
// where we want it. Pure-ish: pass `width` to make rendering deterministic in tests.
|
|
6
|
+
//
|
|
7
|
+
// Render-stability principles (codex-style, for slow/remote terminals): ink erases and rewrites the
|
|
8
|
+
// ENTIRE dynamic region on every frame, so the box's cost scales with (a) how many lines it occupies
|
|
9
|
+
// 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
|
|
11
|
+
// changes the prompt text doesn't force React to reconcile the unchanged rows.
|
|
12
|
+
// 2. Line-wrapping + cursor are computed deterministically from (value, cursor, width) in one memo,
|
|
13
|
+
// so long input wraps under a stable continuation indent and the cursor never drifts — instead
|
|
14
|
+
// of leaning on ink's soft-wrap of a single <Text>, which mis-aligns wrapped rows against the
|
|
15
|
+
// "› " prompt gutter and reflows unpredictably as you type.
|
|
6
16
|
import { Box, Text, useInput, useStdout } from "ink";
|
|
7
|
-
import { useMemo, useState } from "react";
|
|
17
|
+
import { memo, useMemo, useState } from "react";
|
|
8
18
|
import { fileCandidates } from "../context/mentions.js";
|
|
9
19
|
import { imagePathFromPaste } from "../images.js";
|
|
10
20
|
import { vimNormal } from "./vim.js";
|
|
11
21
|
export const MODES = ["suggest", "auto-edit", "full-auto", "plan"];
|
|
12
22
|
export const nextMode = (m) => MODES[(MODES.indexOf(m) + 1) % MODES.length];
|
|
13
23
|
const tok = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`);
|
|
14
|
-
|
|
24
|
+
// The prompt gutter ("› " / "◆ ") is 2 cells wide; wrapped continuation lines indent by the same
|
|
25
|
+
// amount so the text column is stable across visual rows.
|
|
26
|
+
const GUTTER = 2;
|
|
27
|
+
const TopBorder = memo(function TopBorder({ name, width }) {
|
|
15
28
|
const labelLen = name.length + 2; // "⏺ " + name
|
|
16
29
|
const left = Math.max(2, width - labelLen - 3);
|
|
17
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" })] }));
|
|
18
|
-
}
|
|
31
|
+
});
|
|
19
32
|
// Bottom border carries token usage + concurrency at the right corner (modes moved to ModeBar below).
|
|
20
|
-
function BottomBorder({ s, width }) {
|
|
33
|
+
const BottomBorder = memo(function BottomBorder({ s, width }) {
|
|
21
34
|
const usage = `↑${tok(s.input)} ↓${tok(s.output)}${s.ctxPct > 0 ? ` · ctx ${s.ctxPct}%` : ""}`;
|
|
22
35
|
const label = s.agents > 0 ? `${usage} · ⛁${s.agents}` : `${usage} · ⛁ idle`;
|
|
23
36
|
const left = Math.max(2, width - label.length - 3);
|
|
24
37
|
return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: `${"─".repeat(left)} ${label} ─` }) }));
|
|
25
|
-
}
|
|
38
|
+
});
|
|
26
39
|
const MODE_DESC = {
|
|
27
40
|
suggest: "confirms edits & commands",
|
|
28
41
|
"auto-edit": "auto-applies edits · asks before commands",
|
|
@@ -31,23 +44,22 @@ const MODE_DESC = {
|
|
|
31
44
|
};
|
|
32
45
|
// Prominent approval-mode selector below the box: all three listed, the active one highlighted (red
|
|
33
46
|
// for the dangerous full-auto) with a one-line description and the shift+tab hint.
|
|
34
|
-
function ModeBar({ approval }) {
|
|
47
|
+
const ModeBar = memo(function ModeBar({ approval }) {
|
|
35
48
|
const warn = approval === "full-auto";
|
|
36
49
|
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 ⇄` })] }));
|
|
37
|
-
}
|
|
50
|
+
});
|
|
38
51
|
/** The active `@mention` token immediately left of the cursor (for the file popup), or null. */
|
|
39
52
|
function activeMention(value, cursor) {
|
|
40
53
|
const m = /(?:^|\s)@([^\s@]*)$/.exec(value.slice(0, cursor));
|
|
41
54
|
return m ? { query: m[1], start: cursor - m[1].length } : null;
|
|
42
55
|
}
|
|
43
56
|
// Dropdown of fuzzy @path matches, shown above the input as you type `@…` (codex / Claude-Code style).
|
|
44
|
-
function MentionPopup({ items, selected, query }) {
|
|
57
|
+
const MentionPopup = memo(function MentionPopup({ items, selected, query }) {
|
|
45
58
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: ` @${query} · ${items.length} match${items.length === 1 ? "" : "es"} — ↑↓ select · Tab/Enter insert · Esc dismiss` }), items.map((it, i) => (_jsxs(Text, { children: [i === selected ? _jsx(Text, { color: "cyan", children: " ▸ " }) : _jsx(Text, { children: " " }), _jsx(Text, { color: it.endsWith("/") ? "blue" : undefined, dimColor: i !== selected, bold: i === selected, children: it })] }, it)))] }));
|
|
46
|
-
}
|
|
59
|
+
});
|
|
47
60
|
const TOKEN_RE = /\[Image #\d+\]/g;
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
function InputLine({ value, cursor }) {
|
|
61
|
+
/** Split the value into text/image-token segments (image tokens render highlighted, codex-style). */
|
|
62
|
+
function segmentize(value) {
|
|
51
63
|
const parts = [];
|
|
52
64
|
let last = 0;
|
|
53
65
|
let m;
|
|
@@ -60,30 +72,131 @@ function InputLine({ value, cursor }) {
|
|
|
60
72
|
}
|
|
61
73
|
if (last < value.length)
|
|
62
74
|
parts.push({ text: value.slice(last), token: false });
|
|
75
|
+
return parts;
|
|
76
|
+
}
|
|
77
|
+
/** Wrap `value` into rows that each fit within `cols` cells, breaking on spaces where possible but
|
|
78
|
+
* never inside an `[Image #N]` token. Deterministic (no reliance on ink's soft-wrap) so wrapped rows
|
|
79
|
+
* align under a stable gutter and the cursor position is exact. Always returns at least one row. */
|
|
80
|
+
export function wrapRows(value, cols) {
|
|
81
|
+
const width = Math.max(1, cols);
|
|
82
|
+
const rows = [];
|
|
83
|
+
const parts = segmentize(value);
|
|
84
|
+
// Flatten to atomic units: a run of text is breakable per-char/word; an image token is atomic.
|
|
85
|
+
const atoms = [];
|
|
86
|
+
let pos = 0;
|
|
87
|
+
for (const p of parts) {
|
|
88
|
+
if (p.token) {
|
|
89
|
+
atoms.push({ text: p.text, start: pos, atomic: true });
|
|
90
|
+
pos += p.text.length;
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
// Break the text run into word chunks (keep trailing space with the word) so wraps land on spaces.
|
|
94
|
+
const re = /\S+\s*|\s+/g;
|
|
95
|
+
let m;
|
|
96
|
+
let base = pos;
|
|
97
|
+
while ((m = re.exec(p.text))) {
|
|
98
|
+
atoms.push({ text: m[0], start: base + m.index, atomic: false });
|
|
99
|
+
}
|
|
100
|
+
pos += p.text.length;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (atoms.length === 0)
|
|
104
|
+
return [{ start: 0, end: value.length }];
|
|
105
|
+
let rowStart = 0;
|
|
106
|
+
let col = 0;
|
|
107
|
+
const flush = (end) => {
|
|
108
|
+
rows.push({ start: rowStart, end });
|
|
109
|
+
rowStart = end;
|
|
110
|
+
col = 0;
|
|
111
|
+
};
|
|
112
|
+
for (const a of atoms) {
|
|
113
|
+
const aEnd = a.start + a.text.length;
|
|
114
|
+
if (a.atomic || a.text.length <= width) {
|
|
115
|
+
// A whole unit (image token OR a word chunk that fits within a full row): if it doesn't fit in
|
|
116
|
+
// the remaining room and the row already has content, wrap to a fresh row FIRST — never split it.
|
|
117
|
+
if (a.text.length > width - col && col > 0)
|
|
118
|
+
flush(a.start);
|
|
119
|
+
col += a.text.length; // an oversized atomic token may exceed width — acceptable (rare, kept whole)
|
|
120
|
+
if (col >= width)
|
|
121
|
+
flush(aEnd);
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
// A single word longer than the whole width: hard-break it across rows.
|
|
125
|
+
let s = a.start;
|
|
126
|
+
if (col > 0)
|
|
127
|
+
flush(s); // start the long word on a fresh row
|
|
128
|
+
while (s < aEnd) {
|
|
129
|
+
const room = width - col;
|
|
130
|
+
const take = Math.min(room, aEnd - s);
|
|
131
|
+
col += take;
|
|
132
|
+
s += take;
|
|
133
|
+
if (col >= width)
|
|
134
|
+
flush(s);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Flush the tail. If content exactly filled the last flushed row (rowStart === value.length), we add
|
|
139
|
+
// an EMPTY trailing row so an end-of-line cursor doesn't overflow the full row (would wrap oddly).
|
|
140
|
+
if (rowStart < value.length) {
|
|
141
|
+
rows.push({ start: rowStart, end: value.length });
|
|
142
|
+
}
|
|
143
|
+
else if (rows.length === 0) {
|
|
144
|
+
rows.push({ start: 0, end: value.length }); // whole value fit on one (un-flushed) row
|
|
145
|
+
}
|
|
146
|
+
else if (rowStart === value.length && col === 0) {
|
|
147
|
+
rows.push({ start: value.length, end: value.length }); // exactly-full last row → empty tail for the cursor
|
|
148
|
+
}
|
|
149
|
+
return rows;
|
|
150
|
+
}
|
|
151
|
+
/** Render a single visual row of the prompt: text + highlighted image tokens, drawing the block
|
|
152
|
+
* cursor (inverse cell) when it falls on this row. Splitting happens on precomputed rows so the
|
|
153
|
+
* whole prompt never reflows as a single <Text> — stable under wrapping and quick to diff. */
|
|
154
|
+
function renderRow(value, row, cursor, showCursor, isLastRow, keyPrefix) {
|
|
63
155
|
const seg = (token, text, k) => token ? (_jsx(Text, { backgroundColor: "magenta", color: "white", children: text }, k)) : (_jsx(Text, { children: text }, k));
|
|
156
|
+
// Segments intersected with this row's [start,end) range.
|
|
157
|
+
const parts = segmentize(value);
|
|
64
158
|
const nodes = [];
|
|
65
|
-
let pos = 0;
|
|
66
159
|
let ki = 0;
|
|
160
|
+
let pos = 0;
|
|
161
|
+
const cursorOnRow = showCursor && cursor >= row.start && cursor < row.end;
|
|
67
162
|
for (const p of parts) {
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
163
|
+
const pStart = pos;
|
|
164
|
+
const pEnd = pos + p.text.length;
|
|
165
|
+
pos = pEnd;
|
|
166
|
+
const from = Math.max(pStart, row.start);
|
|
167
|
+
const to = Math.min(pEnd, row.end);
|
|
168
|
+
if (from >= to)
|
|
169
|
+
continue; // segment not on this row
|
|
170
|
+
const slice = p.text.slice(from - pStart, to - pStart);
|
|
171
|
+
if (cursorOnRow && cursor >= from && cursor < to) {
|
|
172
|
+
const rel = cursor - from;
|
|
72
173
|
if (rel > 0)
|
|
73
|
-
nodes.push(seg(p.token,
|
|
74
|
-
nodes.push(_jsx(Text, { inverse: true, children:
|
|
75
|
-
if (rel + 1 <
|
|
76
|
-
nodes.push(seg(p.token,
|
|
174
|
+
nodes.push(seg(p.token, slice.slice(0, rel), `${keyPrefix}s${ki++}`));
|
|
175
|
+
nodes.push(_jsx(Text, { inverse: true, children: slice[rel] }, `${keyPrefix}c${ki++}`));
|
|
176
|
+
if (rel + 1 < slice.length)
|
|
177
|
+
nodes.push(seg(p.token, slice.slice(rel + 1), `${keyPrefix}e${ki++}`));
|
|
77
178
|
}
|
|
78
179
|
else {
|
|
79
|
-
nodes.push(seg(p.token,
|
|
180
|
+
nodes.push(seg(p.token, slice, `${keyPrefix}p${ki++}`));
|
|
80
181
|
}
|
|
81
|
-
pos = end;
|
|
82
182
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
183
|
+
// End-of-value cursor: a trailing inverse space, only ever on the LAST row (so it's drawn once).
|
|
184
|
+
if (showCursor && isLastRow && cursor >= value.length && cursor >= row.start) {
|
|
185
|
+
nodes.push(_jsx(Text, { inverse: true, children: " " }, `${keyPrefix}end`));
|
|
186
|
+
}
|
|
187
|
+
return nodes;
|
|
86
188
|
}
|
|
189
|
+
/** The prompt: gutter + wrapped input rows (or a placeholder when empty). Each wrapped continuation
|
|
190
|
+
* row is indented under the gutter so the text column is stable. Memoized so it only re-renders when
|
|
191
|
+
* the value/cursor/width/gutter actually change (not when unrelated status ticks over). */
|
|
192
|
+
const InputLine = memo(function InputLine({ value, cursor, width, gutter, gutterColor, placeholder, }) {
|
|
193
|
+
const cols = Math.max(1, width - GUTTER); // text column width (after the 2-cell gutter)
|
|
194
|
+
const rows = useMemo(() => (value.length ? wrapRows(value, cols) : [{ start: 0, end: 0 }]), [value, cols]);
|
|
195
|
+
if (value.length === 0) {
|
|
196
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: gutterColor, children: gutter }), _jsxs(Text, { children: [_jsx(Text, { inverse: true, children: " " }), _jsx(Text, { dimColor: true, children: placeholder })] })] }));
|
|
197
|
+
}
|
|
198
|
+
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
|
+
});
|
|
87
200
|
/** Top border (session) + prompt line + bottom border (usage) + ModeBar, with an @path popup. */
|
|
88
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", }) {
|
|
89
202
|
const { stdout } = useStdout();
|
|
@@ -124,7 +237,10 @@ export function InputBox({ status, cwd, width, onSubmit, onClipboardImage, isAct
|
|
|
124
237
|
setPending("");
|
|
125
238
|
};
|
|
126
239
|
const mention = activeMention(value, cursor);
|
|
127
|
-
|
|
240
|
+
// FS scan only when the mention QUERY changes — not on every cursor move / unrelated keystroke.
|
|
241
|
+
// (`mention.start` moves whenever the cursor does; keying off it re-scanned the disk needlessly.)
|
|
242
|
+
const mentionQuery = isActive && mention && !dismissed ? mention.query : null;
|
|
243
|
+
const candidates = useMemo(() => (mentionQuery !== null ? fileCandidates(cwd, mentionQuery, 8) : []), [cwd, mentionQuery]);
|
|
128
244
|
const popupOpen = candidates.length > 0;
|
|
129
245
|
const selIdx = popupOpen ? Math.min(sel, candidates.length - 1) : 0;
|
|
130
246
|
const complete = (cand) => {
|
|
@@ -238,5 +354,7 @@ export function InputBox({ status, cwd, width, onSubmit, onClipboardImage, isAct
|
|
|
238
354
|
set(value.slice(0, cursor) + input + value.slice(cursor), cursor + input.length);
|
|
239
355
|
}
|
|
240
356
|
}, { isActive });
|
|
241
|
-
|
|
357
|
+
const gutter = vim && mode === "normal" ? "◆ " : "› ";
|
|
358
|
+
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 })] }));
|
|
242
360
|
}
|