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/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 = (() => { try {
15
- const { execSync } = require('node:child_process');
16
- return execSync('git rev-parse --abbrev-ref HEAD', { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
17
- }
18
- catch {
19
- return '';
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 byVerb = new Map();
50
- for (const it of cur) {
51
- const v = verbForTool(it.name);
52
- if (!byVerb.has(v))
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 state: scrollOffset, pinned (user scrolled away from bottom),
96
- // pendingNew (rows arrived while pinned), and a commands bag for key handlers.
97
- // The `tick` prop is a monotonic value that increments on every content mutation,
98
- // including in-place text growth during streaming (appendDelta mutates by index,
99
- // so transcript.length does not change on a delta — the effect must fire anyway).
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 { totalRows, viewportH, messageBoundaries, tick } = opts;
102
- const [scrollOffset, setScrollOffset] = useState(0);
103
- const [pinned, setPinned] = useState(false);
104
- const [pendingNew, setPendingNew] = useState(0);
105
- const pinnedRef = useRef(false);
106
- const maxOffset = Math.max(0, totalRows - viewportH);
107
- // 1-line tolerance: maxOffset can shift by 1 during streaming and leave us
108
- // at maxOffset - 1, which would otherwise be "not at bottom". The +1 tolerance
109
- // keeps follow-tail engaged through that off-by-one.
110
- const isAtBottom = scrollOffset + 1 >= maxOffset;
111
- const recomputePinned = useCallback((next) => {
112
- const atBottom = next + 1 >= maxOffset;
113
- pinnedRef.current = !atBottom;
114
- setPinned(!atBottom);
115
- if (atBottom)
116
- setPendingNew(0);
117
- }, [maxOffset]);
118
- // Watch `tick` — fires on every content change (add, remove, in-place delta).
119
- const lastTickRef = useRef(tick);
120
- const lastMaxOffsetRef = useRef(maxOffset);
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
- if (firstEffectRef.current) {
124
- // Initial mount: if there's content, follow the tail (preserves the
125
- // pre-refactor behavior where scrollOffset was 0 only on empty state).
126
- firstEffectRef.current = false;
127
- lastTickRef.current = tick;
128
- lastMaxOffsetRef.current = maxOffset;
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
- if (tick === lastTickRef.current)
135
- return;
136
- lastTickRef.current = tick;
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
- else {
144
- // FollowTail: snap to the new bottom.
145
- setScrollOffset(maxOffset);
146
- }
147
- }, [tick, maxOffset]);
148
- const commands = {
149
- lineUp: () => { const next = Math.max(0, scrollOffset - 1); setScrollOffset(next); recomputePinned(next); },
150
- lineDown: () => { const next = Math.min(maxOffset, scrollOffset + 1); setScrollOffset(next); recomputePinned(next); },
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 prev = [...messageBoundaries].reverse().find((b) => b < scrollOffset);
153
- const next = prev ?? Math.max(0, scrollOffset - viewportH);
154
- setScrollOffset(next);
155
- recomputePinned(next);
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 nxt = messageBoundaries.find((b) => b > scrollOffset);
159
- const next = nxt ?? Math.min(maxOffset, scrollOffset + viewportH);
160
- setScrollOffset(next);
161
- recomputePinned(next);
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: () => { setScrollOffset(0); recomputePinned(0); },
164
- jumpBottom: () => { setScrollOffset(maxOffset); recomputePinned(maxOffset); },
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
- const totalRows = grouped.length + (plan.length > 0 ? 1 : 0) + 2;
186
- const messageBoundaries = useMemo(() => grouped.map((_, i) => i), [grouped]);
187
- // Monotonic tick: increments on every render, so the scroll hook fires
188
- // for every content mutation — including in-place text deltas.
189
- const tickRef = useRef(0);
190
- useEffect(() => { tickRef.current += 1; });
191
- const scroll = useChatScroll({
192
- totalRows,
193
- viewportH,
194
- messageBoundaries,
195
- tick: tickRef.current,
196
- });
197
- const { scrollOffset, isAtBottom, maxOffset, pinned, pendingNew, commands } = scroll;
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 = maxOffset === 0 ? 0 : Math.round((scrollOffset / maxOffset) * (trackH - 1));
200
- const visibleGrouped = isFullscreen ? grouped.slice(scrollOffset, scrollOffset + viewportH) : grouped;
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 && maxOffset > 0) {
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 · †‘†“ for history · / for commands' : 'enter to send · shift+enter newline · @ to attach';
335
- const hints = maxOffset > 0 && isFullscreen ? `${baseHints} · PgUp/Dn scroll` : baseHints;
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' ? 'œ—' : `${gr.totalMs}ms`;
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
- let friendly = '';
374
- try {
375
- const a = JSON.parse(it.args);
376
- const p = a.path ?? a.pattern ?? a.command ?? '';
377
- const short = p ? String(p).split('/').pop()?.slice(0, 40) ?? p : '';
378
- if (it.name === 'read_file' && short)
379
- friendly = `${short}`;
380
- else if (it.name === 'shell_exec' && p)
381
- friendly = `$ ${String(p).slice(0, 40)}`;
382
- else if (short)
383
- friendly = short;
384
- else
385
- friendly = it.args.slice(0, 40);
386
- }
387
- catch {
388
- friendly = it.args.slice(0, 40);
389
- }
390
- 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));
391
- }) : null] }, gr.id));
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'), " \u0153\u2014 ", it.message] }) }, it.id);
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, ' new ', pendingNew === 1 ? 'message' : 'messages', ' '] }) })) : 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, maxOffset > 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 ●' : ''] })] })] }));
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
  }