klyro 0.1.45 → 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 CHANGED
@@ -85,7 +85,25 @@ export async function startRepl(opts = {}) {
85
85
  const pendingQueue = [];
86
86
  let isMounted = false;
87
87
  let directHooks;
88
+ // Plain-text mirror for exit replay (scroll.md §1.2: session survives in
89
+ // native scrollback after the alt screen is torn down). Cap 300 lines.
90
+ const exitMirror = [];
91
+ function mirrorLine(item) {
92
+ let line = null;
93
+ if (item.kind === 'text')
94
+ line = `${item.role === 'user' ? '> ' : ''}${item.text}`;
95
+ else if (item.kind === 'error')
96
+ line = `[error] ${item.message}`;
97
+ else if (item.kind === 'file_changed')
98
+ line = `[${item.op}] ${item.path}`;
99
+ if (line === null)
100
+ return;
101
+ exitMirror.push(line.slice(0, 2000));
102
+ if (exitMirror.length > 300)
103
+ exitMirror.splice(0, exitMirror.length - 300);
104
+ }
88
105
  function queuedAppend(item) {
106
+ mirrorLine(item);
89
107
  if (isMounted && directHooks)
90
108
  directHooks.append(item);
91
109
  else
@@ -140,6 +158,49 @@ export async function startRepl(opts = {}) {
140
158
  let tuiSessionId;
141
159
  if (isAltScreen)
142
160
  enterAlt();
161
+ // I7 (scroll.md §8.6, S8): while the TUI owns stdout, route console.*
162
+ // to a ring buffer + ~/.klyro/debug.log so stray tool/provider logs
163
+ // can't corrupt the frame. Restored on exit.
164
+ const consoleRing = [];
165
+ const origConsoleFns = {
166
+ log: console.log,
167
+ info: console.info,
168
+ warn: console.warn,
169
+ error: console.error,
170
+ debug: console.debug,
171
+ };
172
+ function patchConsole() {
173
+ if (!isAltScreen)
174
+ return;
175
+ const sink = (...args) => {
176
+ const line = `[${new Date().toISOString()}] ${args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')}`;
177
+ consoleRing.push(line);
178
+ if (consoleRing.length > 200)
179
+ consoleRing.splice(0, consoleRing.length - 200);
180
+ try {
181
+ const fs = require('node:fs');
182
+ const path = require('node:path');
183
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? cwd;
184
+ const dir = path.join(home, '.klyro');
185
+ fs.mkdirSync(dir, { recursive: true });
186
+ fs.appendFileSync(path.join(dir, 'debug.log'), line + '\n');
187
+ }
188
+ catch { /* ignore */ }
189
+ };
190
+ console.log = sink;
191
+ console.info = sink;
192
+ console.warn = sink;
193
+ console.error = sink;
194
+ console.debug = sink;
195
+ }
196
+ function restoreConsole() {
197
+ console.log = origConsoleFns.log;
198
+ console.info = origConsoleFns.info;
199
+ console.warn = origConsoleFns.warn;
200
+ console.error = origConsoleFns.error;
201
+ console.debug = origConsoleFns.debug;
202
+ }
203
+ patchConsole();
143
204
  const EFFORT_STEPS = { low: 10, medium: 30, high: 50, max: 100 };
144
205
  // P1 session/permission state (commands.md Priority 1)
145
206
  let sessionLabel = '';
@@ -278,6 +339,7 @@ export async function startRepl(opts = {}) {
278
339
  leaveAlt();
279
340
  };
280
341
  process.once('SIGINT', sigintHandler);
342
+ process.once('SIGTERM', sigintHandler);
281
343
  async function runWithBridge(text) {
282
344
  if (!model) {
283
345
  queuedAppend({ id: `err-${Date.now()}`, kind: 'error', message: 'no model configured' });
@@ -2092,9 +2154,22 @@ export async function startRepl(opts = {}) {
2092
2154
  // ac.aborted indicates SIGINT; return 130 (128+SIGINT) like shells do.
2093
2155
  return new Promise((resolve) => {
2094
2156
  const onExit = () => {
2095
- if (sigintHandler)
2157
+ if (sigintHandler) {
2096
2158
  process.removeListener('SIGINT', sigintHandler);
2159
+ process.removeListener('SIGTERM', sigintHandler);
2160
+ }
2161
+ restoreConsole();
2097
2162
  leaveAlt();
2163
+ // §1.2 exit behavior: replay a plain-text transcript into the main
2164
+ // buffer so the session survives in native scrollback.
2165
+ if (exitMirror.length > 0) {
2166
+ try {
2167
+ process.stdout.write('\n--- klyro session transcript ---\n');
2168
+ for (const line of exitMirror.slice(-100))
2169
+ process.stdout.write(line + '\n');
2170
+ }
2171
+ catch { /* ignore */ }
2172
+ }
2098
2173
  resolve(ac.signal.aborted ? 130 : 0);
2099
2174
  };
2100
2175
  if (!app) {
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,130 @@ 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
+ }, []);
182
204
  const width = stdout?.columns ?? 100;
183
205
  const height = stdout?.rows ?? 30;
184
206
  const isFullscreen = props.isFullscreen ?? false;
185
207
  const grouped = groupTools(transcript);
186
208
  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;
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;
200
277
  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;
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;
203
321
  useEffect(() => bridge.subscribe((p) => setAwaitingApproval(p !== null)), [bridge]);
204
322
  useEffect(() => {
205
323
  if (queuedInputs.length > 0 && status.status !== 'running' && !awaitingApproval) {
@@ -259,7 +377,7 @@ export function App(props) {
259
377
  return;
260
378
  }
261
379
  // Scroll keys (work in any mode, including while running).
262
- if (isFullscreen && maxOffset > 0) {
380
+ if (isFullscreen && maxTop > 0) {
263
381
  if (key.home) {
264
382
  commands.jumpTop();
265
383
  return;
@@ -268,6 +386,10 @@ export function App(props) {
268
386
  commands.jumpBottom();
269
387
  return;
270
388
  }
389
+ if (key.ctrl && inputStr === 'g') {
390
+ commands.jumpBottom();
391
+ return;
392
+ } // Ctrl+G → bottom (§8.4)
271
393
  if (key.pageUp || (key.ctrl && inputStr === 'u')) {
272
394
  commands.pageUp();
273
395
  return;
@@ -299,14 +421,19 @@ export function App(props) {
299
421
  void props.onSlash({ kind: 'quit' });
300
422
  return;
301
423
  }
424
+ // Enter on empty input dismisses the badge (jump to bottom, §7.2)
302
425
  if (key.return) {
303
426
  const v = input.trim();
304
- if (!v)
427
+ if (!v) {
428
+ if (pinned)
429
+ commands.jumpBottom();
305
430
  return;
431
+ }
306
432
  if (queuedInputs.length >= 3)
307
433
  return;
308
434
  setQueuedInputs((prev) => [...prev, v]);
309
435
  setInput('');
436
+ pushHistory(v);
310
437
  return;
311
438
  }
312
439
  if (key.backspace || key.delete) {
@@ -317,11 +444,53 @@ export function App(props) {
317
444
  setInput((v) => v + inputStr.replace(/\r\n/g, '\n').replace(/\r/g, '\n'));
318
445
  return;
319
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)
320
484
  if (key.return) {
321
485
  const v = input.trim();
322
- if (!v)
486
+ if (!v) {
487
+ if (pinned)
488
+ commands.jumpBottom();
323
489
  return;
490
+ }
324
491
  setInput('');
492
+ setHistIdx(null);
493
+ pushHistory(v);
325
494
  setTranscript((prev) => [...prev, { id: nextId('user'), kind: 'text', text: v, role: 'user' }]);
326
495
  streamingIdRef.current = null;
327
496
  const cmd = parseSlash(v);
@@ -333,10 +502,13 @@ export function App(props) {
333
502
  }
334
503
  if (key.backspace || key.delete) {
335
504
  setInput((v) => v.slice(0, -1));
505
+ setHistIdx(null);
336
506
  return;
337
507
  }
338
- if (!key.ctrl && !key.meta)
508
+ if (!key.ctrl && !key.meta) {
339
509
  setInput((v) => v + inputStr);
510
+ setHistIdx(null);
511
+ }
340
512
  });
341
513
  const ver = props.version ?? '0.1.27';
342
514
  const rule = g('rule').repeat(Math.max(10, width - 2));
@@ -344,8 +516,8 @@ export function App(props) {
344
516
  const totalTokens = status.usageInput + status.usageOutput;
345
517
  const ctxPct = totalTokens > 0 ? Math.round((totalTokens / 120_000) * 100) : 0;
346
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';
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) => {
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) => {
349
521
  if (item.verb) {
350
522
  const gr = item;
351
523
  const isExpanded = expandedGroups.has(gr.id);
@@ -381,26 +553,26 @@ export function App(props) {
381
553
  const right = gr.status === 'running' ? `${(elapsed / 1000).toFixed(1)}s` : gr.status === 'error' ? g('failure') : `${gr.totalMs}ms`;
382
554
  const marker = isExpanded ? g('expanded') : g('collapsed');
383
555
  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));
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));
404
576
  }
405
577
  const it = item;
406
578
  if (it.kind === 'text' && it.role === 'user') {
@@ -419,5 +591,5 @@ export function App(props) {
419
591
  if (it.kind === 'diff')
420
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));
421
593
  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 ●' : ''] })] })] }));
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 ●' : ''] })] })] }));
423
595
  }
@@ -201,6 +201,54 @@ describe('App', () => {
201
201
  const frame = lastFrame() ?? '';
202
202
  expect(frame).toMatch(/LATE-1-tag/);
203
203
  });
204
+ it('↑ recalls the previous prompt (input history, §8.3)', async () => {
205
+ const onPrompt = vi.fn(async () => { });
206
+ const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: onPrompt, onSlash: async () => { } }));
207
+ stdin.write('first recallable prompt');
208
+ await new Promise((r) => setTimeout(r, 20));
209
+ stdin.write('\x0d');
210
+ await new Promise((r) => setTimeout(r, 50));
211
+ expect(onPrompt).toHaveBeenCalledWith('first recallable prompt');
212
+ // Input cleared after submit; ↑ should restore it from history.
213
+ stdin.write('\x1b[A');
214
+ await new Promise((r) => setTimeout(r, 30));
215
+ expect(lastFrame() ?? '').toContain('first recallable prompt');
216
+ });
217
+ it('↑ on empty input scrolls one line instead of history', async () => {
218
+ const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
219
+ await new Promise((r) => setTimeout(r, 50));
220
+ stdin.write(KEY_HOME);
221
+ await new Promise((r) => setTimeout(r, 30));
222
+ // Empty input + plain ↑ → line up (stays near top, MSG-00 visible).
223
+ stdin.write('\x1b[A');
224
+ await new Promise((r) => setTimeout(r, 30));
225
+ expect(lastFrame() ?? '').toMatch(/MSG-00-tag/);
226
+ });
227
+ it('/c shows top-6 suggestions and Tab completes', async () => {
228
+ const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { } }));
229
+ stdin.write('/c');
230
+ await new Promise((r) => setTimeout(r, 30));
231
+ const withSuggest = lastFrame() ?? '';
232
+ expect(withSuggest).toMatch(/\/clear/);
233
+ expect(withSuggest).toMatch(/tab to complete/i);
234
+ stdin.write('\t');
235
+ await new Promise((r) => setTimeout(r, 30));
236
+ expect(lastFrame() ?? '').toContain('/clear ');
237
+ });
238
+ it('Enter on empty input while pinned jumps back to bottom (§7.2)', async () => {
239
+ const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
240
+ await new Promise((r) => setTimeout(r, 100));
241
+ stdin.write(KEY_HOME);
242
+ await new Promise((r) => setTimeout(r, 100));
243
+ const g = globalThis;
244
+ g.__klyroAppAppend({ id: 'late-1', kind: 'text', text: 'LATE-1-tag', role: 'assistant' });
245
+ await new Promise((r) => setTimeout(r, 200));
246
+ expect(lastFrame() ?? '').not.toMatch(/LATE-1-tag/);
247
+ // Empty input + Enter → dismiss badge, follow tail.
248
+ stdin.write('\x0d');
249
+ await new Promise((r) => setTimeout(r, 100));
250
+ expect(lastFrame() ?? '').toMatch(/LATE-1-tag/);
251
+ });
204
252
  it('Shift+Up / Shift+Down scroll by one line', async () => {
205
253
  const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
206
254
  await new Promise((r) => setTimeout(r, 50));
@@ -0,0 +1,85 @@
1
+ /**
2
+ * scroll.md §4 — Measurement layer (item → display lines).
3
+ *
4
+ * Pure functions only: no hooks, no side effects. The App measures each
5
+ * transcript block into wrapped display-line heights, caches by
6
+ * (key, width, content-signature), and builds a cumulative line index so
7
+ * scrolling is O(1) per frame and only the streaming tail is re-measured.
8
+ *
9
+ * Heights mirror the render structure in app.tsx:
10
+ * user text = wrapped lines + 1 (marginBottom)
11
+ * assistant text = 1 (guide+Klyro header) + wrapped + 1
12
+ * group collapsed= 1 + 1 margin; expanded = 1 + min(n,12) + overflow?1 + 1
13
+ * error = wrapped + 1 margin
14
+ * policy = 0 (renders null)
15
+ * file_changed = 1 + 1 margin
16
+ * diff = 1 summary + Σ(1 path + wrapped hunk lines) + 1 margin
17
+ * plan block = 1 + min(8, steps) + 1 margin
18
+ * thinking/queue = content lines + 1 margin
19
+ */
20
+ /** Display width per scroll.md §12: never `.length` (CJK=2, emoji=2, combining=0). */
21
+ export declare function displayWidth(s: string): number;
22
+ /** Wrapped display-line count for already-newline-split text at `width`. */
23
+ export declare function wrapCount(text: string, width: number): number;
24
+ /** Content width inside the transcript column (guide prefix + padding). */
25
+ export declare function contentWidth(termWidth: number): number;
26
+ export type BlockDesc = {
27
+ kind: 'user';
28
+ text: string;
29
+ } | {
30
+ kind: 'assistant';
31
+ text: string;
32
+ } | {
33
+ kind: 'group';
34
+ count: number;
35
+ expanded: boolean;
36
+ } | {
37
+ kind: 'error';
38
+ message: string;
39
+ } | {
40
+ kind: 'policy';
41
+ } | {
42
+ kind: 'file';
43
+ path: string;
44
+ } | {
45
+ kind: 'diff';
46
+ hunks: Array<{
47
+ path: string;
48
+ lines: string[];
49
+ }>;
50
+ } | {
51
+ kind: 'plan';
52
+ done: number;
53
+ total: number;
54
+ } | {
55
+ kind: 'thinking';
56
+ } | {
57
+ kind: 'queued';
58
+ count: number;
59
+ };
60
+ export declare const EXPANDED_DETAIL_CAP = 12;
61
+ export declare function blockHeight(b: BlockDesc, termWidth: number): number;
62
+ /** Content fingerprint: changes whenever the block's rendered lines could change. */
63
+ export declare function blockSig(b: BlockDesc): string;
64
+ /** Cache: key → height. Only the streaming tail changes sig per tick (I6 → O(1)). */
65
+ export declare class MeasureCache {
66
+ private map;
67
+ private maxEntries;
68
+ constructor(maxEntries?: number);
69
+ heightFor(key: string, block: BlockDesc, termWidth: number): number;
70
+ /** Resize (width change) invalidates everything — scroll.md §12. */
71
+ invalidateWidth(): void;
72
+ get size(): number;
73
+ }
74
+ export interface LineIndex {
75
+ offsets: number[];
76
+ total: number;
77
+ }
78
+ /** Cumulative first-row offsets; dirtyFrom allows incremental rebuild (only tail dirtied). */
79
+ export declare function buildIndex(heights: number[], prev?: {
80
+ offsets: number[];
81
+ dirtyFrom: number;
82
+ }): LineIndex;
83
+ /** Binary search: display row → block index (upperBound(offsets, row) - 1). */
84
+ export declare function itemAtRow(offsets: number[], row: number): number;
85
+ export declare function clamp(n: number, lo: number, hi: number): number;
@@ -0,0 +1,186 @@
1
+ /**
2
+ * scroll.md §4 — Measurement layer (item → display lines).
3
+ *
4
+ * Pure functions only: no hooks, no side effects. The App measures each
5
+ * transcript block into wrapped display-line heights, caches by
6
+ * (key, width, content-signature), and builds a cumulative line index so
7
+ * scrolling is O(1) per frame and only the streaming tail is re-measured.
8
+ *
9
+ * Heights mirror the render structure in app.tsx:
10
+ * user text = wrapped lines + 1 (marginBottom)
11
+ * assistant text = 1 (guide+Klyro header) + wrapped + 1
12
+ * group collapsed= 1 + 1 margin; expanded = 1 + min(n,12) + overflow?1 + 1
13
+ * error = wrapped + 1 margin
14
+ * policy = 0 (renders null)
15
+ * file_changed = 1 + 1 margin
16
+ * diff = 1 summary + Σ(1 path + wrapped hunk lines) + 1 margin
17
+ * plan block = 1 + min(8, steps) + 1 margin
18
+ * thinking/queue = content lines + 1 margin
19
+ */
20
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
21
+ function isWide(cp) {
22
+ return ((cp >= 0x1100 && cp <= 0x115f) ||
23
+ (cp >= 0x2e80 && cp <= 0xa4cf) ||
24
+ (cp >= 0xac00 && cp <= 0xd7a3) ||
25
+ (cp >= 0xf900 && cp <= 0xfaff) ||
26
+ (cp >= 0xfe30 && cp <= 0xfe4f) ||
27
+ (cp >= 0xff00 && cp <= 0xff60) ||
28
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
29
+ (cp >= 0x1f300 && cp <= 0x1faff) ||
30
+ (cp >= 0x2600 && cp <= 0x27bf) ||
31
+ (cp >= 0x2b00 && cp <= 0x2bff));
32
+ }
33
+ function isZeroWidth(cp) {
34
+ return ((cp >= 0x0300 && cp <= 0x036f) ||
35
+ (cp >= 0x200b && cp <= 0x200f) ||
36
+ (cp >= 0xfe00 && cp <= 0xfe0f) ||
37
+ cp === 0x00ad);
38
+ }
39
+ /** Display width per scroll.md §12: never `.length` (CJK=2, emoji=2, combining=0). */
40
+ export function displayWidth(s) {
41
+ const clean = s.replace(ANSI_RE, '').replace(/\t/g, ' ');
42
+ let w = 0;
43
+ for (const ch of clean) {
44
+ const cp = ch.codePointAt(0) ?? 0;
45
+ if (isZeroWidth(cp))
46
+ continue;
47
+ w += isWide(cp) ? 2 : 1;
48
+ }
49
+ return w;
50
+ }
51
+ /** Wrapped display-line count for already-newline-split text at `width`. */
52
+ export function wrapCount(text, width) {
53
+ const w = Math.max(1, width);
54
+ let n = 0;
55
+ for (const line of text.split('\n')) {
56
+ const dw = displayWidth(line);
57
+ n += dw === 0 ? 1 : Math.max(1, Math.ceil(dw / w));
58
+ }
59
+ return Math.max(1, n);
60
+ }
61
+ /** Content width inside the transcript column (guide prefix + padding). */
62
+ export function contentWidth(termWidth) {
63
+ return Math.max(20, termWidth - 10);
64
+ }
65
+ export const EXPANDED_DETAIL_CAP = 12; // scroll.md §9.3
66
+ export function blockHeight(b, termWidth) {
67
+ const cw = contentWidth(termWidth);
68
+ switch (b.kind) {
69
+ case 'user':
70
+ return wrapCount(b.text, termWidth) + 1;
71
+ case 'assistant':
72
+ return 1 + wrapCount(b.text, cw) + 1;
73
+ case 'group':
74
+ if (!b.expanded)
75
+ return 1 + 1;
76
+ return 1 + Math.min(b.count, EXPANDED_DETAIL_CAP) + (b.count > EXPANDED_DETAIL_CAP ? 1 : 0) + 1;
77
+ case 'error':
78
+ return wrapCount(b.message, termWidth) + 1;
79
+ case 'policy':
80
+ return 0;
81
+ case 'file':
82
+ return 1 + 1;
83
+ case 'diff': {
84
+ let n = 1; // summary
85
+ for (const h of b.hunks)
86
+ n += 1 + h.lines.reduce((s, l) => s + wrapCount(l, cw), 0);
87
+ return n + 1;
88
+ }
89
+ case 'plan':
90
+ return 1 + Math.min(8, b.total) + 1;
91
+ case 'thinking':
92
+ return 1 + 1;
93
+ case 'queued':
94
+ return b.count + 1;
95
+ }
96
+ }
97
+ /** Content fingerprint: changes whenever the block's rendered lines could change. */
98
+ export function blockSig(b) {
99
+ switch (b.kind) {
100
+ case 'user':
101
+ return `u:${b.text.length}:${b.text.slice(0, 16)}:${b.text.slice(-16)}`;
102
+ case 'assistant':
103
+ return `a:${b.text.length}:${b.text.slice(0, 16)}:${b.text.slice(-16)}`;
104
+ case 'group':
105
+ return `g:${b.count}:${b.expanded ? 1 : 0}`;
106
+ case 'error':
107
+ return `e:${b.message.length}:${b.message.slice(-32)}`;
108
+ case 'policy':
109
+ return 'p';
110
+ case 'file':
111
+ return `f:${b.path}`;
112
+ case 'diff':
113
+ return `d:${b.hunks.length}:${b.hunks.reduce((s, h) => s + h.lines.length, 0)}`;
114
+ case 'plan':
115
+ return `pl:${b.done}/${b.total}`;
116
+ case 'thinking':
117
+ return 't';
118
+ case 'queued':
119
+ return `q:${b.count}`;
120
+ }
121
+ }
122
+ /** Cache: key → height. Only the streaming tail changes sig per tick (I6 → O(1)). */
123
+ export class MeasureCache {
124
+ map = new Map();
125
+ maxEntries;
126
+ constructor(maxEntries = 2000) {
127
+ this.maxEntries = maxEntries;
128
+ }
129
+ heightFor(key, block, termWidth) {
130
+ const sig = blockSig(block);
131
+ const hit = this.map.get(key);
132
+ if (hit && hit.width === termWidth && hit.sig === sig)
133
+ return hit.height;
134
+ const height = blockHeight(block, termWidth);
135
+ if (this.map.size >= this.maxEntries) {
136
+ const oldest = this.map.keys().next();
137
+ if (!oldest.done)
138
+ this.map.delete(oldest.value);
139
+ }
140
+ this.map.set(key, { width: termWidth, sig, height });
141
+ return height;
142
+ }
143
+ /** Resize (width change) invalidates everything — scroll.md §12. */
144
+ invalidateWidth() {
145
+ this.map.clear();
146
+ }
147
+ get size() {
148
+ return this.map.size;
149
+ }
150
+ }
151
+ /** Cumulative first-row offsets; dirtyFrom allows incremental rebuild (only tail dirtied). */
152
+ export function buildIndex(heights, prev) {
153
+ const offsets = prev ? [...prev.offsets] : [];
154
+ const from = prev ? Math.min(prev.dirtyFrom, heights.length) : 0;
155
+ for (let i = Math.max(0, from); i < heights.length; i++) {
156
+ offsets[i] = i === 0 ? 0 : offsets[i - 1] + heights[i - 1];
157
+ }
158
+ offsets.length = heights.length;
159
+ const last = heights.length - 1;
160
+ const total = last < 0 ? 0 : offsets[last] + heights[last];
161
+ return { offsets, total };
162
+ }
163
+ /** Binary search: display row → block index (upperBound(offsets, row) - 1). */
164
+ export function itemAtRow(offsets, row) {
165
+ if (offsets.length === 0)
166
+ return -1;
167
+ if (row <= 0)
168
+ return 0;
169
+ let lo = 0;
170
+ let hi = offsets.length - 1;
171
+ let ans = 0;
172
+ while (lo <= hi) {
173
+ const mid = (lo + hi) >> 1;
174
+ if (offsets[mid] <= row) {
175
+ ans = mid;
176
+ lo = mid + 1;
177
+ }
178
+ else {
179
+ hi = mid - 1;
180
+ }
181
+ }
182
+ return ans;
183
+ }
184
+ export function clamp(n, lo, hi) {
185
+ return Math.max(lo, Math.min(hi, n));
186
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * scroll.md §5 — Anchor-based scroll model (pure, no React).
3
+ *
4
+ * Position is an Anchor ({itemId, lineInItem}), never a raw row index (I4),
5
+ * so resizes, group expand/collapse, and compaction never make the view jump.
6
+ * While anchored to 'bottom', new output follows; the instant the user scrolls
7
+ * up the anchor pins to an item and freezes, accumulating newSinceUnstick
8
+ * display lines for the `↓ N new` badge (§7).
9
+ */
10
+ export type Anchor = {
11
+ mode: 'bottom';
12
+ } | {
13
+ mode: 'pinned';
14
+ itemId: string;
15
+ lineInItem: number;
16
+ };
17
+ export interface ScrollState {
18
+ anchor: Anchor;
19
+ userScrolled: boolean;
20
+ /** display lines appended since the user unstuck (badge count, §7) */
21
+ newSinceUnstick: number;
22
+ }
23
+ export declare const FOLLOW_EPSILON = 1;
24
+ export declare const initialScroll: ScrollState;
25
+ export type ScrollAction = {
26
+ type: 'BY_LINES';
27
+ delta: number;
28
+ } | {
29
+ type: 'BY_PAGE';
30
+ dir: -1 | 1;
31
+ } | {
32
+ type: 'BY_HALF_PAGE';
33
+ dir: -1 | 1;
34
+ } | {
35
+ type: 'TO_TOP';
36
+ } | {
37
+ type: 'TO_BOTTOM';
38
+ } | {
39
+ type: 'CONTENT_GREW';
40
+ lines: number;
41
+ } | {
42
+ type: 'REFLOW';
43
+ };
44
+ /** Index abstraction over measured display lines (§4.3). */
45
+ export interface ScrollCtx {
46
+ /** number of blocks */
47
+ count: number;
48
+ /** display-line offset of block i */
49
+ offsetOf: (i: number) => number;
50
+ /** key of block i (stable across regroups) */
51
+ keyOf: (i: number) => string;
52
+ /** block index containing display row */
53
+ indexAt: (row: number) => number;
54
+ /** total measured display lines */
55
+ total: number;
56
+ viewportH: number;
57
+ }
58
+ export declare function maxTopFor(ctx: ScrollCtx): number;
59
+ export declare function scrollReducer(s: ScrollState, a: ScrollAction, ctx: ScrollCtx): ScrollState;
60
+ export interface Resolved {
61
+ topRow: number;
62
+ atBottom: boolean;
63
+ }
64
+ /** Anchor → topRow, run once per frame after the line index rebuild (§5.3). */
65
+ export declare function resolveTopRow(s: ScrollState, ctx: ScrollCtx): Resolved;
66
+ /** Badge label per §7.2. Empty string = hidden. */
67
+ export declare function badgeLabel(atBottom: boolean, newSinceUnstick: number, idle: boolean): string;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * scroll.md §5 — Anchor-based scroll model (pure, no React).
3
+ *
4
+ * Position is an Anchor ({itemId, lineInItem}), never a raw row index (I4),
5
+ * so resizes, group expand/collapse, and compaction never make the view jump.
6
+ * While anchored to 'bottom', new output follows; the instant the user scrolls
7
+ * up the anchor pins to an item and freezes, accumulating newSinceUnstick
8
+ * display lines for the `↓ N new` badge (§7).
9
+ */
10
+ export const FOLLOW_EPSILON = 1; // within 1 line of bottom counts as "at bottom" (§13)
11
+ export const initialScroll = {
12
+ anchor: { mode: 'bottom' },
13
+ userScrolled: false,
14
+ newSinceUnstick: 0,
15
+ };
16
+ function clampN(n, lo, hi) {
17
+ return Math.max(lo, Math.min(hi, n));
18
+ }
19
+ export function maxTopFor(ctx) {
20
+ return Math.max(0, ctx.total - ctx.viewportH);
21
+ }
22
+ function stickBottom() {
23
+ return { anchor: { mode: 'bottom' }, userScrolled: false, newSinceUnstick: 0 };
24
+ }
25
+ function pinAt(s, ctx, row) {
26
+ const maxTop = maxTopFor(ctx);
27
+ const top = clampN(row, 0, maxTop);
28
+ if (top >= maxTop - FOLLOW_EPSILON)
29
+ return stickBottom();
30
+ if (ctx.count === 0)
31
+ return stickBottom();
32
+ const i = clampN(ctx.indexAt(top), 0, ctx.count - 1);
33
+ return {
34
+ anchor: { mode: 'pinned', itemId: ctx.keyOf(i), lineInItem: top - ctx.offsetOf(i) },
35
+ userScrolled: true,
36
+ newSinceUnstick: s.newSinceUnstick,
37
+ };
38
+ }
39
+ export function scrollReducer(s, a, ctx) {
40
+ const maxTop = maxTopFor(ctx);
41
+ // current resolved top (anchor may predate this frame's measurements)
42
+ const cur = resolveTopRow(s, ctx).topRow;
43
+ switch (a.type) {
44
+ case 'BY_LINES':
45
+ return pinAt(s, ctx, cur + a.delta);
46
+ case 'BY_PAGE':
47
+ return pinAt(s, ctx, cur + a.dir * (ctx.viewportH - 1)); // 1-line overlap
48
+ case 'BY_HALF_PAGE':
49
+ return pinAt(s, ctx, cur + a.dir * Math.floor(ctx.viewportH / 2));
50
+ case 'TO_TOP':
51
+ return pinAt(s, ctx, 0);
52
+ case 'TO_BOTTOM':
53
+ return stickBottom();
54
+ case 'CONTENT_GREW':
55
+ if (a.lines <= 0) {
56
+ // tail shrank/rewrote (§12): keep anchor, recompute, don't count
57
+ return { ...s };
58
+ }
59
+ if (s.anchor.mode === 'bottom') {
60
+ // follow — keep the counter clean; topRow derives from maxTop
61
+ return { ...s, newSinceUnstick: 0 };
62
+ }
63
+ void maxTop;
64
+ return { ...s, newSinceUnstick: s.newSinceUnstick + a.lines };
65
+ case 'REFLOW':
66
+ return { ...s };
67
+ }
68
+ }
69
+ /** Anchor → topRow, run once per frame after the line index rebuild (§5.3). */
70
+ export function resolveTopRow(s, ctx) {
71
+ const maxTop = maxTopFor(ctx);
72
+ if (s.anchor.mode === 'bottom')
73
+ return { topRow: maxTop, atBottom: true };
74
+ let i = -1;
75
+ for (let k = 0; k < ctx.count; k++) {
76
+ if (ctx.keyOf(k) === s.anchor.itemId) {
77
+ i = k;
78
+ break;
79
+ }
80
+ }
81
+ if (i === -1)
82
+ return { topRow: maxTop, atBottom: true }; // pruned → re-stick (§12)
83
+ const raw = ctx.offsetOf(i) + Math.max(0, s.anchor.lineInItem);
84
+ const topRow = clampN(raw, 0, maxTop);
85
+ return { topRow, atBottom: topRow >= maxTop - FOLLOW_EPSILON };
86
+ }
87
+ /** Badge label per §7.2. Empty string = hidden. */
88
+ export function badgeLabel(atBottom, newSinceUnstick, idle) {
89
+ if (atBottom || newSinceUnstick <= 0)
90
+ return '';
91
+ if (idle)
92
+ return '↓ jump to end';
93
+ if (newSinceUnstick >= 1000)
94
+ return '↓ 999+ new';
95
+ return `↓ ${newSinceUnstick} new`;
96
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klyro",
3
- "version": "0.1.45",
3
+ "version": "0.1.46",
4
4
  "description": "Klyro \u2014 autonomous coding harness CLI that streams from any OpenAI-compatible or Anthropic LLM endpoint.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",