klyro 0.1.39 → 0.1.41

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.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Klyro TUI G�� opencode-clean G�� no clumsy words, correct wrap, markdown, scroll
3
- * Header 3 rows, guide G�� at col2, G�� Klyro accent, prose wrapped at word boundaries
2
+ * Klyro TUI — opencode-clean — no clumsy words, correct wrap, markdown, scroll
3
+ * Header 3 rows, guide │ at col2, ● Klyro accent, prose wrapped at word boundaries
4
4
  */
5
5
  import React from 'react';
6
6
  import type { StatusSnapshot } from './status.js';
package/dist/tui/app.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  /**
3
- * Klyro TUI G�� opencode-clean G�� no clumsy words, correct wrap, markdown, scroll
4
- * Header 3 rows, guide G�� at col2, G�� Klyro accent, prose wrapped at word boundaries
3
+ * Klyro TUI — opencode-clean — no clumsy words, correct wrap, markdown, scroll
4
+ * Header 3 rows, guide │ at col2, ● Klyro accent, prose wrapped at word boundaries
5
5
  */
6
- import { useState, useEffect, useRef, useCallback } from 'react';
6
+ import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
7
7
  import { Box, Text, useInput, useStdout } from 'ink';
8
8
  import { TuiApprovalBridge } from './approval.js';
9
9
  import { parse as parseSlash } from '../cli/slash/parser.js';
@@ -19,7 +19,7 @@ function Header({ cwd, model, version, width }) {
19
19
  return '';
20
20
  } })();
21
21
  const showLinks = width >= 120;
22
- 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: "G\uFFFD\uFFFD /help /config /clear /exit" }) : null] }), _jsxs(Text, { color: tokens.colors.dim, children: [model, "[200k] -+ API Usage Billing"] }), _jsxs(Text, { color: tokens.colors.dim, children: [cwd, branch ? ` -+ ${branch}` : ''] })] }));
22
+ 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: "\u00E2\u201D\u201A /help /config /clear /exit" }) : null] }), _jsxs(Text, { color: tokens.colors.dim, children: [model, "[200k] \u00C2\u00B7 API Usage Billing"] }), _jsxs(Text, { color: tokens.colors.dim, children: [cwd, branch ? ` · ${branch}` : ''] })] }));
23
23
  }
