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/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 byVerb = new Map();
52
- for (const it of cur) {
53
- const v = verbForTool(it.name);
54
- if (!byVerb.has(v))
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 state: scrollOffset, pinned (user scrolled away from bottom),
98
- // pendingNew (rows arrived while pinned), and a commands bag for key handlers.
99
- // The `tick` prop is a monotonic value that increments on every content mutation,
100
- // including in-place text growth during streaming (appendDelta mutates by index,
101
- // 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.
102
113
  function useChatScroll(opts) {
103
- const { totalRows, viewportH, messageBoundaries, tick } = opts;
104
- const [scrollOffset, setScrollOffset] = useState(0);
105
- const [pinned, setPinned] = useState(false);
106
- const [pendingNew, setPendingNew] = useState(0);
107
- const pinnedRef = useRef(false);
108
- const maxOffset = Math.max(0, totalRows - viewportH);
109
- // 1-line tolerance: maxOffset can shift by 1 during streaming and leave us
110
- // at maxOffset - 1, which would otherwise be "not at bottom". The +1 tolerance
111
- // keeps follow-tail engaged through that off-by-one.
112
- const isAtBottom = scrollOffset + 1 >= maxOffset;
113
- const recomputePinned = useCallback((next) => {
114
- const atBottom = next + 1 >= maxOffset;
115
- pinnedRef.current = !atBottom;
116
- setPinned(!atBottom);
117
- if (atBottom)
118
- setPendingNew(0);
119
- }, [maxOffset]);
120
- // Watch `tick` — fires on every content change (add, remove, in-place delta).
121
- const lastTickRef = useRef(tick);
122
- const lastMaxOffsetRef = useRef(maxOffset);
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
- if (firstEffectRef.current) {
126
- // Initial mount: if there's content, follow the tail (preserves the
127
- // pre-refactor behavior where scrollOffset was 0 only on empty state).
128
- firstEffectRef.current = false;
129
- lastTickRef.current = tick;
130
- lastMaxOffsetRef.current = maxOffset;
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
- if (tick === lastTickRef.current)
137
- return;
138
- lastTickRef.current = tick;
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
- }, [tick, maxOffset]);
150
- const commands = {
151
- lineUp: () => { const next = Math.max(0, scrollOffset - 1); setScrollOffset(next); recomputePinned(next); },
152
- 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 }),
153
156
  pageUp: () => {
154
- const prev = [...messageBoundaries].reverse().find((b) => b < scrollOffset);
155
- const next = prev ?? Math.max(0, scrollOffset - viewportH);
156
- setScrollOffset(next);
157
- 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 });
158
162
  },
159
163
  pageDown: () => {
160
- const nxt = messageBoundaries.find((b) => b > scrollOffset);
161
- const next = nxt ?? Math.min(maxOffset, scrollOffset + viewportH);
162
- setScrollOffset(next);
163
- 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 });
164
169
  },
165
- jumpTop: () => { setScrollOffset(0); recomputePinned(0); },
166
- 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,
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
- const totalRows = grouped.length + (plan.length > 0 ? 1 : 0) + 2;
188
- const messageBoundaries = useMemo(() => grouped.map((_, i) => i), [grouped]);
189
- // Monotonic tick: increments on every render, so the scroll hook fires
190
- // for every content mutation — including in-place text deltas.
191
- const tickRef = useRef(0);
192
- useEffect(() => { tickRef.current += 1; });
193
- const scroll = useChatScroll({
194
- totalRows,
195
- viewportH,
196
- messageBoundaries,
197
- tick: tickRef.current,
198
- });
199
- const { scrollOffset, isAtBottom, maxOffset, pinned, pendingNew, commands } = scroll;
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 = maxOffset === 0 ? 0 : Math.round((scrollOffset / maxOffset) * (trackH - 1));
202
- const visibleGrouped = isFullscreen ? grouped.slice(scrollOffset, scrollOffset + viewportH) : grouped;
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 && maxOffset > 0) {
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.pageUp || (key.ctrl && inputStr === 'u')) {
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 = maxOffset > 0 && isFullscreen ? `${baseHints} · PgUp/Dn scroll` : baseHints;
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
- let friendly = '';
386
- try {
387
- const a = JSON.parse(it.args);
388
- const p = a.path ?? a.pattern ?? a.command ?? '';
389
- const short = p ? String(p).split('/').pop()?.slice(0, 40) ?? p : '';
390
- if (it.name === 'read_file' && short)
391
- friendly = `${short}`;
392
- else if (it.name === 'shell_exec' && p)
393
- friendly = `$ ${String(p).slice(0, 40)}`;
394
- else if (short)
395
- friendly = short;
396
- else
397
- friendly = it.args.slice(0, 40);
398
- }
399
- catch {
400
- friendly = it.args.slice(0, 40);
401
- }
402
- 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));
403
- }) : null] }, gr.id));
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, ' new ', pendingNew === 1 ? 'message' : 'messages', ' '] }) })) : 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, 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 ●' : ''] })] })] }));
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
  }