klyro 0.1.44 → 0.1.46
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 +1141 -9
- package/dist/cli/slash/parser.d.ts +182 -0
- package/dist/cli/slash/parser.js +209 -1
- package/dist/tui/app.js +314 -130
- package/dist/tui/app.test.js +48 -0
- package/dist/tui/measure.d.ts +85 -0
- package/dist/tui/measure.js +186 -0
- package/dist/tui/scroll-model.d.ts +67 -0
- package/dist/tui/scroll-model.js +96 -0
- package/package.json +1 -1
package/dist/tui/app.js
CHANGED
|
@@ -1,23 +1,27 @@
|
|
|
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
|
|
5
5
|
*/
|
|
6
6
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|
7
7
|
import { Box, Text, useInput, useStdout } from 'ink';
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
8
9
|
import { TuiApprovalBridge } from './approval.js';
|
|
9
|
-
import { parse as parseSlash } from '../cli/slash/parser.js';
|
|
10
|
+
import { parse as parseSlash, suggestCommands } from '../cli/slash/parser.js';
|
|
10
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';
|
|
11
14
|
let _id = 0;
|
|
12
15
|
function nextId(p) { _id++; return `${p}-${_id}`; }
|
|
13
16
|
function Header({ cwd, model, version, width }) {
|
|
14
|
-
const branch = (() => {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
const branch = useMemo(() => {
|
|
18
|
+
try {
|
|
19
|
+
return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return '';
|
|
23
|
+
}
|
|
24
|
+
}, [cwd]);
|
|
21
25
|
const showLinks = width >= 120;
|
|
22
26
|
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { bold: true, color: tokens.colors.accent, children: ["KLYRO v", version] }), showLinks ? _jsx(Text, { color: tokens.colors.dim, children: "\u2502 /help /config /clear /exit" }) : null] }), _jsxs(Text, { color: tokens.colors.dim, children: [model, "[200k] \u00B7 API Usage Billing"] }), _jsxs(Text, { color: tokens.colors.dim, children: [cwd, branch ? ` · ${branch}` : ''] })] }));
|
|
23
27
|
}
|
|
@@ -40,29 +44,34 @@ function verbForTool(name) {
|
|
|
40
44
|
return 'Edited';
|
|
41
45
|
return 'Called';
|
|
42
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
|
+
*/
|
|
43
53
|
function groupTools(items) {
|
|
44
54
|
const out = [];
|
|
45
55
|
let cur = [];
|
|
56
|
+
let curVerb = null;
|
|
46
57
|
const flush = () => {
|
|
47
58
|
if (cur.length === 0)
|
|
48
59
|
return;
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
byVerb.set(v, []);
|
|
54
|
-
byVerb.get(v).push(it);
|
|
55
|
-
}
|
|
56
|
-
for (const [verb, list] of byVerb) {
|
|
57
|
-
const totalMs = list.reduce((s, x) => s + (x.latencyMs ?? 0), 0);
|
|
58
|
-
const status = list.some((x) => x.isError || x.status === 'error') ? 'error' : list.some((x) => x.status === 'running') ? 'running' : 'done';
|
|
59
|
-
out.push({ id: nextId('g'), verb, items: list, totalMs, status });
|
|
60
|
-
}
|
|
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 });
|
|
61
64
|
cur = [];
|
|
65
|
+
curVerb = null;
|
|
62
66
|
};
|
|
63
67
|
for (const it of items) {
|
|
64
|
-
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;
|
|
65
73
|
cur.push(it);
|
|
74
|
+
}
|
|
66
75
|
else {
|
|
67
76
|
flush();
|
|
68
77
|
out.push(it);
|
|
@@ -92,78 +101,86 @@ function MarkdownText({ text, dim, width }) {
|
|
|
92
101
|
// Render as single line with bold segments — Ink will wrap the parent Box
|
|
93
102
|
return _jsx(Text, { wrap: "wrap", children: parts });
|
|
94
103
|
}
|
|
95
|
-
// Chat scroll
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
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.
|
|
100
113
|
function useChatScroll(opts) {
|
|
101
|
-
const {
|
|
102
|
-
const [
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
//
|
|
119
|
-
const
|
|
120
|
-
const
|
|
121
|
-
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);
|
|
122
134
|
useEffect(() => {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (maxOffset > 0) {
|
|
130
|
-
setScrollOffset(maxOffset);
|
|
131
|
-
}
|
|
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));
|
|
132
141
|
return;
|
|
133
142
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const grew = maxOffset - lastMaxOffsetRef.current;
|
|
138
|
-
lastMaxOffsetRef.current = maxOffset;
|
|
139
|
-
if (pinnedRef.current) {
|
|
140
|
-
if (grew > 0)
|
|
141
|
-
setPendingNew((p) => p + grew);
|
|
143
|
+
const delta = index.total - prevTotal;
|
|
144
|
+
if (delta !== 0) {
|
|
145
|
+
setState((s) => scrollReducer(s, { type: 'CONTENT_GREW', lines: delta }, ctxRef.current));
|
|
142
146
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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 }),
|
|
151
156
|
pageUp: () => {
|
|
152
|
-
const
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
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 });
|
|
156
162
|
},
|
|
157
163
|
pageDown: () => {
|
|
158
|
-
const
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
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 });
|
|
162
169
|
},
|
|
163
|
-
jumpTop: () => {
|
|
164
|
-
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,
|
|
165
183
|
};
|
|
166
|
-
return { scrollOffset, setScrollOffset, pinned, pendingNew, isAtBottom, maxOffset, commands };
|
|
167
184
|
}
|
|
168
185
|
export function App(props) {
|
|
169
186
|
const { stdout } = useStdout();
|
|
@@ -177,27 +194,130 @@ export function App(props) {
|
|
|
177
194
|
const [queuedInputs, setQueuedInputs] = useState([]);
|
|
178
195
|
const [expandedGroups, setExpandedGroups] = useState(new Set());
|
|
179
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
|
+
}, []);
|
|
180
204
|
const width = stdout?.columns ?? 100;
|
|
181
205
|
const height = stdout?.rows ?? 30;
|
|
182
206
|
const isFullscreen = props.isFullscreen ?? false;
|
|
183
207
|
const grouped = groupTools(transcript);
|
|
184
208
|
const viewportH = Math.max(5, height - 10);
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
209
|
+
// Degraded mode (§12): terminal < 10 rows → transcript hidden, input+status only.
|
|
210
|
+
const tiny = height < 10;
|
|
211
|
+
// --- Measured blocks (§4): one entry per grouped item + tail blocks ------
|
|
212
|
+
// Heights are estimated wrapped display lines; the cache makes streaming
|
|
213
|
+
// O(1) (only the tail sig changes per tick, I6).
|
|
214
|
+
const measureCacheRef = useRef(null);
|
|
215
|
+
if (!measureCacheRef.current)
|
|
216
|
+
measureCacheRef.current = new MeasureCache();
|
|
217
|
+
const cache = measureCacheRef.current;
|
|
218
|
+
const blocks = useMemo(() => {
|
|
219
|
+
const out = [];
|
|
220
|
+
grouped.forEach((entry, gi) => {
|
|
221
|
+
if (entry.verb) {
|
|
222
|
+
const gr = entry;
|
|
223
|
+
out.push({
|
|
224
|
+
key: gr.id,
|
|
225
|
+
desc: { kind: 'group', count: gr.items.length, expanded: expandedGroups.has(gr.id) },
|
|
226
|
+
groupIndex: gi,
|
|
227
|
+
tail: null,
|
|
228
|
+
});
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const it = entry;
|
|
232
|
+
if (it.kind === 'text' && it.role === 'user') {
|
|
233
|
+
out.push({ key: it.id, desc: { kind: 'user', text: it.text }, groupIndex: gi, tail: null });
|
|
234
|
+
}
|
|
235
|
+
else if (it.kind === 'text') {
|
|
236
|
+
out.push({ key: it.id, desc: { kind: 'assistant', text: it.text }, groupIndex: gi, tail: null });
|
|
237
|
+
}
|
|
238
|
+
else if (it.kind === 'error') {
|
|
239
|
+
out.push({ key: it.id, desc: { kind: 'error', message: it.message }, groupIndex: gi, tail: null });
|
|
240
|
+
}
|
|
241
|
+
else if (it.kind === 'policy') {
|
|
242
|
+
out.push({ key: it.id, desc: { kind: 'policy' }, groupIndex: gi, tail: null });
|
|
243
|
+
}
|
|
244
|
+
else if (it.kind === 'file_changed') {
|
|
245
|
+
out.push({ key: it.id, desc: { kind: 'file', path: it.path }, groupIndex: gi, tail: null });
|
|
246
|
+
}
|
|
247
|
+
else if (it.kind === 'diff') {
|
|
248
|
+
out.push({
|
|
249
|
+
key: it.id,
|
|
250
|
+
desc: { kind: 'diff', hunks: it.hunks.map((h) => ({ path: h.path, lines: h.lines.map((l) => l.text) })) },
|
|
251
|
+
groupIndex: gi,
|
|
252
|
+
tail: null,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
if (plan.length > 0) {
|
|
257
|
+
out.push({
|
|
258
|
+
key: 'tail:plan',
|
|
259
|
+
desc: { kind: 'plan', done: plan.filter((p) => p.status === 'done').length, total: plan.length },
|
|
260
|
+
groupIndex: null,
|
|
261
|
+
tail: 'plan',
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
if (status.status === 'running' && !streamingIdRef.current) {
|
|
265
|
+
out.push({ key: 'tail:thinking', desc: { kind: 'thinking' }, groupIndex: null, tail: 'thinking' });
|
|
266
|
+
}
|
|
267
|
+
if (queuedInputs.length > 0) {
|
|
268
|
+
out.push({ key: 'tail:queued', desc: { kind: 'queued', count: queuedInputs.length }, groupIndex: null, tail: 'queued' });
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
271
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
272
|
+
}, [grouped, plan, status.status, queuedInputs.length, expandedGroups, width, transcript]);
|
|
273
|
+
const blockKeys = useMemo(() => blocks.map((b) => b.key), [blocks]);
|
|
274
|
+
const blockHeights = useMemo(() => blocks.map((b) => cache.heightFor(b.key, b.desc, width)), [blocks, width, cache]);
|
|
275
|
+
const scroll = useChatScroll({ keys: blockKeys, heights: blockHeights, viewportH, width });
|
|
276
|
+
const { topRow, maxTop, pinned, pendingNew, commands } = scroll;
|
|
198
277
|
const trackH = viewportH;
|
|
199
|
-
const thumbPos =
|
|
200
|
-
|
|
278
|
+
const thumbPos = maxTop === 0 ? 0 : Math.round((topRow / maxTop) * (trackH - 1));
|
|
279
|
+
// Slice *display lines* [topRow, topRow+viewportH): intersecting blocks only
|
|
280
|
+
// (§6.1 virtualization — only visible items are composed).
|
|
281
|
+
const endRow = topRow + viewportH;
|
|
282
|
+
let gi0 = grouped.length;
|
|
283
|
+
let gi1 = -1;
|
|
284
|
+
let showPlan = false;
|
|
285
|
+
let showThinking = false;
|
|
286
|
+
let showQueued = false;
|
|
287
|
+
if (!isFullscreen || tiny) {
|
|
288
|
+
gi0 = 0;
|
|
289
|
+
gi1 = grouped.length - 1;
|
|
290
|
+
showPlan = true;
|
|
291
|
+
showThinking = true;
|
|
292
|
+
showQueued = true;
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
let row = 0;
|
|
296
|
+
for (let bi = 0; bi < blocks.length; bi++) {
|
|
297
|
+
const h = blockHeights[bi] ?? 0;
|
|
298
|
+
const bStart = row;
|
|
299
|
+
const bEnd = row + h;
|
|
300
|
+
row = bEnd;
|
|
301
|
+
if (bEnd <= topRow || bStart >= endRow || h === 0)
|
|
302
|
+
continue;
|
|
303
|
+
const b = blocks[bi];
|
|
304
|
+
if (b.groupIndex !== null) {
|
|
305
|
+
gi0 = Math.min(gi0, b.groupIndex);
|
|
306
|
+
gi1 = Math.max(gi1, b.groupIndex);
|
|
307
|
+
}
|
|
308
|
+
else if (b.tail === 'plan')
|
|
309
|
+
showPlan = true;
|
|
310
|
+
else if (b.tail === 'thinking')
|
|
311
|
+
showThinking = true;
|
|
312
|
+
else if (b.tail === 'queued')
|
|
313
|
+
showQueued = true;
|
|
314
|
+
}
|
|
315
|
+
if (gi1 < gi0) {
|
|
316
|
+
gi0 = 0;
|
|
317
|
+
gi1 = -1;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const visibleGrouped = isFullscreen && !tiny ? grouped.slice(gi0, gi1 + 1) : grouped;
|
|
201
321
|
useEffect(() => bridge.subscribe((p) => setAwaitingApproval(p !== null)), [bridge]);
|
|
202
322
|
useEffect(() => {
|
|
203
323
|
if (queuedInputs.length > 0 && status.status !== 'running' && !awaitingApproval) {
|
|
@@ -241,13 +361,23 @@ export function App(props) {
|
|
|
241
361
|
n.delete(id);
|
|
242
362
|
else
|
|
243
363
|
n.add(id); return n; });
|
|
364
|
+
// `/` autocomplete — top 6 prefix matches while completing a command name (no space yet)
|
|
365
|
+
const slashSuggest = input.startsWith('/') && !input.slice(1).includes(' ') && !awaitingApproval
|
|
366
|
+
? suggestCommands(input, 6)
|
|
367
|
+
: [];
|
|
244
368
|
useInput((inputStr, key) => {
|
|
245
369
|
if (key.escape && queuedInputs.length > 0) {
|
|
246
370
|
setQueuedInputs((prev) => prev.slice(1));
|
|
247
371
|
return;
|
|
248
372
|
}
|
|
373
|
+
// Tab completes the top slash suggestion (e.g. `/c` → `/clear `)
|
|
374
|
+
if ((key.tab || inputStr === '\t') && slashSuggest.length > 0) {
|
|
375
|
+
const top = slashSuggest[0];
|
|
376
|
+
setInput(`/${top.name} `);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
249
379
|
// Scroll keys (work in any mode, including while running).
|
|
250
|
-
if (isFullscreen &&
|
|
380
|
+
if (isFullscreen && maxTop > 0) {
|
|
251
381
|
if (key.home) {
|
|
252
382
|
commands.jumpTop();
|
|
253
383
|
return;
|
|
@@ -256,6 +386,10 @@ export function App(props) {
|
|
|
256
386
|
commands.jumpBottom();
|
|
257
387
|
return;
|
|
258
388
|
}
|
|
389
|
+
if (key.ctrl && inputStr === 'g') {
|
|
390
|
+
commands.jumpBottom();
|
|
391
|
+
return;
|
|
392
|
+
} // Ctrl+G → bottom (§8.4)
|
|
259
393
|
if (key.pageUp || (key.ctrl && inputStr === 'u')) {
|
|
260
394
|
commands.pageUp();
|
|
261
395
|
return;
|
|
@@ -287,14 +421,19 @@ export function App(props) {
|
|
|
287
421
|
void props.onSlash({ kind: 'quit' });
|
|
288
422
|
return;
|
|
289
423
|
}
|
|
424
|
+
// Enter on empty input dismisses the badge (jump to bottom, §7.2)
|
|
290
425
|
if (key.return) {
|
|
291
426
|
const v = input.trim();
|
|
292
|
-
if (!v)
|
|
427
|
+
if (!v) {
|
|
428
|
+
if (pinned)
|
|
429
|
+
commands.jumpBottom();
|
|
293
430
|
return;
|
|
431
|
+
}
|
|
294
432
|
if (queuedInputs.length >= 3)
|
|
295
433
|
return;
|
|
296
434
|
setQueuedInputs((prev) => [...prev, v]);
|
|
297
435
|
setInput('');
|
|
436
|
+
pushHistory(v);
|
|
298
437
|
return;
|
|
299
438
|
}
|
|
300
439
|
if (key.backspace || key.delete) {
|
|
@@ -305,11 +444,53 @@ export function App(props) {
|
|
|
305
444
|
setInput((v) => v + inputStr.replace(/\r\n/g, '\n').replace(/\r/g, '\n'));
|
|
306
445
|
return;
|
|
307
446
|
}
|
|
447
|
+
// Contextual ↑/↓ (§8.3): text in buffer (or browsing) → history;
|
|
448
|
+
// empty buffer → scroll viewport one line.
|
|
449
|
+
if (key.upArrow && !key.shift && !key.ctrl) {
|
|
450
|
+
if (input.trim() !== '' || histIdx !== null) {
|
|
451
|
+
if (history.length > 0) {
|
|
452
|
+
const next = histIdx === null ? history.length - 1 : Math.max(0, histIdx - 1);
|
|
453
|
+
setHistIdx(next);
|
|
454
|
+
setInput(history[next] ?? '');
|
|
455
|
+
}
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
if (isFullscreen && maxTop > 0) {
|
|
459
|
+
commands.lineUp();
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (key.downArrow && !key.shift && !key.ctrl) {
|
|
464
|
+
if (histIdx !== null) {
|
|
465
|
+
const next = histIdx + 1;
|
|
466
|
+
if (next >= history.length) {
|
|
467
|
+
setHistIdx(null);
|
|
468
|
+
setInput('');
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
setHistIdx(next);
|
|
472
|
+
setInput(history[next] ?? '');
|
|
473
|
+
}
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (input.trim() !== '')
|
|
477
|
+
return; // single line with text, nothing newer
|
|
478
|
+
if (isFullscreen && maxTop > 0) {
|
|
479
|
+
commands.lineDown();
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
// Enter on empty input dismisses the badge (jump to bottom, §7.2)
|
|
308
484
|
if (key.return) {
|
|
309
485
|
const v = input.trim();
|
|
310
|
-
if (!v)
|
|
486
|
+
if (!v) {
|
|
487
|
+
if (pinned)
|
|
488
|
+
commands.jumpBottom();
|
|
311
489
|
return;
|
|
490
|
+
}
|
|
312
491
|
setInput('');
|
|
492
|
+
setHistIdx(null);
|
|
493
|
+
pushHistory(v);
|
|
313
494
|
setTranscript((prev) => [...prev, { id: nextId('user'), kind: 'text', text: v, role: 'user' }]);
|
|
314
495
|
streamingIdRef.current = null;
|
|
315
496
|
const cmd = parseSlash(v);
|
|
@@ -321,19 +502,22 @@ export function App(props) {
|
|
|
321
502
|
}
|
|
322
503
|
if (key.backspace || key.delete) {
|
|
323
504
|
setInput((v) => v.slice(0, -1));
|
|
505
|
+
setHistIdx(null);
|
|
324
506
|
return;
|
|
325
507
|
}
|
|
326
|
-
if (!key.ctrl && !key.meta)
|
|
508
|
+
if (!key.ctrl && !key.meta) {
|
|
327
509
|
setInput((v) => v + inputStr);
|
|
510
|
+
setHistIdx(null);
|
|
511
|
+
}
|
|
328
512
|
});
|
|
329
513
|
const ver = props.version ?? '0.1.27';
|
|
330
514
|
const rule = g('rule').repeat(Math.max(10, width - 2));
|
|
331
515
|
const cost = (status.usageInput / 1000 * 0.003 + status.usageOutput / 1000 * 0.015);
|
|
332
516
|
const totalTokens = status.usageInput + status.usageOutput;
|
|
333
517
|
const ctxPct = totalTokens > 0 ? Math.round((totalTokens / 120_000) * 100) : 0;
|
|
334
|
-
const baseHints = status.status === 'running' ? 'ctrl+c to stop · enter to queue · ctrl+o expand' : transcript.length === 0 ? 'shift+tab to cycle ·
|
|
335
|
-
const hints =
|
|
336
|
-
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) => {
|
|
518
|
+
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';
|
|
519
|
+
const hints = maxTop > 0 && isFullscreen ? `${baseHints} · PgUp/Dn scroll` : baseHints;
|
|
520
|
+
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) => {
|
|
337
521
|
if (item.verb) {
|
|
338
522
|
const gr = item;
|
|
339
523
|
const isExpanded = expandedGroups.has(gr.id);
|
|
@@ -366,29 +550,29 @@ export function App(props) {
|
|
|
366
550
|
return `Edited ${gr.items.length} files`;
|
|
367
551
|
return `${gr.verb} ${gr.items.length} items`;
|
|
368
552
|
})();
|
|
369
|
-
const right = gr.status === 'running' ? `${(elapsed / 1000).toFixed(1)}s` : gr.status === 'error' ? '
|
|
370
|
-
const marker = isExpanded ? '
|
|
553
|
+
const right = gr.status === 'running' ? `${(elapsed / 1000).toFixed(1)}s` : gr.status === 'error' ? g('failure') : `${gr.totalMs}ms`;
|
|
554
|
+
const marker = isExpanded ? g('expanded') : g('collapsed');
|
|
371
555
|
const markerColor = gr.status === 'error' ? tokens.colors.err : gr.status === 'running' ? tokens.colors.warn : tokens.colors.ok;
|
|
372
|
-
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) => {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
556
|
+
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) => {
|
|
557
|
+
let friendly = '';
|
|
558
|
+
try {
|
|
559
|
+
const a = JSON.parse(it.args);
|
|
560
|
+
const p = a.path ?? a.pattern ?? a.command ?? '';
|
|
561
|
+
const short = p ? String(p).split('/').pop()?.slice(0, 40) ?? p : '';
|
|
562
|
+
if (it.name === 'read_file' && short)
|
|
563
|
+
friendly = `${short}`;
|
|
564
|
+
else if (it.name === 'shell_exec' && p)
|
|
565
|
+
friendly = `$ ${String(p).slice(0, 40)}`;
|
|
566
|
+
else if (short)
|
|
567
|
+
friendly = short;
|
|
568
|
+
else
|
|
569
|
+
friendly = it.args.slice(0, 40);
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
friendly = it.args.slice(0, 40);
|
|
573
|
+
}
|
|
574
|
+
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));
|
|
575
|
+
}), 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));
|
|
392
576
|
}
|
|
393
577
|
const it = item;
|
|
394
578
|
if (it.kind === 'text' && it.role === 'user') {
|
|
@@ -399,7 +583,7 @@ export function App(props) {
|
|
|
399
583
|
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: tokens.colors.accent, children: [g('agentBullet'), " Klyro"] })] }), _jsx(Box, { paddingLeft: 2, flexDirection: "column", children: _jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.dim, children: [" ", g('guide'), " "] }), _jsx(Box, { flexGrow: 1, children: _jsx(MarkdownText, { text: it.text }) })] }) })] }, it.id));
|
|
400
584
|
}
|
|
401
585
|
if (it.kind === 'error')
|
|
402
|
-
return _jsx(Box, { paddingLeft: 2, marginBottom: 1, children: _jsxs(Text, { color: tokens.colors.err, children: [" ", g('guide'), "
|
|
586
|
+
return _jsx(Box, { paddingLeft: 2, marginBottom: 1, children: _jsxs(Text, { color: tokens.colors.err, children: [" ", g('guide'), " ", g('failure'), " ", it.message] }) }, it.id);
|
|
403
587
|
if (it.kind === 'policy')
|
|
404
588
|
return null;
|
|
405
589
|
if (it.kind === 'file_changed')
|
|
@@ -407,5 +591,5 @@ export function App(props) {
|
|
|
407
591
|
if (it.kind === 'diff')
|
|
408
592
|
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));
|
|
409
593
|
return null;
|
|
410
|
-
}), 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
|
|
594
|
+
}) : 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 ●' : ''] })] })] }));
|
|
411
595
|
}
|