klyro 0.1.45 → 0.1.47
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/cli/repl.js +115 -1
- package/dist/tui/app.d.ts +2 -0
- package/dist/tui/app.js +312 -121
- package/dist/tui/app.test.js +67 -0
- package/dist/tui/measure.d.ts +85 -0
- package/dist/tui/measure.js +186 -0
- package/dist/tui/mouse.d.ts +36 -0
- package/dist/tui/mouse.js +94 -0
- package/dist/tui/scroll-flow.test.d.ts +1 -0
- package/dist/tui/scroll-flow.test.js +94 -0
- package/dist/tui/scroll-model.d.ts +67 -0
- package/dist/tui/scroll-model.js +99 -0
- package/package.json +1 -1
package/dist/tui/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
3
|
* Klyro TUI - opencode-clean - no clumsy words, correct wrap, markdown, scroll
|
|
4
4
|
* Header 3 rows, guide │ at col2, ● Klyro accent, prose wrapped at word boundaries
|
|
@@ -9,6 +9,8 @@ import { execFileSync } from 'node:child_process';
|
|
|
9
9
|
import { TuiApprovalBridge } from './approval.js';
|
|
10
10
|
import { parse as parseSlash, suggestCommands } from '../cli/slash/parser.js';
|
|
11
11
|
import { tokens, g } from './tokens.js';
|
|
12
|
+
import { initialScroll, scrollReducer, resolveTopRow, maxTopFor, } from './scroll-model.js';
|
|
13
|
+
import { buildIndex, itemAtRow, MeasureCache } from './measure.js';
|
|
12
14
|
let _id = 0;
|
|
13
15
|
function nextId(p) { _id++; return `${p}-${_id}`; }
|
|
14
16
|
function Header({ cwd, model, version, width }) {
|
|
@@ -42,29 +44,34 @@ function verbForTool(name) {
|
|
|
42
44
|
return 'Edited';
|
|
43
45
|
return 'Called';
|
|
44
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Aggregator (scroll.md §9): merge CONSECUTIVE same-op tool events into one
|
|
49
|
+
* ActivityGroup. Never merge across a different op — read,read,edit,read →
|
|
50
|
+
* 3 groups, not 2. Group ids are content-derived (stable across regroups so
|
|
51
|
+
* scroll anchors and expansion state survive streaming appends).
|
|
52
|
+
*/
|
|
45
53
|
function groupTools(items) {
|
|
46
54
|
const out = [];
|
|
47
55
|
let cur = [];
|
|
56
|
+
let curVerb = null;
|
|
48
57
|
const flush = () => {
|
|
49
58
|
if (cur.length === 0)
|
|
50
59
|
return;
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
byVerb.set(v, []);
|
|
56
|
-
byVerb.get(v).push(it);
|
|
57
|
-
}
|
|
58
|
-
for (const [verb, list] of byVerb) {
|
|
59
|
-
const totalMs = list.reduce((s, x) => s + (x.latencyMs ?? 0), 0);
|
|
60
|
-
const status = list.some((x) => x.isError || x.status === 'error') ? 'error' : list.some((x) => x.status === 'running') ? 'running' : 'done';
|
|
61
|
-
out.push({ id: nextId('g'), verb, items: list, totalMs, status });
|
|
62
|
-
}
|
|
60
|
+
const verb = curVerb;
|
|
61
|
+
const totalMs = cur.reduce((s, x) => s + (x.latencyMs ?? 0), 0);
|
|
62
|
+
const status = cur.some((x) => x.isError || x.status === 'error') ? 'error' : cur.some((x) => x.status === 'running') ? 'running' : 'done';
|
|
63
|
+
out.push({ id: `g:${verb}:${cur.map((i) => i.id_call).join('|')}`, verb, items: cur, totalMs, status });
|
|
63
64
|
cur = [];
|
|
65
|
+
curVerb = null;
|
|
64
66
|
};
|
|
65
67
|
for (const it of items) {
|
|
66
|
-
if (it.kind === 'tool')
|
|
68
|
+
if (it.kind === 'tool') {
|
|
69
|
+
const v = verbForTool(it.name);
|
|
70
|
+
if (curVerb !== null && v !== curVerb)
|
|
71
|
+
flush(); // different op → break the group
|
|
72
|
+
curVerb = v;
|
|
67
73
|
cur.push(it);
|
|
74
|
+
}
|
|
68
75
|
else {
|
|
69
76
|
flush();
|
|
70
77
|
out.push(it);
|
|
@@ -94,78 +101,86 @@ function MarkdownText({ text, dim, width }) {
|
|
|
94
101
|
// Render as single line with bold segments — Ink will wrap the parent Box
|
|
95
102
|
return _jsx(Text, { wrap: "wrap", children: parts });
|
|
96
103
|
}
|
|
97
|
-
// Chat scroll
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
104
|
+
// Chat scroll — scroll.md §5 anchor model adapted to Ink.
|
|
105
|
+
//
|
|
106
|
+
// Position is an Anchor ({itemId, lineInItem}), never a raw row index (I4),
|
|
107
|
+
// resolved per frame against measured *display lines* (I3, see measure.ts).
|
|
108
|
+
// While anchored to 'bottom', new output follows; scrolling up pins to an
|
|
109
|
+
// item and freezes, accumulating newSinceUnstick lines for the `↓ N new` pill.
|
|
110
|
+
//
|
|
111
|
+
// Deviation from §7.1: Ink cannot overlay rows, so the badge renders as a
|
|
112
|
+
// one-line pill above the input instead of overwriting the last viewport row.
|
|
102
113
|
function useChatScroll(opts) {
|
|
103
|
-
const {
|
|
104
|
-
const [
|
|
105
|
-
const
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
//
|
|
121
|
-
const
|
|
122
|
-
const
|
|
123
|
-
const firstEffectRef = useRef(true);
|
|
114
|
+
const { keys, heights, viewportH, width } = opts;
|
|
115
|
+
const [state, setState] = useState(initialScroll);
|
|
116
|
+
const index = useMemo(() => buildIndex(heights), [heights]);
|
|
117
|
+
const boundaries = useMemo(() => index.offsets.filter((_, i) => heights[i] > 0), [index, heights]);
|
|
118
|
+
const ctx = {
|
|
119
|
+
count: keys.length,
|
|
120
|
+
offsetOf: (i) => index.offsets[i] ?? 0,
|
|
121
|
+
keyOf: (i) => keys[i] ?? '',
|
|
122
|
+
indexAt: (row) => itemAtRow(index.offsets, row),
|
|
123
|
+
total: index.total,
|
|
124
|
+
viewportH,
|
|
125
|
+
};
|
|
126
|
+
const ctxRef = useRef(ctx);
|
|
127
|
+
ctxRef.current = ctx;
|
|
128
|
+
const boundariesRef = useRef(boundaries);
|
|
129
|
+
boundariesRef.current = boundaries;
|
|
130
|
+
// Auto-follow wiring (§6): measured total deltas → CONTENT_GREW;
|
|
131
|
+
// width change → REFLOW (§12). Initial anchor is 'bottom' → follow-tail.
|
|
132
|
+
const prevTotalRef = useRef(index.total);
|
|
133
|
+
const prevWidthRef = useRef(width);
|
|
124
134
|
useEffect(() => {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if (maxOffset > 0) {
|
|
132
|
-
setScrollOffset(maxOffset);
|
|
133
|
-
}
|
|
135
|
+
const prevTotal = prevTotalRef.current;
|
|
136
|
+
const prevWidth = prevWidthRef.current;
|
|
137
|
+
prevTotalRef.current = index.total;
|
|
138
|
+
prevWidthRef.current = width;
|
|
139
|
+
if (width !== prevWidth) {
|
|
140
|
+
setState((s) => scrollReducer(s, { type: 'REFLOW' }, ctxRef.current));
|
|
134
141
|
return;
|
|
135
142
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const grew = maxOffset - lastMaxOffsetRef.current;
|
|
140
|
-
lastMaxOffsetRef.current = maxOffset;
|
|
141
|
-
if (pinnedRef.current) {
|
|
142
|
-
if (grew > 0)
|
|
143
|
-
setPendingNew((p) => p + grew);
|
|
144
|
-
}
|
|
145
|
-
else {
|
|
146
|
-
// FollowTail: snap to the new bottom.
|
|
147
|
-
setScrollOffset(maxOffset);
|
|
143
|
+
const delta = index.total - prevTotal;
|
|
144
|
+
if (delta !== 0) {
|
|
145
|
+
setState((s) => scrollReducer(s, { type: 'CONTENT_GREW', lines: delta }, ctxRef.current));
|
|
148
146
|
}
|
|
149
|
-
}, [
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
147
|
+
}, [index.total, width]);
|
|
148
|
+
const dispatch = useCallback((a) => {
|
|
149
|
+
setState((s) => scrollReducer(s, a, ctxRef.current));
|
|
150
|
+
}, []);
|
|
151
|
+
const stateRef = useRef(state);
|
|
152
|
+
stateRef.current = state;
|
|
153
|
+
const commands = useMemo(() => ({
|
|
154
|
+
lineUp: () => dispatch({ type: 'BY_LINES', delta: -1 }),
|
|
155
|
+
lineDown: () => dispatch({ type: 'BY_LINES', delta: 1 }),
|
|
153
156
|
pageUp: () => {
|
|
154
|
-
const
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
157
|
+
const c = ctxRef.current;
|
|
158
|
+
const top = resolveTopRow(stateRef.current, c).topRow;
|
|
159
|
+
const prev = [...boundariesRef.current].reverse().find((b) => b < top);
|
|
160
|
+
const next = prev ?? Math.max(0, top - (c.viewportH - 1)); // 1-line overlap
|
|
161
|
+
dispatch({ type: 'BY_LINES', delta: next - top });
|
|
158
162
|
},
|
|
159
163
|
pageDown: () => {
|
|
160
|
-
const
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
+
const c = ctxRef.current;
|
|
165
|
+
const top = resolveTopRow(stateRef.current, c).topRow;
|
|
166
|
+
const nxt = boundariesRef.current.find((b) => b > top);
|
|
167
|
+
const next = nxt ?? Math.min(maxTopFor(c), top + (c.viewportH - 1));
|
|
168
|
+
dispatch({ type: 'BY_LINES', delta: next - top });
|
|
164
169
|
},
|
|
165
|
-
jumpTop: () => {
|
|
166
|
-
jumpBottom: () => {
|
|
170
|
+
jumpTop: () => dispatch({ type: 'TO_TOP' }),
|
|
171
|
+
jumpBottom: () => dispatch({ type: 'TO_BOTTOM' }),
|
|
172
|
+
}), [dispatch]);
|
|
173
|
+
const resolved = resolveTopRow(state, ctx);
|
|
174
|
+
const maxTop = maxTopFor(ctx);
|
|
175
|
+
const pinned = state.anchor.mode === 'pinned' && !resolved.atBottom;
|
|
176
|
+
return {
|
|
177
|
+
topRow: resolved.topRow,
|
|
178
|
+
atBottom: resolved.atBottom,
|
|
179
|
+
maxTop,
|
|
180
|
+
pinned,
|
|
181
|
+
pendingNew: state.newSinceUnstick,
|
|
182
|
+
commands,
|
|
167
183
|
};
|
|
168
|
-
return { scrollOffset, setScrollOffset, pinned, pendingNew, isAtBottom, maxOffset, commands };
|
|
169
184
|
}
|
|
170
185
|
export function App(props) {
|
|
171
186
|
const { stdout } = useStdout();
|
|
@@ -179,27 +194,145 @@ export function App(props) {
|
|
|
179
194
|
const [queuedInputs, setQueuedInputs] = useState([]);
|
|
180
195
|
const [expandedGroups, setExpandedGroups] = useState(new Set());
|
|
181
196
|
const streamingIdRef = useRef(null);
|
|
197
|
+
// Input history for contextual ↑/↓ (scroll.md §8.3, S6)
|
|
198
|
+
const [history, setHistory] = useState([]);
|
|
199
|
+
const [histIdx, setHistIdx] = useState(null);
|
|
200
|
+
const pushHistory = useCallback((v) => {
|
|
201
|
+
setHistory((prev) => (prev[prev.length - 1] === v ? prev : [...prev.slice(-99), v]));
|
|
202
|
+
setHistIdx(null);
|
|
203
|
+
}, []);
|
|
204
|
+
// Live scroll control for external drivers (mouse-wheel tap in repl.ts).
|
|
205
|
+
const scrollCmdsRef = useRef({ line: (_d) => { }, bottom: () => { } });
|
|
182
206
|
const width = stdout?.columns ?? 100;
|
|
183
207
|
const height = stdout?.rows ?? 30;
|
|
184
208
|
const isFullscreen = props.isFullscreen ?? false;
|
|
185
209
|
const grouped = groupTools(transcript);
|
|
186
210
|
const viewportH = Math.max(5, height - 10);
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
211
|
+
// Degraded mode (§12): terminal < 10 rows → transcript hidden, input+status only.
|
|
212
|
+
const tiny = height < 10;
|
|
213
|
+
// --- Measured blocks (§4): one entry per grouped item + tail blocks ------
|
|
214
|
+
// Heights are estimated wrapped display lines; the cache makes streaming
|
|
215
|
+
// O(1) (only the tail sig changes per tick, I6).
|
|
216
|
+
const measureCacheRef = useRef(null);
|
|
217
|
+
if (!measureCacheRef.current)
|
|
218
|
+
measureCacheRef.current = new MeasureCache();
|
|
219
|
+
const cache = measureCacheRef.current;
|
|
220
|
+
const blocks = useMemo(() => {
|
|
221
|
+
const out = [];
|
|
222
|
+
grouped.forEach((entry, gi) => {
|
|
223
|
+
if (entry.verb) {
|
|
224
|
+
const gr = entry;
|
|
225
|
+
out.push({
|
|
226
|
+
key: gr.id,
|
|
227
|
+
desc: { kind: 'group', count: gr.items.length, expanded: expandedGroups.has(gr.id) },
|
|
228
|
+
groupIndex: gi,
|
|
229
|
+
tail: null,
|
|
230
|
+
});
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const it = entry;
|
|
234
|
+
if (it.kind === 'text' && it.role === 'user') {
|
|
235
|
+
out.push({ key: it.id, desc: { kind: 'user', text: it.text }, groupIndex: gi, tail: null });
|
|
236
|
+
}
|
|
237
|
+
else if (it.kind === 'text') {
|
|
238
|
+
out.push({ key: it.id, desc: { kind: 'assistant', text: it.text }, groupIndex: gi, tail: null });
|
|
239
|
+
}
|
|
240
|
+
else if (it.kind === 'error') {
|
|
241
|
+
out.push({ key: it.id, desc: { kind: 'error', message: it.message }, groupIndex: gi, tail: null });
|
|
242
|
+
}
|
|
243
|
+
else if (it.kind === 'policy') {
|
|
244
|
+
out.push({ key: it.id, desc: { kind: 'policy' }, groupIndex: gi, tail: null });
|
|
245
|
+
}
|
|
246
|
+
else if (it.kind === 'file_changed') {
|
|
247
|
+
out.push({ key: it.id, desc: { kind: 'file', path: it.path }, groupIndex: gi, tail: null });
|
|
248
|
+
}
|
|
249
|
+
else if (it.kind === 'diff') {
|
|
250
|
+
out.push({
|
|
251
|
+
key: it.id,
|
|
252
|
+
desc: { kind: 'diff', hunks: it.hunks.map((h) => ({ path: h.path, lines: h.lines.map((l) => l.text) })) },
|
|
253
|
+
groupIndex: gi,
|
|
254
|
+
tail: null,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
if (plan.length > 0) {
|
|
259
|
+
out.push({
|
|
260
|
+
key: 'tail:plan',
|
|
261
|
+
desc: { kind: 'plan', done: plan.filter((p) => p.status === 'done').length, total: plan.length },
|
|
262
|
+
groupIndex: null,
|
|
263
|
+
tail: 'plan',
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
if (status.status === 'running' && !streamingIdRef.current) {
|
|
267
|
+
out.push({ key: 'tail:thinking', desc: { kind: 'thinking' }, groupIndex: null, tail: 'thinking' });
|
|
268
|
+
}
|
|
269
|
+
if (queuedInputs.length > 0) {
|
|
270
|
+
out.push({ key: 'tail:queued', desc: { kind: 'queued', count: queuedInputs.length }, groupIndex: null, tail: 'queued' });
|
|
271
|
+
}
|
|
272
|
+
return out;
|
|
273
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
274
|
+
}, [grouped, plan, status.status, queuedInputs.length, expandedGroups, width, transcript]);
|
|
275
|
+
const blockKeys = useMemo(() => blocks.map((b) => b.key), [blocks]);
|
|
276
|
+
const blockHeights = useMemo(() => blocks.map((b) => cache.heightFor(b.key, b.desc, width)), [blocks, width, cache]);
|
|
277
|
+
const scroll = useChatScroll({ keys: blockKeys, heights: blockHeights, viewportH, width });
|
|
278
|
+
const { topRow, maxTop, pinned, pendingNew, commands } = scroll;
|
|
200
279
|
const trackH = viewportH;
|
|
201
|
-
const thumbPos =
|
|
202
|
-
|
|
280
|
+
const thumbPos = maxTop === 0 ? 0 : Math.round((topRow / maxTop) * (trackH - 1));
|
|
281
|
+
// Slice *display lines* [topRow, topRow+viewportH): intersecting blocks only
|
|
282
|
+
// (§6.1 virtualization — only visible items are composed).
|
|
283
|
+
const endRow = topRow + viewportH;
|
|
284
|
+
let gi0 = grouped.length;
|
|
285
|
+
let gi1 = -1;
|
|
286
|
+
let showPlan = false;
|
|
287
|
+
let showThinking = false;
|
|
288
|
+
let showQueued = false;
|
|
289
|
+
if (!isFullscreen || tiny) {
|
|
290
|
+
gi0 = 0;
|
|
291
|
+
gi1 = grouped.length - 1;
|
|
292
|
+
showPlan = true;
|
|
293
|
+
showThinking = true;
|
|
294
|
+
showQueued = true;
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
let row = 0;
|
|
298
|
+
for (let bi = 0; bi < blocks.length; bi++) {
|
|
299
|
+
const h = blockHeights[bi] ?? 0;
|
|
300
|
+
const bStart = row;
|
|
301
|
+
const bEnd = row + h;
|
|
302
|
+
row = bEnd;
|
|
303
|
+
if (bEnd <= topRow || bStart >= endRow || h === 0)
|
|
304
|
+
continue;
|
|
305
|
+
const b = blocks[bi];
|
|
306
|
+
if (b.groupIndex !== null) {
|
|
307
|
+
gi0 = Math.min(gi0, b.groupIndex);
|
|
308
|
+
gi1 = Math.max(gi1, b.groupIndex);
|
|
309
|
+
}
|
|
310
|
+
else if (b.tail === 'plan')
|
|
311
|
+
showPlan = true;
|
|
312
|
+
else if (b.tail === 'thinking')
|
|
313
|
+
showThinking = true;
|
|
314
|
+
else if (b.tail === 'queued')
|
|
315
|
+
showQueued = true;
|
|
316
|
+
}
|
|
317
|
+
if (gi1 < gi0) {
|
|
318
|
+
gi0 = 0;
|
|
319
|
+
gi1 = -1;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const visibleGrouped = isFullscreen && !tiny ? grouped.slice(gi0, gi1 + 1) : grouped;
|
|
323
|
+
// Publish live scroll control for the mouse-wheel tap (stable callbacks, latest ctx).
|
|
324
|
+
scrollCmdsRef.current = {
|
|
325
|
+
line: (d) => {
|
|
326
|
+
const n = Math.abs(Math.round(d));
|
|
327
|
+
for (let i = 0; i < n; i++) {
|
|
328
|
+
if (d < 0)
|
|
329
|
+
commands.lineUp();
|
|
330
|
+
else
|
|
331
|
+
commands.lineDown();
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
bottom: () => commands.jumpBottom(),
|
|
335
|
+
};
|
|
203
336
|
useEffect(() => bridge.subscribe((p) => setAwaitingApproval(p !== null)), [bridge]);
|
|
204
337
|
useEffect(() => {
|
|
205
338
|
if (queuedInputs.length > 0 && status.status !== 'running' && !awaitingApproval) {
|
|
@@ -236,9 +369,13 @@ export function App(props) {
|
|
|
236
369
|
const updateStatus = useCallback((s) => setStatus((p) => ({ ...p, ...s })), []);
|
|
237
370
|
const updatePlan = useCallback((p) => setPlan(p), []);
|
|
238
371
|
const clearTranscript = useCallback(() => { streamingIdRef.current = null; setTranscript([]); setPlan([]); }, []);
|
|
372
|
+
// Scroll control for external drivers (mouse-wheel tap in repl.ts, §8.4).
|
|
373
|
+
// Stored in refs so the callbacks stay stable while acting on latest state.
|
|
374
|
+
const scrollLines = useCallback((delta) => { scrollCmdsRef.current.line(delta); }, []);
|
|
375
|
+
const scrollToBottom = useCallback(() => { scrollCmdsRef.current.bottom(); }, []);
|
|
239
376
|
const onMountedRef = useRef(props.onMounted);
|
|
240
377
|
useEffect(() => { onMountedRef.current = props.onMounted; }, [props.onMounted]);
|
|
241
|
-
useEffect(() => { onMountedRef.current?.({ append, appendDelta, updateStatus, updatePlan, clearTranscript }); globalThis.__klyroAppAppend = append; globalThis.__klyroAppendDelta = appendDelta; globalThis.__klyroAppStatus = updateStatus; globalThis.__klyroAppPlan = updatePlan; return () => { delete globalThis.__klyroAppAppend; delete globalThis.__klyroAppendDelta; delete globalThis.__klyroAppStatus; delete globalThis.__klyroAppPlan; }; }, [append, appendDelta, updateStatus, updatePlan, clearTranscript]);
|
|
378
|
+
useEffect(() => { onMountedRef.current?.({ append, appendDelta, updateStatus, updatePlan, clearTranscript, scrollLines, scrollToBottom }); globalThis.__klyroAppAppend = append; globalThis.__klyroAppendDelta = appendDelta; globalThis.__klyroAppStatus = updateStatus; globalThis.__klyroAppPlan = updatePlan; return () => { delete globalThis.__klyroAppAppend; delete globalThis.__klyroAppendDelta; delete globalThis.__klyroAppStatus; delete globalThis.__klyroAppPlan; }; }, [append, appendDelta, updateStatus, updatePlan, clearTranscript, scrollLines, scrollToBottom]);
|
|
242
379
|
const toggleGroup = (id) => setExpandedGroups((prev) => { const n = new Set(prev); if (n.has(id))
|
|
243
380
|
n.delete(id);
|
|
244
381
|
else
|
|
@@ -259,7 +396,7 @@ export function App(props) {
|
|
|
259
396
|
return;
|
|
260
397
|
}
|
|
261
398
|
// Scroll keys (work in any mode, including while running).
|
|
262
|
-
if (isFullscreen &&
|
|
399
|
+
if (isFullscreen && maxTop > 0) {
|
|
263
400
|
if (key.home) {
|
|
264
401
|
commands.jumpTop();
|
|
265
402
|
return;
|
|
@@ -268,11 +405,15 @@ export function App(props) {
|
|
|
268
405
|
commands.jumpBottom();
|
|
269
406
|
return;
|
|
270
407
|
}
|
|
271
|
-
if (key.
|
|
408
|
+
if (key.ctrl && inputStr === 'g') {
|
|
409
|
+
commands.jumpBottom();
|
|
410
|
+
return;
|
|
411
|
+
} // Ctrl+G → bottom (§8.4)
|
|
412
|
+
if (key.pageUp || (key.ctrl && inputStr === 'u') || (key.ctrl && inputStr === 'b')) {
|
|
272
413
|
commands.pageUp();
|
|
273
414
|
return;
|
|
274
415
|
}
|
|
275
|
-
if (key.pageDown || (key.ctrl && inputStr === 'd')) {
|
|
416
|
+
if (key.pageDown || (key.ctrl && inputStr === 'd') || (key.ctrl && inputStr === 'f')) {
|
|
276
417
|
commands.pageDown();
|
|
277
418
|
return;
|
|
278
419
|
}
|
|
@@ -299,14 +440,19 @@ export function App(props) {
|
|
|
299
440
|
void props.onSlash({ kind: 'quit' });
|
|
300
441
|
return;
|
|
301
442
|
}
|
|
443
|
+
// Enter on empty input dismisses the badge (jump to bottom, §7.2)
|
|
302
444
|
if (key.return) {
|
|
303
445
|
const v = input.trim();
|
|
304
|
-
if (!v)
|
|
446
|
+
if (!v) {
|
|
447
|
+
if (pinned)
|
|
448
|
+
commands.jumpBottom();
|
|
305
449
|
return;
|
|
450
|
+
}
|
|
306
451
|
if (queuedInputs.length >= 3)
|
|
307
452
|
return;
|
|
308
453
|
setQueuedInputs((prev) => [...prev, v]);
|
|
309
454
|
setInput('');
|
|
455
|
+
pushHistory(v);
|
|
310
456
|
return;
|
|
311
457
|
}
|
|
312
458
|
if (key.backspace || key.delete) {
|
|
@@ -317,11 +463,53 @@ export function App(props) {
|
|
|
317
463
|
setInput((v) => v + inputStr.replace(/\r\n/g, '\n').replace(/\r/g, '\n'));
|
|
318
464
|
return;
|
|
319
465
|
}
|
|
466
|
+
// Contextual ↑/↓ (§8.3): text in buffer (or browsing) → history;
|
|
467
|
+
// empty buffer → scroll viewport one line.
|
|
468
|
+
if (key.upArrow && !key.shift && !key.ctrl) {
|
|
469
|
+
if (input.trim() !== '' || histIdx !== null) {
|
|
470
|
+
if (history.length > 0) {
|
|
471
|
+
const next = histIdx === null ? history.length - 1 : Math.max(0, histIdx - 1);
|
|
472
|
+
setHistIdx(next);
|
|
473
|
+
setInput(history[next] ?? '');
|
|
474
|
+
}
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
if (isFullscreen && maxTop > 0) {
|
|
478
|
+
commands.lineUp();
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
if (key.downArrow && !key.shift && !key.ctrl) {
|
|
483
|
+
if (histIdx !== null) {
|
|
484
|
+
const next = histIdx + 1;
|
|
485
|
+
if (next >= history.length) {
|
|
486
|
+
setHistIdx(null);
|
|
487
|
+
setInput('');
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
setHistIdx(next);
|
|
491
|
+
setInput(history[next] ?? '');
|
|
492
|
+
}
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
if (input.trim() !== '')
|
|
496
|
+
return; // single line with text, nothing newer
|
|
497
|
+
if (isFullscreen && maxTop > 0) {
|
|
498
|
+
commands.lineDown();
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
// Enter on empty input dismisses the badge (jump to bottom, §7.2)
|
|
320
503
|
if (key.return) {
|
|
321
504
|
const v = input.trim();
|
|
322
|
-
if (!v)
|
|
505
|
+
if (!v) {
|
|
506
|
+
if (pinned)
|
|
507
|
+
commands.jumpBottom();
|
|
323
508
|
return;
|
|
509
|
+
}
|
|
324
510
|
setInput('');
|
|
511
|
+
setHistIdx(null);
|
|
512
|
+
pushHistory(v);
|
|
325
513
|
setTranscript((prev) => [...prev, { id: nextId('user'), kind: 'text', text: v, role: 'user' }]);
|
|
326
514
|
streamingIdRef.current = null;
|
|
327
515
|
const cmd = parseSlash(v);
|
|
@@ -333,10 +521,13 @@ export function App(props) {
|
|
|
333
521
|
}
|
|
334
522
|
if (key.backspace || key.delete) {
|
|
335
523
|
setInput((v) => v.slice(0, -1));
|
|
524
|
+
setHistIdx(null);
|
|
336
525
|
return;
|
|
337
526
|
}
|
|
338
|
-
if (!key.ctrl && !key.meta)
|
|
527
|
+
if (!key.ctrl && !key.meta) {
|
|
339
528
|
setInput((v) => v + inputStr);
|
|
529
|
+
setHistIdx(null);
|
|
530
|
+
}
|
|
340
531
|
});
|
|
341
532
|
const ver = props.version ?? '0.1.27';
|
|
342
533
|
const rule = g('rule').repeat(Math.max(10, width - 2));
|
|
@@ -344,8 +535,8 @@ export function App(props) {
|
|
|
344
535
|
const totalTokens = status.usageInput + status.usageOutput;
|
|
345
536
|
const ctxPct = totalTokens > 0 ? Math.round((totalTokens / 120_000) * 100) : 0;
|
|
346
537
|
const baseHints = status.status === 'running' ? 'ctrl+c to stop · enter to queue · ctrl+o expand' : transcript.length === 0 ? 'shift+tab to cycle · ↑/↓ for history · / for commands' : 'enter to send · shift+enter newline · @ to attach';
|
|
347
|
-
const hints =
|
|
348
|
-
return (_jsxs(Box, { flexDirection: "column", width: width, height: isFullscreen ? height - 1 : undefined, children: [_jsx(Header, { cwd: props.cwd, model: status.model, version: ver, width: width }), _jsxs(Box, { flexDirection: "row", flexGrow: isFullscreen ? 1 : 0, overflow: isFullscreen ? 'hidden' : undefined, children: [_jsxs(Box, { flexDirection: "column", flexGrow: 1, overflow: isFullscreen ? 'hidden' : undefined, paddingX: 0, children: [grouped.length === 0 ? (_jsx(Text, { color: tokens.colors.dim, children: "Message Klyro..." })) : visibleGrouped.map((item) => {
|
|
538
|
+
const hints = maxTop > 0 && isFullscreen ? `${baseHints} · PgUp/Dn scroll` : baseHints;
|
|
539
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, height: isFullscreen ? height - 1 : undefined, children: [_jsx(Header, { cwd: props.cwd, model: status.model, version: ver, width: width }), _jsxs(Box, { flexDirection: "row", flexGrow: isFullscreen ? 1 : 0, overflow: isFullscreen ? 'hidden' : undefined, children: [_jsxs(Box, { flexDirection: "column", flexGrow: 1, overflow: isFullscreen ? 'hidden' : undefined, paddingX: 0, children: [tiny ? (_jsxs(Text, { color: tokens.colors.warn, children: ["\u26A0 terminal too small (", width, "x", height, ") \u2014 transcript hidden"] })) : null, !tiny && grouped.length === 0 ? (_jsx(Text, { color: tokens.colors.dim, children: "Message Klyro..." })) : !tiny ? visibleGrouped.map((item) => {
|
|
349
540
|
if (item.verb) {
|
|
350
541
|
const gr = item;
|
|
351
542
|
const isExpanded = expandedGroups.has(gr.id);
|
|
@@ -381,26 +572,26 @@ export function App(props) {
|
|
|
381
572
|
const right = gr.status === 'running' ? `${(elapsed / 1000).toFixed(1)}s` : gr.status === 'error' ? g('failure') : `${gr.totalMs}ms`;
|
|
382
573
|
const marker = isExpanded ? g('expanded') : g('collapsed');
|
|
383
574
|
const markerColor = gr.status === 'error' ? tokens.colors.err : gr.status === 'running' ? tokens.colors.warn : tokens.colors.ok;
|
|
384
|
-
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: markerColor, children: [marker, " ", verbLine] }), _jsxs(Text, { color: tokens.colors.dim, children: [" ", right] })] }), isExpanded ? gr.items.map((it) => {
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
575
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: markerColor, children: [marker, " ", verbLine] }), _jsxs(Text, { color: tokens.colors.dim, children: [" ", right] })] }), isExpanded ? (_jsxs(_Fragment, { children: [gr.items.slice(0, 12).map((it) => {
|
|
576
|
+
let friendly = '';
|
|
577
|
+
try {
|
|
578
|
+
const a = JSON.parse(it.args);
|
|
579
|
+
const p = a.path ?? a.pattern ?? a.command ?? '';
|
|
580
|
+
const short = p ? String(p).split('/').pop()?.slice(0, 40) ?? p : '';
|
|
581
|
+
if (it.name === 'read_file' && short)
|
|
582
|
+
friendly = `${short}`;
|
|
583
|
+
else if (it.name === 'shell_exec' && p)
|
|
584
|
+
friendly = `$ ${String(p).slice(0, 40)}`;
|
|
585
|
+
else if (short)
|
|
586
|
+
friendly = short;
|
|
587
|
+
else
|
|
588
|
+
friendly = it.args.slice(0, 40);
|
|
589
|
+
}
|
|
590
|
+
catch {
|
|
591
|
+
friendly = it.args.slice(0, 40);
|
|
592
|
+
}
|
|
593
|
+
return (_jsxs(Box, { paddingLeft: 4, children: [_jsxs(Text, { color: tokens.colors.guide, children: [g('end'), " "] }), _jsx(Text, { color: tokens.colors.dim, children: friendly })] }, it.id));
|
|
594
|
+
}), gr.items.length > 12 ? (_jsx(Box, { paddingLeft: 4, children: _jsxs(Text, { color: tokens.colors.dim, children: ["\u2026 ", gr.items.length - 12, " more"] }) })) : null] })) : null] }, gr.id));
|
|
404
595
|
}
|
|
405
596
|
const it = item;
|
|
406
597
|
if (it.kind === 'text' && it.role === 'user') {
|
|
@@ -419,5 +610,5 @@ export function App(props) {
|
|
|
419
610
|
if (it.kind === 'diff')
|
|
420
611
|
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1, children: [_jsx(Text, { bold: true, color: tokens.colors.soft, children: it.summary ?? 'Diff' }), it.hunks.map((h, i) => (_jsxs(Box, { flexDirection: "column", marginTop: 0, children: [_jsx(Text, { color: tokens.colors.soft, children: h.path }), h.lines.map((l, j) => (_jsxs(Text, { wrap: "wrap", color: l.kind === 'add' ? tokens.colors.ok : l.kind === 'remove' ? tokens.colors.err : tokens.colors.dim, children: [l.kind === 'add' ? '+ ' : l.kind === 'remove' ? '- ' : ' ', l.text] }, j)))] }, i)))] }, it.id));
|
|
421
612
|
return null;
|
|
422
|
-
}), status.status === 'running' && !streamingIdRef.current ? (_jsxs(Box, { paddingLeft: 2, marginBottom: 1, children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsx(Text, { color: tokens.colors.dim, children: "Thinking..." }), _jsxs(Text, { color: tokens.colors.dim, children: [" ", (elapsed / 1000).toFixed(1), "s"] })] })) : null, plan.length > 0 ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginTop: 0, marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { bold: true, children: [g('todoPlan'), " Plan ", plan.filter((p) => p.status === 'done').length, "/", plan.length] })] }), plan.slice(0, 8).map((p) => (_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: p.status === 'done' ? tokens.colors.ok : p.status === 'in_progress' ? tokens.colors.accent : tokens.colors.dim, children: [p.status === 'done' ? g('todoDone') : p.status === 'in_progress' ? g('todoActive') : g('todoPending'), " ", p.title] })] }, p.id)))] })) : null, queuedInputs.length > 0 ? (_jsx(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1, children: queuedInputs.map((q, i) => (_jsxs(Text, { color: tokens.colors.dim, children: ["queued: ", q.slice(0, 60), i === 0 ? ' esc to drop' : ''] }, i))) })) : null] }), isFullscreen ? (_jsx(Box, { flexDirection: "column", width: 1, marginLeft: 1, children: Array.from({ length: trackH }).map((_, i) => (_jsx(Text, { color: i === thumbPos ? tokens.colors.accent : tokens.colors.guide, children: i === thumbPos ? '●' : '│' }, i))) })) : null] }), pinned && pendingNew > 0 ? (_jsx(Box, { justifyContent: "flex-end", paddingX: 1, marginTop: -1, children: _jsxs(Text, { backgroundColor: tokens.colors.accentSoft, color: tokens.colors.accent, bold: true, children: [' ↓ ', pendingNew
|
|
613
|
+
}) : null, showThinking && status.status === 'running' && !streamingIdRef.current ? (_jsxs(Box, { paddingLeft: 2, marginBottom: 1, children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsx(Text, { color: tokens.colors.dim, children: "Thinking..." }), _jsxs(Text, { color: tokens.colors.dim, children: [" ", (elapsed / 1000).toFixed(1), "s"] })] })) : null, showPlan && plan.length > 0 ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginTop: 0, marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { bold: true, children: [g('todoPlan'), " Plan ", plan.filter((p) => p.status === 'done').length, "/", plan.length] })] }), plan.slice(0, 8).map((p) => (_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: p.status === 'done' ? tokens.colors.ok : p.status === 'in_progress' ? tokens.colors.accent : tokens.colors.dim, children: [p.status === 'done' ? g('todoDone') : p.status === 'in_progress' ? g('todoActive') : g('todoPending'), " ", p.title] })] }, p.id)))] })) : null, showQueued && queuedInputs.length > 0 ? (_jsx(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1, children: queuedInputs.map((q, i) => (_jsxs(Text, { color: tokens.colors.dim, children: ["queued: ", q.slice(0, 60), i === 0 ? ' esc to drop' : ''] }, i))) })) : null] }), isFullscreen ? (_jsx(Box, { flexDirection: "column", width: 1, marginLeft: 1, children: Array.from({ length: trackH }).map((_, i) => (_jsx(Text, { color: i === thumbPos ? tokens.colors.accent : tokens.colors.guide, children: i === thumbPos ? '●' : '│' }, i))) })) : null] }), pinned && pendingNew > 0 ? (_jsx(Box, { justifyContent: "flex-end", paddingX: 1, marginTop: -1, children: _jsxs(Text, { backgroundColor: tokens.colors.accentSoft, color: tokens.colors.accent, bold: true, children: [' ↓ ', pendingNew >= 1000 ? '999+ new' : `${pendingNew} new`, ' '] }) })) : null, slashSuggest.length > 0 ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [slashSuggest.map((s, i) => (_jsxs(Text, { color: i === 0 ? tokens.colors.accent : tokens.colors.dim, children: [i === 0 ? '▸' : ' ', " /", s.name, " \u2014 ", s.hint] }, s.name))), _jsx(Text, { color: tokens.colors.dim, children: " tab to complete" })] })) : null, _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: tokens.colors.guide, children: g('rule').repeat(Math.max(10, width - 2)) }), _jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.accent, bold: true, children: [g('prompt'), " "] }), _jsxs(Text, { wrap: "wrap", children: [input || _jsx(Text, { color: tokens.colors.dim, children: "Message Klyro..." }), "|"] })] }), _jsx(Text, { color: tokens.colors.guide, children: g('rule').repeat(Math.max(10, width - 2)) })] }), _jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { color: tokens.colors.dim, children: [baseHints, maxTop > 0 && isFullscreen ? ' · PgUp/Dn scroll' : ''] }), _jsxs(Text, { color: tokens.colors.dim, children: [cost > 0 ? `$${cost.toFixed(2)} · ` : '', ctxPct > 0 ? `${ctxPct}% ctx · ` : '', status.status === 'running' ? 'auto mode on ●' : ''] })] })] }));
|
|
423
614
|
}
|