24
24
  function verbForTool(name) {
25
25
  if (name === 'read_file')
@@ -71,7 +71,7 @@ function groupTools(items) {
71
71
  flush();
72
72
  return out;
73
73
  }
74
- // Simple markdown: **bold** G�� bold, keep lists/tables, wrap at word boundaries
74
+ // Simple markdown: **bold** → bold, keep lists/tables, wrap at word boundaries
75
75
  function MarkdownText({ text, dim, width }) {
76
76
  // Split by **bold** segments
77
77
  const parts = [];
@@ -89,9 +89,82 @@ function MarkdownText({ text, dim, width }) {
89
89
  parts.push(_jsx(Text, { color: dim ? tokens.colors.dim : undefined, wrap: "wrap", children: text.slice(last) }, `t-${idx++}`));
90
90
  if (parts.length === 0)
91
91
  return _jsx(Text, { color: dim ? tokens.colors.dim : undefined, wrap: "wrap", children: text });
92
- // Render as single line with bold segments G�� Ink will wrap the parent Box
92
+ // Render as single line with bold segments — Ink will wrap the parent Box
93
93
  return _jsx(Text, { wrap: "wrap", children: parts });
94
94
  }
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).
100
+ 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);
122
+ 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
+ }
132
+ return;
133
+ }
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);
142
+ }
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); },
151
+ 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);
156
+ },
157
+ pageDown: () => {
158
+ const nxt = messageBoundaries.find((b) => b > scrollOffset);
159
+ const next = nxt ?? Math.min(maxOffset, scrollOffset + viewportH);
160
+ setScrollOffset(next);
161
+ recomputePinned(next);
162
+ },
163
+ jumpTop: () => { setScrollOffset(0); recomputePinned(0); },
164
+ jumpBottom: () => { setScrollOffset(maxOffset); recomputePinned(maxOffset); },
165
+ };
166
+ return { scrollOffset, setScrollOffset, pinned, pendingNew, isAtBottom, maxOffset, commands };
167
+ }
95
168
  export function App(props) {
96
169
  const { stdout } = useStdout();
97
170
  const [transcript, setTranscript] = useState(props.initialTranscript ?? []);
@@ -103,7 +176,6 @@ export function App(props) {
103
176
  const [elapsed, setElapsed] = useState(0);
104
177
  const [queuedInputs, setQueuedInputs] = useState([]);
105
178
  const [expandedGroups, setExpandedGroups] = useState(new Set());
106
- const [scrollOffset, setScrollOffset] = useState(0);
107
179
  const streamingIdRef = useRef(null);
108
180
  const width = stdout?.columns ?? 100;
109
181
  const height = stdout?.rows ?? 30;
@@ -111,8 +183,18 @@ export function App(props) {
111
183
  const grouped = groupTools(transcript);
112
184
  const viewportH = Math.max(5, height - 10);
113
185
  const totalRows = grouped.length + (plan.length > 0 ? 1 : 0) + 2;
114
- const maxOffset = Math.max(0, totalRows - viewportH);
115
- const isAtBottom = scrollOffset >= maxOffset;
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;
116
198
  const trackH = viewportH;
117
199
  const thumbPos = maxOffset === 0 ? 0 : Math.round((scrollOffset / maxOffset) * (trackH - 1));
118
200
  const visibleGrouped = isFullscreen ? grouped.slice(scrollOffset, scrollOffset + viewportH) : grouped;
@@ -132,8 +214,6 @@ export function App(props) {
132
214
  }, [queuedInputs, status.status, awaitingApproval]);
133
215
  useEffect(() => { if (status.status !== 'running')
134
216
  return; const start = Date.now() - elapsed; const t = setInterval(() => setElapsed(Date.now() - start), 1000); return () => clearInterval(t); }, [status.status, elapsed]);
135
- useEffect(() => { if (isAtBottom)
136
- setScrollOffset(maxOffset); }, [transcript.length, plan.length, maxOffset, isAtBottom]);
137
217
  const append = useCallback((item) => { if (item.kind !== 'text' || item.role !== 'assistant')
138
218
  streamingIdRef.current = null; setTranscript((prev) => [...prev, item]); }, []);
139
219
  const appendDelta = useCallback((text) => {
@@ -160,28 +240,37 @@ export function App(props) {
160
240
  n.delete(id);
161
241
  else
162
242
  n.add(id); return n; });
163
- const scrollUp = (n = 3) => setScrollOffset((p) => Math.max(0, p - n));
164
- const scrollDown = (n = 3) => setScrollOffset((p) => Math.min(maxOffset, p + n));
165
243
  useInput((inputStr, key) => {
166
244
  if (key.escape && queuedInputs.length > 0) {
167
245
  setQueuedInputs((prev) => prev.slice(1));
168
246
  return;
169
247
  }
170
- if (key.pageUp || (key.ctrl && inputStr === 'u')) {
171
- scrollUp(5);
172
- return;
173
- }
174
- if (key.pageDown || (key.ctrl && inputStr === 'd')) {
175
- scrollDown(5);
176
- return;
177
- }
178
- if (key.upArrow && (key.shift || key.ctrl)) {
179
- scrollUp(1);
180
- return;
181
- }
182
- if (key.downArrow && (key.shift || key.ctrl)) {
183
- scrollDown(1);
184
- return;
248
+ // Scroll keys (work in any mode, including while running).
249
+ if (isFullscreen && maxOffset > 0) {
250
+ if (key.home) {
251
+ commands.jumpTop();
252
+ return;
253
+ }
254
+ if (key.end) {
255
+ commands.jumpBottom();
256
+ return;
257
+ }
258
+ if (key.pageUp || (key.ctrl && inputStr === 'u')) {
259
+ commands.pageUp();
260
+ return;
261
+ }
262
+ if (key.pageDown || (key.ctrl && inputStr === 'd')) {
263
+ commands.pageDown();
264
+ return;
265
+ }
266
+ if (key.upArrow && (key.shift || key.ctrl)) {
267
+ commands.lineUp();
268
+ return;
269
+ }
270
+ if (key.downArrow && (key.shift || key.ctrl)) {
271
+ commands.lineDown();
272
+ return;
273
+ }
185
274
  }
186
275
  if (awaitingApproval)
187
276
  return;
@@ -241,9 +330,9 @@ export function App(props) {
241
330
  const cost = (status.usageInput / 1000 * 0.003 + status.usageOutput / 1000 * 0.015);
242
331
  const totalTokens = status.usageInput + status.usageOutput;
243
332
  const ctxPct = totalTokens > 0 ? Math.round((totalTokens / 120_000) * 100) : 0;
244
- const baseHints = status.status === 'running' ? 'ctrl+c to stop -+ enter to queue -+ ctrl+o expand' : transcript.length === 0 ? 'shift+tab to cycle -+ G��G�� for history -+ / for commands' : 'enter to send -+ shift+enter newline -+ @ to attach';
245
- const hints = maxOffset > 0 && isFullscreen ? `${baseHints} -+ PgUp/Dn scroll` : baseHints;
246
- 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 KlyroG\u01EA" })) : visibleGrouped.map((item) => {
333
+ 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';
334
+ const hints = maxOffset > 0 && isFullscreen ? `${baseHints} · PgUp/Dn scroll` : baseHints;
335
+ 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\u00E2\u20AC\u00A6" })) : visibleGrouped.map((item) => {
247
336
  if (item.verb) {
248
337
  const gr = item;
249
338
  const isExpanded = expandedGroups.has(gr.id);
@@ -276,8 +365,8 @@ export function App(props) {
276
365
  return `Edited ${gr.items.length} files`;
277
366
  return `${gr.verb} ${gr.items.length} items`;
278
367
  })();
279
- const right = gr.status === 'running' ? `${(elapsed / 1000).toFixed(1)}s` : gr.status === 'error' ? 'G��' : `${gr.totalMs}ms`;
280
- const marker = isExpanded ? 'G�+' : 'G��';
368
+ const right = gr.status === 'running' ? `${(elapsed / 1000).toFixed(1)}s` : gr.status === 'error' ? '✗' : `${gr.totalMs}ms`;
369
+ const marker = isExpanded ? '▼' : '✓';
281
370
  const markerColor = gr.status === 'error' ? tokens.colors.err : gr.status === 'running' ? tokens.colors.warn : tokens.colors.ok;
282
371
  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) => {
283
372
  let friendly = '';
@@ -305,11 +394,11 @@ export function App(props) {
305
394
  return _jsxs(Box, { marginBottom: 1, children: [_jsxs(Text, { color: tokens.colors.accent, bold: true, children: [g('prompt'), " "] }), _jsx(Text, { wrap: "wrap", children: it.text })] }, it.id);
306
395
  }
307
396
  if (it.kind === 'text') {
308
- // prose G�� render markdown, not raw **, with proper wrap and guide
397
+ // prose — render markdown, not raw **, with proper wrap and guide
309
398
  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));
310
399
  }
311
400
  if (it.kind === 'error')
312
- return _jsx(Box, { paddingLeft: 2, marginBottom: 1, children: _jsxs(Text, { color: tokens.colors.err, children: [" ", g('guide'), " G\uFFFD\uFFFD ", it.message] }) }, it.id);
401
+ return _jsx(Box, { paddingLeft: 2, marginBottom: 1, children: _jsxs(Text, { color: tokens.colors.err, children: [" ", g('guide'), " \u00E2\u0153\u2014 ", it.message] }) }, it.id);
313
402
  if (it.kind === 'policy')
314
403
  return null;
315
404
  if (it.kind === 'file_changed')
@@ -317,5 +406,5 @@ export function App(props) {
317
406
  if (it.kind === 'diff')
318
407
  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));
319
408
  return null;
320
- }), 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 ? 'G��' : 'G��' }, i))) })) : 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 KlyroG\u01EA" }), "G\uFFFD\uFFFD"] })] }), _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 G��' : ''] })] })] }));
409
+ }), 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\u00E2\u20AC\u00A6" }), "\u00E2\u2013\u008F"] })] }), _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 ●' : ''] })] })] }));
321
410
  }
@@ -79,4 +79,146 @@ describe('App', () => {
79
79
  const call = onSlash.mock.calls[0]?.[0];
80
80
  expect(call?.kind).toBe('quit');
81
81
  });
82
+ // --- Chat scroll behavior (TUI_DESIGN chat_scroll.md) -----------------
83
+ // Build a 25-item initial transcript. Each item has a unique tag so we can
84
+ // grep `lastFrame()` for it.
85
+ function makeInitialTranscript(n) {
86
+ const out = [];
87
+ for (let i = 0; i < n; i++) {
88
+ out.push({
89
+ id: `seed-${i}`,
90
+ kind: 'text',
91
+ text: `MSG-${i.toString().padStart(2, '0')}-tag`,
92
+ role: 'user',
93
+ });
94
+ }
95
+ return out;
96
+ }
97
+ // ANSI sequences Ink's parse-keypress recognizes.
98
+ const KEY_HOME = '\x1b[H';
99
+ const KEY_END = '\x1b[F';
100
+ const KEY_PGUP = '\x1b[5~';
101
+ const KEY_PGDN = '\x1b[6~';
102
+ const KEY_SHIFT_UP = '\x1b[1;2A';
103
+ const KEY_SHIFT_DOWN = '\x1b[1;2B';
104
+ it('starts at the bottom (follow-tail) when initial content fills the viewport', async () => {
105
+ const { lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
106
+ await new Promise((r) => setTimeout(r, 50));
107
+ const frame = lastFrame() ?? '';
108
+ // The viewport is 20 rows; the last few seeded items (MSG-22..MSG-24) should
109
+ // be in the visible window. The first item (MSG-00) should NOT be visible.
110
+ expect(frame).toMatch(/MSG-24-tag/);
111
+ expect(frame).toMatch(/MSG-23-tag/);
112
+ expect(frame).not.toMatch(/MSG-00-tag/);
113
+ });
114
+ it('Home jumps to the top; End re-engages follow-tail', async () => {
115
+ const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
116
+ await new Promise((r) => setTimeout(r, 50));
117
+ stdin.write(KEY_HOME);
118
+ await new Promise((r) => setTimeout(r, 30));
119
+ const top = lastFrame() ?? '';
120
+ expect(top).toMatch(/MSG-00-tag/);
121
+ expect(top).not.toMatch(/MSG-24-tag/);
122
+ // End re-engages follow-tail.
123
+ stdin.write(KEY_END);
124
+ await new Promise((r) => setTimeout(r, 30));
125
+ const bottom = lastFrame() ?? '';
126
+ expect(bottom).toMatch(/MSG-24-tag/);
127
+ expect(bottom).not.toMatch(/MSG-00-tag/);
128
+ });
129
+ it('PageUp/PageDown snap to message boundaries', async () => {
130
+ const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
131
+ await new Promise((r) => setTimeout(r, 50));
132
+ // Go to top, then PageDown 3 times. Each PageDown should land on a message
133
+ // boundary, so visible window starts at one of the seeded indices.
134
+ stdin.write(KEY_HOME);
135
+ await new Promise((r) => setTimeout(r, 30));
136
+ stdin.write(KEY_PGDN);
137
+ await new Promise((r) => setTimeout(r, 30));
138
+ stdin.write(KEY_PGDN);
139
+ await new Promise((r) => setTimeout(r, 30));
140
+ stdin.write(KEY_PGDN);
141
+ await new Promise((r) => setTimeout(r, 30));
142
+ const frame = lastFrame() ?? '';
143
+ // After 3 PageDowns from top, the earliest visible item should be MSG-03
144
+ // (snap-to-message keeps the boundary on the first visible row). We assert
145
+ // that MSG-03 is visible and MSG-00 is not.
146
+ expect(frame).toMatch(/MSG-03-tag/);
147
+ expect(frame).not.toMatch(/MSG-00-tag/);
148
+ });
149
+ it('pins to top: new content does NOT auto-scroll when user has scrolled up', async () => {
150
+ const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
151
+ await new Promise((r) => setTimeout(r, 100));
152
+ // Pin: scroll up to top.
153
+ stdin.write(KEY_HOME);
154
+ await new Promise((r) => setTimeout(r, 100));
155
+ const before = lastFrame() ?? '';
156
+ expect(before).toMatch(/MSG-00-tag/);
157
+ expect(before).not.toMatch(/MSG-24-tag/);
158
+ // New content arrives while pinned.
159
+ const g = globalThis;
160
+ g.__klyroAppAppend({
161
+ id: 'late-1',
162
+ kind: 'text',
163
+ text: 'LATE-1-tag',
164
+ role: 'assistant',
165
+ });
166
+ g.__klyroAppAppend({
167
+ id: 'late-2',
168
+ kind: 'text',
169
+ text: 'LATE-2-tag',
170
+ role: 'assistant',
171
+ });
172
+ g.__klyroAppAppend({
173
+ id: 'late-3',
174
+ kind: 'text',
175
+ text: 'LATE-3-tag',
176
+ role: 'assistant',
177
+ });
178
+ await new Promise((r) => setTimeout(r, 200));
179
+ const after = lastFrame() ?? '';
180
+ // Still pinned at top: MSG-00 visible, LATE items not in viewport.
181
+ expect(after).toMatch(/MSG-00-tag/);
182
+ expect(after).not.toMatch(/LATE-1-tag/);
183
+ });
184
+ it('pressing End re-engages follow-tail and reveals new content', async () => {
185
+ const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
186
+ await new Promise((r) => setTimeout(r, 100));
187
+ stdin.write(KEY_HOME);
188
+ await new Promise((r) => setTimeout(r, 100));
189
+ const g = globalThis;
190
+ g.__klyroAppAppend({
191
+ id: 'late-1',
192
+ kind: 'text',
193
+ text: 'LATE-1-tag',
194
+ role: 'assistant',
195
+ });
196
+ await new Promise((r) => setTimeout(r, 200));
197
+ expect(lastFrame() ?? '').not.toMatch(/LATE-1-tag/);
198
+ // End re-engages follow-tail and shows the new content.
199
+ stdin.write(KEY_END);
200
+ await new Promise((r) => setTimeout(r, 100));
201
+ const frame = lastFrame() ?? '';
202
+ expect(frame).toMatch(/LATE-1-tag/);
203
+ });
204
+ it('Shift+Up / Shift+Down scroll by one line', async () => {
205
+ const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
206
+ await new Promise((r) => setTimeout(r, 50));
207
+ // Jump to top, then shift+down a few times, then back with shift+up.
208
+ stdin.write(KEY_HOME);
209
+ await new Promise((r) => setTimeout(r, 30));
210
+ stdin.write(KEY_SHIFT_DOWN);
211
+ stdin.write(KEY_SHIFT_DOWN);
212
+ await new Promise((r) => setTimeout(r, 30));
213
+ const frame = lastFrame() ?? '';
214
+ // Shift+Down from scrollOffset=0 moves us down by 2. The visible window
215
+ // is now [2..22). MSG-00 should be off-screen, MSG-02 should be on-screen.
216
+ expect(frame).not.toMatch(/MSG-00-tag/);
217
+ expect(frame).toMatch(/MSG-02-tag/);
218
+ // Shift+Up once: scrollOffset back to 1, MSG-01 visible, MSG-02 still visible.
219
+ stdin.write(KEY_SHIFT_UP);
220
+ await new Promise((r) => setTimeout(r, 30));
221
+ const frame2 = lastFrame() ?? '';
222
+ expect(frame2).toMatch(/MSG-01-tag/);
223
+ });
82
224
  });
@@ -1,33 +1,35 @@
1
1
  /**
2
- * -�2.1 Color tokens + -�2.2 Glyph set G�� TUI_DESIGN.md
3
- * Accent is Orange #E8843C (256:209, 16: yellow bold), one accent G��5%
4
- * No backgrounds except diff viewer. fg.dim G��4.5:1 on near-black.
2
+ * Klyro tokens — White & Orange #FF6B1A light + Dark #E8843C per design.md + TUI_DESIGN.md
3
+ * True 24-bit hex, Ink will render via 256-color fallback. No backgrounds except diff.
5
4
  */
6
5
  export declare const tokens: {
7
6
  readonly colors: {
8
- readonly accent: "#E8843C";
9
- readonly fg: "#FFFFFF";
7
+ readonly bg: "#FFFFFF";
8
+ readonly bgElevated: "#FAF7F2";
9
+ readonly fg: "#1A1A1A";
10
10
  readonly soft: "#F5F5F5";
11
11
  readonly dim: "#9A9A9A";
12
12
  readonly guide: "#3A3A3A";
13
- readonly ok: "#E8843C";
13
+ readonly accent: "#FF6B1A";
14
+ readonly accentSoft: "#FFF1E6";
15
+ readonly ok: "#FF6B1A";
14
16
  readonly err: "#E06C6C";
15
- readonly warn: "#E8843C";
16
- readonly info: "#FFFFFF";
17
+ readonly warn: "#FF6B1A";
18
+ readonly info: "#6FA8DC";
17
19
  readonly diffAddBg: "#12250F";
18
20
  readonly diffDelBg: "#2A1212";
19
21
  };
20
22
  readonly ansi: {
21
23
  readonly accent: "yellowBright";
22
24
  readonly accentBold: "yellowBright";
23
- readonly fg: "whiteBright";
24
- readonly soft: "white";
25
+ readonly fg: "white";
26
+ readonly soft: "whiteBright";
25
27
  readonly dim: "gray";
26
28
  readonly guide: "gray";
27
29
  readonly ok: "yellowBright";
28
30
  readonly err: "red";
29
31
  readonly warn: "yellowBright";
30
- readonly info: "white";
32
+ readonly info: "blue";
31
33
  readonly border: "gray";
32
34
  readonly muted: "gray";
33
35
  readonly success: "yellowBright";
@@ -37,34 +39,30 @@ export declare const tokens: {
37
39
  };
38
40
  export declare const glyphs: {
39
41
  readonly prompt: ">";
40
- readonly agentBullet: "G��";
41
- readonly collapsed: "G�+";
42
- readonly expanded: "G�+";
43
- readonly guide: "G��";
44
- readonly branch: "G��";
45
- readonly end: "G��";
46
- readonly rule: "G��";
47
- readonly treeBranch: "G��G��G��";
48
- readonly treeEnd: "G��G��G��";
49
- readonly success: "G��";
50
- readonly failure: "G��";
42
+ readonly agentBullet: "";
43
+ readonly collapsed: "";
44
+ readonly expanded: "";
45
+ readonly guide: "";
46
+ readonly branch: "";
47
+ readonly end: "";
48
+ readonly rule: "";
49
+ readonly treeBranch: "├──";
50
+ readonly treeEnd: "└──";
51
+ readonly success: "";
52
+ readonly failure: "";
51
53
  readonly warning: "!";
52
- readonly repair: "G�+";
53
- readonly todoPending: "G��";
54
- readonly todoActive: "G��";
55
- readonly todoDone: "G��";
56
- readonly todoPlan: "G��";
57
- readonly modeAccept: "G��";
58
- readonly modePlan: "G��";
59
- readonly modeAuto: "G��";
60
- readonly editsBadge: "G��";
61
- readonly dot: "-+";
62
- readonly ellipsis: "";
63
- readonly meterFilled: "G��";
64
- readonly meterEmpty: "G��";
65
- readonly continuation: "G�";
66
- readonly brand: "G��";
67
- readonly compaction: "G��";
54
+ readonly repair: "";
55
+ readonly todoPending: "";
56
+ readonly todoActive: "";
57
+ readonly todoDone: "";
58
+ readonly todoPlan: "";
59
+ readonly logoBar: "";
60
+ readonly dotFilled: "";
61
+ readonly dotEmpty: "";
62
+ readonly brand: "";
63
+ readonly compaction: "";
64
+ readonly editsBadge: "";
65
+ readonly dot: "·";
68
66
  };
69
67
  export declare const glyphAscii: {
70
68
  readonly prompt: ">";
@@ -85,11 +83,13 @@ export declare const glyphAscii: {
85
83
  readonly todoActive: "[>]";
86
84
  readonly todoDone: "[x]";
87
85
  readonly todoPlan: "#";
86
+ readonly logoBar: "|";
87
+ readonly dotFilled: "*";
88
+ readonly dotEmpty: "o";
88
89
  };
89
90
  export declare function isAsciiMode(): boolean;
90
91
  export declare function g(name: keyof typeof glyphs): string;
91
92
  export declare const spacing: {
92
- readonly maxWidth: 120;
93
- readonly indent: 2;
94
- readonly gap: 1;
93
+ readonly sidebar: 28;
94
+ readonly inspector: 36;
95
95
  };
@@ -1,33 +1,35 @@
1
1
  /**
2
- * -�2.1 Color tokens + -�2.2 Glyph set G�� TUI_DESIGN.md
3
- * Accent is Orange #E8843C (256:209, 16: yellow bold), one accent G��5%
4
- * No backgrounds except diff viewer. fg.dim G��4.5:1 on near-black.
2
+ * Klyro tokens — White & Orange #FF6B1A light + Dark #E8843C per design.md + TUI_DESIGN.md
3
+ * True 24-bit hex, Ink will render via 256-color fallback. No backgrounds except diff.
5
4
  */
6
5
  export const tokens = {
7
6
  colors: {
8
- accent: '#E8843C', // Orange G�� wordmark, prompt >, G��, thumb, selected
9
- fg: '#FFFFFF', // White G�� user input, headings, file names
10
- soft: '#F5F5F5', // Soft white G�� assistant prose
11
- dim: '#9A9A9A', // Dim white G�� hints, durations
12
- guide: '#3A3A3A', // Guide G��
13
- ok: '#E8843C', // Orange for success check (white+orange theme)
7
+ bg: '#FFFFFF',
8
+ bgElevated: '#FAF7F2',
9
+ fg: '#1A1A1A',
10
+ soft: '#F5F5F5',
11
+ dim: '#9A9A9A',
12
+ guide: '#3A3A3A',
13
+ accent: '#FF6B1A',
14
+ accentSoft: '#FFF1E6',
15
+ ok: '#FF6B1A',
14
16
  err: '#E06C6C',
15
- warn: '#E8843C', // Orange for running/spinner
16
- info: '#FFFFFF',
17
+ warn: '#FF6B1A',
18
+ info: '#6FA8DC',
17
19
  diffAddBg: '#12250F',
18
20
  diffDelBg: '#2A1212',
19
21
  },
20
22
  ansi: {
21
- accent: 'yellowBright', // #E8843C G�� vivid orange
23
+ accent: 'yellowBright',
22
24
  accentBold: 'yellowBright',
23
- fg: 'whiteBright', // #FFFFFF G�� pure white
24
- soft: 'white',
25
+ fg: 'white',
26
+ soft: 'whiteBright',
25
27
  dim: 'gray',
26
28
  guide: 'gray',
27
- ok: 'yellowBright', // G�� orange vivid
29
+ ok: 'yellowBright',
28
30
  err: 'red',
29
- warn: 'yellowBright', // spinner orange vivid
30
- info: 'white',
31
+ warn: 'yellowBright',
32
+ info: 'blue',
31
33
  border: 'gray',
32
34
  muted: 'gray',
33
35
  success: 'yellowBright',
@@ -37,35 +39,30 @@ export const tokens = {
37
39
  };
38
40
  export const glyphs = {
39
41
  prompt: '>',
40
- agentBullet: 'G��',
41
- collapsed: 'G�+',
42
- expanded: 'G�+',
43
- guide: 'G��',
44
- branch: 'G��',
45
- end: 'G��',
46
- rule: 'G��',
47
- treeBranch: 'G��G��G��',
48
- treeEnd: 'G��G��G��',
49
- success: 'G��',
50
- failure: 'G��',
42
+ agentBullet: '',
43
+ collapsed: '',
44
+ expanded: '',
45
+ guide: '',
46
+ branch: '',
47
+ end: '',
48
+ rule: '',
49
+ treeBranch: '├──',
50
+ treeEnd: '└──',
51
+ success: '',
52
+ failure: '',
51
53
  warning: '!',
52
- repair: 'G�+',
53
- todoPending: 'G��',
54
- todoActive: 'G��',
55
- todoDone: 'G��',
56
- todoPlan: 'G��',
57
- modeAccept: 'G��',
58
- modePlan: 'G��',
59
- modeAuto: 'G��',
60
- editsBadge: 'G��',
61
- dot: '-+',
62
- ellipsis: '',
63
- meterFilled: 'G��',
64
- meterEmpty: 'G��',
65
- continuation: 'G�',
66
- // compat
67
- brand: 'G��',
68
- compaction: 'G��',
54
+ repair: '',
55
+ todoPending: '',
56
+ todoActive: '',
57
+ todoDone: '',
58
+ todoPlan: '',
59
+ logoBar: '',
60
+ dotFilled: '',
61
+ dotEmpty: '',
62
+ brand: '',
63
+ compaction: '',
64
+ editsBadge: '',
65
+ dot: '·',
69
66
  };
70
67
  export const glyphAscii = {
71
68
  prompt: '>',
@@ -86,16 +83,16 @@ export const glyphAscii = {
86
83
  todoActive: '[>]',
87
84
  todoDone: '[x]',
88
85
  todoPlan: '#',
86
+ logoBar: '|',
87
+ dotFilled: '*',
88
+ dotEmpty: 'o',
89
89
  };
90
90
  export function isAsciiMode() {
91
- return (process.env.TERM === 'dumb' ||
92
- process.env.KLYRO_ASCII === '1' ||
93
- (process.env.LANG !== undefined && !process.env.LANG.toLowerCase().includes('utf-8')) ||
94
- false);
91
+ return process.env.TERM === 'dumb' || process.env.KLYRO_ASCII === '1' || false;
95
92
  }
96
93
  export function g(name) {
97
94
  if (isAsciiMode())
98
95
  return glyphAscii[name] ?? glyphs[name];
99
96
  return glyphs[name];
100
97
  }
101
- export const spacing = { maxWidth: 120, indent: 2, gap: 1 };
98
+ export const spacing = { sidebar: 28, inspector: 36 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klyro",
3
- "version": "0.1.39",
3
+ "version": "0.1.41",
4
4
  "description": "Klyro — autonomous coding harness CLI that streams from any OpenAI-compatible or Anthropic LLM endpoint.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",