klyro 0.1.55 → 0.1.57

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.
@@ -115,6 +115,8 @@ async function* streamAnthropic(req, opts) {
115
115
  const toolBuffers = new Map();
116
116
  // Map content_block index → tool_use id (persists after tool completes to handle late deltas)
117
117
  const indexToToolId = new Map();
118
+ // Active thinking-block index (Anthropic reasoning channel).
119
+ const thinkingState = { idx: null };
118
120
  // message_stop already yields message_end — don't emit a second one at EOF.
119
121
  let sawMessageEnd = false;
120
122
  try {
@@ -153,7 +155,7 @@ async function* streamAnthropic(req, opts) {
153
155
  catch {
154
156
  continue;
155
157
  }
156
- const out = translateSse(e.event, parsed, toolBuffers, indexToToolId);
158
+ const out = translateSse(e.event, parsed, toolBuffers, indexToToolId, thinkingState);
157
159
  for (const ev of out) {
158
160
  if (ev.kind === 'message_end')
159
161
  sawMessageEnd = true;
@@ -173,7 +175,7 @@ async function* streamAnthropic(req, opts) {
173
175
  if (!sawMessageEnd)
174
176
  yield { kind: 'message_end', finishReason: 'stop' };
175
177
  }
176
- function translateSse(event, parsed, toolBuffers, indexToToolId) {
178
+ function translateSse(event, parsed, toolBuffers, indexToToolId, thinking) {
177
179
  const out = [];
178
180
  switch (event) {
179
181
  case 'content_block_start': {
@@ -185,6 +187,9 @@ function translateSse(event, parsed, toolBuffers, indexToToolId) {
185
187
  indexToToolId.set(idx, block.id);
186
188
  out.push({ kind: 'tool_call_start', id: block.id, name: block.name });
187
189
  }
190
+ else if ((block?.type === 'thinking' || block?.type === 'redacted_thinking') && thinking && idx !== undefined) {
191
+ thinking.idx = idx;
192
+ }
188
193
  return out;
189
194
  }
190
195
  case 'content_block_delta': {
@@ -193,6 +198,9 @@ function translateSse(event, parsed, toolBuffers, indexToToolId) {
193
198
  if (delta?.type === 'text_delta' && typeof delta.text === 'string') {
194
199
  out.push({ kind: 'text_delta', text: delta.text });
195
200
  }
201
+ else if (delta?.type === 'thinking_delta' && typeof delta.thinking === 'string' && delta.thinking) {
202
+ out.push({ kind: 'thinking_delta', text: delta.thinking });
203
+ }
196
204
  else if (delta?.type === 'input_json_delta' && typeof delta.partial_json === 'string') {
197
205
  const id = findToolIdByIndex(index, toolBuffers, indexToToolId);
198
206
  if (id) {
@@ -207,6 +215,8 @@ function translateSse(event, parsed, toolBuffers, indexToToolId) {
207
215
  }
208
216
  case 'content_block_stop': {
209
217
  const index = parsed.index;
218
+ if (thinking && index !== undefined && index === thinking.idx)
219
+ thinking.idx = null;
210
220
  const id = findToolIdByIndex(index, toolBuffers, indexToToolId);
211
221
  if (id) {
212
222
  toolBuffers.delete(id);
@@ -15,6 +15,9 @@ import type { Message } from './message.js';
15
15
  export type StreamEvent = {
16
16
  kind: 'text_delta';
17
17
  text: string;
18
+ } | {
19
+ kind: 'thinking_delta';
20
+ text: string;
18
21
  } | {
19
22
  kind: 'message_start';
20
23
  id?: string;
@@ -239,6 +239,14 @@ async function* streamChatCompletions(url, opts, req, fetchImpl) {
239
239
  if (typeof text === 'string' && text) {
240
240
  yield { kind: 'text_delta', text };
241
241
  }
242
+ // Reasoning channel (DeepSeek-R1 / OpenRouter / vLLM et al. send
243
+ // `reasoning_content`; some proxies use `reasoning`). Shown dimmed
244
+ // while working, discarded when the answer completes.
245
+ const thinking = delta?.reasoning_content ??
246
+ delta?.reasoning;
247
+ if (typeof thinking === 'string' && thinking) {
248
+ yield { kind: 'thinking_delta', text: thinking };
249
+ }
242
250
  for (const tc of choice.delta.tool_calls ?? []) {
243
251
  if (tc.id && tc.function?.name) {
244
252
  toolIds.set(tc.index, tc.id);
@@ -106,6 +106,9 @@ export type RuntimeEvent = {
106
106
  } | {
107
107
  kind: 'text_delta';
108
108
  text: string;
109
+ } | {
110
+ kind: 'thinking_delta';
111
+ text: string;
109
112
  } | {
110
113
  kind: 'tool_call_start';
111
114
  id: string;
@@ -236,6 +236,9 @@ export async function run(opts, deps) {
236
236
  };
237
237
  const events = deps.adapter.stream(req);
238
238
  let textBuf = '';
239
+ // Thinking is ephemeral: streamed to the UI live, never stored in the
240
+ // transcript, and cleared when the turn's answer completes.
241
+ let thinkingBuf = '';
239
242
  const pendingToolCalls = new Map();
240
243
  let lastFinishReason;
241
244
  for await (const ev of events) {
@@ -245,6 +248,10 @@ export async function run(opts, deps) {
245
248
  textBuf += ev.text;
246
249
  emit?.({ kind: 'text_delta', text: ev.text });
247
250
  }
251
+ else if (ev.kind === 'thinking_delta') {
252
+ thinkingBuf += ev.text;
253
+ emit?.({ kind: 'thinking_delta', text: ev.text });
254
+ }
248
255
  else if (ev.kind === 'tool_call_start') {
249
256
  pendingToolCalls.set(ev.id, { id: ev.id, name: ev.name, argsJson: '' });
250
257
  emit?.({ kind: 'tool_call_start', id: ev.id, name: ev.name });
package/dist/cli/repl.js CHANGED
@@ -144,6 +144,23 @@ export async function startRepl(opts = {}) {
144
144
  else
145
145
  pendingQueue.push({ kind: 'delta', text });
146
146
  }
147
+ // Ephemeral reasoning display (light-white while working, gone on response).
148
+ function queuedThinking(text) {
149
+ if (!text)
150
+ return;
151
+ if (isMounted && directHooks)
152
+ directHooks.appendThinkingDelta(text);
153
+ else
154
+ pendingQueue.push({ kind: 'thinking', text });
155
+ }
156
+ function clearThinking() {
157
+ for (let i = pendingQueue.length - 1; i >= 0; i--) {
158
+ if (pendingQueue[i]?.kind === 'thinking')
159
+ pendingQueue.splice(i, 1);
160
+ }
161
+ if (isMounted && directHooks)
162
+ directHooks.clearThinking();
163
+ }
147
164
  // Tool results patch the running start-item in place (App.updateTool) so a
148
165
  // group resolves to done/error with its real latency instead of ticking
149
166
  // forever. Falls back to a standalone item if the start item is gone.
@@ -404,10 +421,13 @@ export async function startRepl(opts = {}) {
404
421
  text += ev.text;
405
422
  queuedDelta(ev.text);
406
423
  }
424
+ else if (ev.kind === 'thinking_delta')
425
+ queuedThinking(ev.text);
407
426
  else if (ev.kind === 'error')
408
427
  throw new Error(ev.message);
409
428
  }
410
429
  lastAssistantText = text;
430
+ clearThinking();
411
431
  queuedStatus({ status: 'done' });
412
432
  return text;
413
433
  }
@@ -454,6 +474,8 @@ export async function startRepl(opts = {}) {
454
474
  hooks.appendDelta(ev.text);
455
475
  else if (ev.kind === 'toolupdate')
456
476
  hooks.updateTool(ev.idCall, ev.patch);
477
+ else if (ev.kind === 'thinking')
478
+ hooks.appendThinkingDelta(ev.text);
457
479
  else
458
480
  hooks.append(ev.item);
459
481
  }
@@ -523,10 +545,13 @@ export async function startRepl(opts = {}) {
523
545
  simpleText += ev.text;
524
546
  queuedDelta(ev.text);
525
547
  }
548
+ else if (ev.kind === 'thinking_delta')
549
+ queuedThinking(ev.text);
526
550
  else if (ev.kind === 'error')
527
551
  throw new Error(ev.message);
528
552
  }
529
553
  lastAssistantText = simpleText;
554
+ clearThinking();
530
555
  queuedStatus({ status: 'done' });
531
556
  return;
532
557
  }
@@ -553,11 +578,15 @@ export async function startRepl(opts = {}) {
553
578
  onEvent: (ev) => {
554
579
  if (ev.kind === 'step_start') {
555
580
  queuedStatus({ step: ev.step });
581
+ clearThinking(); // fresh reasoning display per step
556
582
  }
557
583
  else if (ev.kind === 'text_delta') {
558
584
  // single appendDelta path — App merges into one assistant item (Q→A order, no duplication)
559
585
  queuedDelta(ev.text);
560
586
  }
587
+ else if (ev.kind === 'thinking_delta') {
588
+ queuedThinking(ev.text);
589
+ }
561
590
  else if (ev.kind === 'verification_started') {
562
591
  queuedAppend({ id: `vrfy-${Date.now()}`, kind: 'text', text: `[verify] running \`${ev.command}\``, role: 'assistant' });
563
592
  queuedStatus({ status: 'running' });
@@ -630,6 +659,8 @@ export async function startRepl(opts = {}) {
630
659
  }
631
660
  }
632
661
  else if (ev.kind === 'final_text') {
662
+ // Response arrived: thinking display goes away, only the answer stays.
663
+ clearThinking();
633
664
  // streamingId is closed by status change; no extra handling needed
634
665
  }
635
666
  else if (ev.kind === 'usage') {
package/dist/tui/app.d.ts CHANGED
@@ -29,6 +29,8 @@ export interface AppProps {
29
29
  scrollToTop: () => void;
30
30
  transcript: TranscriptScrollHandle;
31
31
  updateTool: (idCall: string, patch: ToolResultPatch) => void;
32
+ appendThinkingDelta: (text: string) => void;
33
+ clearThinking: () => void;
32
34
  }) => void;
33
35
  version?: string;
34
36
  isFullscreen?: boolean;
package/dist/tui/app.js CHANGED
@@ -27,7 +27,7 @@ function Header({ cwd, model, version, width }) {
27
27
  }
28
28
  }, [cwd]);
29
29
  const showLinks = width >= 120;
30
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { bold: true, color: tokens.colors.accent, children: ["KLYRO v", version] }), showLinks ? _jsx(Text, { color: tokens.colors.dim, children: "\u2502 /help /config /clear /exit" }) : null] }), _jsxs(Text, { color: tokens.colors.dim, children: [model, " \u00B7 ", cwd] }), branch ? _jsxs(Text, { color: tokens.colors.dim, children: ["\u2387 ", branch] }) : null] }));
30
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, flexShrink: 0, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { bold: true, color: tokens.colors.accent, children: ["KLYRO v", version] }), showLinks ? _jsx(Text, { color: tokens.colors.dim, children: "\u2502 /help /config /clear /exit" }) : null] }), _jsxs(Text, { color: tokens.colors.dim, children: [model, " \u00B7 ", cwd] }), branch ? _jsxs(Text, { color: tokens.colors.dim, children: ["\u2387 ", branch] }) : null] }));
31
31
  }
32
32
  function verbForTool(name) {
33
33
  if (name === 'read_file')
@@ -259,6 +259,9 @@ export function App(props) {
259
259
  else if (it.kind === 'text') {
260
260
  out.push({ key: it.id, desc: { kind: 'assistant', text: it.text }, groupIndex: gi, tail: null });
261
261
  }
262
+ else if (it.kind === 'thinking') {
263
+ out.push({ key: it.id, desc: { kind: 'reasoning', text: it.text }, groupIndex: gi, tail: null });
264
+ }
262
265
  else if (it.kind === 'error') {
263
266
  out.push({ key: it.id, desc: { kind: 'error', message: it.message }, groupIndex: gi, tail: null });
264
267
  }
@@ -393,8 +396,30 @@ export function App(props) {
393
396
  setTranscript((prev) => [...prev, { id, kind: 'text', text, role: 'assistant' }]);
394
397
  }
395
398
  }, []);
396
- useEffect(() => { if (status.status !== 'running')
397
- streamingIdRef.current = null; }, [status.status]);
399
+ // Ephemeral reasoning display: merges into one transient item (never in
400
+ // context/persistence); removed when the turn's answer completes.
401
+ const thinkingIdRef = useRef(null);
402
+ const appendThinkingDelta = useCallback((text) => {
403
+ if (!text)
404
+ return;
405
+ const tid = thinkingIdRef.current;
406
+ if (tid)
407
+ setTranscript((prev) => { const idx = prev.findIndex((x) => x.id === tid); if (idx === -1)
408
+ return [...prev, { id: tid, kind: 'thinking', text }]; const cur = prev[idx]; const copy = [...prev]; copy[idx] = { ...cur, text: cur.text + text }; return copy; });
409
+ else {
410
+ const id = nextId('thinking');
411
+ thinkingIdRef.current = id;
412
+ setTranscript((prev) => [...prev, { id, kind: 'thinking', text }]);
413
+ }
414
+ }, []);
415
+ const clearThinking = useCallback(() => {
416
+ thinkingIdRef.current = null;
417
+ setTranscript((prev) => (prev.some((x) => x.kind === 'thinking') ? prev.filter((x) => x.kind !== 'thinking') : prev));
418
+ }, []);
419
+ useEffect(() => { if (status.status !== 'running') {
420
+ streamingIdRef.current = null;
421
+ thinkingIdRef.current = null;
422
+ } }, [status.status]);
398
423
  const updateStatus = useCallback((s) => setStatus((p) => ({ ...p, ...s })), []);
399
424
  const updatePlan = useCallback((p) => setPlan(p), []);
400
425
  // Tool results patch the running start-item IN PLACE (no second item, so a
@@ -439,7 +464,7 @@ export function App(props) {
439
464
  }), []);
440
465
  const onMountedRef = useRef(props.onMounted);
441
466
  useEffect(() => { onMountedRef.current = props.onMounted; }, [props.onMounted]);
442
- useEffect(() => { onMountedRef.current?.({ append, appendDelta, updateStatus, updatePlan, clearTranscript, scrollLines, scrollToBottom, scrollHalfPage, scrollToTop, transcript: transcriptHandle, updateTool }); globalThis.__klyroAppAppend = append; globalThis.__klyroAppendDelta = appendDelta; globalThis.__klyroAppStatus = updateStatus; globalThis.__klyroAppPlan = updatePlan; return () => { delete globalThis.__klyroAppAppend; delete globalThis.__klyroAppendDelta; delete globalThis.__klyroAppStatus; delete globalThis.__klyroAppPlan; }; }, [append, appendDelta, updateStatus, updatePlan, clearTranscript, scrollLines, scrollToBottom, scrollHalfPage, scrollToTop, transcriptHandle, updateTool]);
467
+ useEffect(() => { onMountedRef.current?.({ append, appendDelta, updateStatus, updatePlan, clearTranscript, scrollLines, scrollToBottom, scrollHalfPage, scrollToTop, transcript: transcriptHandle, updateTool, appendThinkingDelta, clearThinking }); globalThis.__klyroAppAppend = append; globalThis.__klyroAppendDelta = appendDelta; globalThis.__klyroAppStatus = updateStatus; globalThis.__klyroAppPlan = updatePlan; globalThis.__klyroAppendThinking = appendThinkingDelta; globalThis.__klyroClearThinking = clearThinking; return () => { delete globalThis.__klyroAppAppend; delete globalThis.__klyroAppendDelta; delete globalThis.__klyroAppStatus; delete globalThis.__klyroAppPlan; delete globalThis.__klyroAppendThinking; delete globalThis.__klyroClearThinking; }; }, [append, appendDelta, updateStatus, updatePlan, clearTranscript, scrollLines, scrollToBottom, scrollHalfPage, scrollToTop, transcriptHandle, updateTool, appendThinkingDelta, clearThinking]);
443
468
  const toggleGroup = (id) => setExpandedGroups((prev) => { const n = new Set(prev); if (n.has(id))
444
469
  n.delete(id);
445
470
  else
@@ -714,6 +739,9 @@ export function App(props) {
714
739
  // prose — render markdown, not raw **, with proper wrap and guide
715
740
  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));
716
741
  }
742
+ // Ephemeral reasoning: light-white while working, removed on response.
743
+ if (it.kind === 'thinking')
744
+ return _jsx(Box, { paddingLeft: 2, marginBottom: 1, children: _jsx(Text, { wrap: "wrap", color: tokens.colors.dim, children: it.text }) }, it.id);
717
745
  if (it.kind === 'error')
718
746
  return _jsx(Box, { paddingLeft: 2, marginBottom: 1, children: _jsxs(Text, { color: tokens.colors.err, children: [" ", g('guide'), " ", g('failure'), " ", it.message] }) }, it.id);
719
747
  if (it.kind === 'policy')
@@ -723,5 +751,5 @@ export function App(props) {
723
751
  if (it.kind === 'diff')
724
752
  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));
725
753
  return null;
726
- }) : null, showThinking && status.status === 'running' && !streamingIdRef.current ? (_jsxs(Box, { paddingLeft: 2, marginBottom: 1, children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: tokens.colors.accent, children: [_jsx(Spinner, { type: "dots" }), " "] }), _jsx(Text, { color: tokens.colors.dim, children: "Thinking... (esc to cancel)" }), _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, i) => (_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'), " ", i + 1, ". ", 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, _jsx(ApprovalModal, { bridge: bridge }), _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: [status.status === 'running' ? (_jsxs(Text, { color: tokens.colors.accent, children: [_jsx(Spinner, { type: "dots" }), " working \u00B7 "] })) : null, baseHints, maxTop > 0 && isFullscreen ? ' · PgUp/Dn scroll' : ''] }), _jsxs(Text, { color: tokens.colors.dim, children: [cost > 0 ? `$${cost.toFixed(2)} · ` : '', ctxPct, "% ctx \u00B7 ", status.model, status.status === 'running' ? ' ●' : ''] })] })] }));
754
+ }) : null, showThinking && status.status === 'running' && !streamingIdRef.current ? (_jsxs(Box, { paddingLeft: 2, marginBottom: 1, children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: tokens.colors.accent, children: [_jsx(Spinner, { type: "dots" }), " "] }), _jsx(Text, { color: tokens.colors.dim, children: "Thinking... (esc to cancel)" }), _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, i) => (_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'), " ", i + 1, ". ", 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, flexShrink: 0, 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, flexShrink: 0, 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, _jsx(ApprovalModal, { bridge: bridge }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, 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", flexShrink: 0, children: [_jsxs(Text, { color: tokens.colors.dim, children: [status.status === 'running' ? (_jsxs(Text, { color: tokens.colors.accent, children: [_jsx(Spinner, { type: "dots" }), " working \u00B7 "] })) : null, baseHints, maxTop > 0 && isFullscreen ? ' · PgUp/Dn scroll' : ''] }), _jsxs(Text, { color: tokens.colors.dim, children: [cost > 0 ? `$${cost.toFixed(2)} · ` : '', ctxPct, "% ctx \u00B7 ", status.model, status.status === 'running' ? ' ●' : ''] })] })] }));
727
755
  }
@@ -94,6 +94,34 @@ describe('App', () => {
94
94
  }
95
95
  return out;
96
96
  }
97
+ // Polling assertions: hook-driven updates flush on React's schedule, so
98
+ // fixed sleeps flake under load. Poll the frame instead.
99
+ async function waitForMatch(getFrame, re, timeout = 4000) {
100
+ const start = Date.now();
101
+ let frame = '';
102
+ for (;;) {
103
+ frame = getFrame() ?? '';
104
+ if (re.test(frame))
105
+ return frame;
106
+ if (Date.now() - start > timeout) {
107
+ throw new Error(`timed out waiting for ${re}\nlast frame:\n${frame.slice(0, 2000)}`);
108
+ }
109
+ await new Promise((r) => setTimeout(r, 25));
110
+ }
111
+ }
112
+ async function waitForAbsent(getFrame, re, timeout = 4000) {
113
+ const start = Date.now();
114
+ let frame = '';
115
+ for (;;) {
116
+ frame = getFrame() ?? '';
117
+ if (!re.test(frame))
118
+ return frame;
119
+ if (Date.now() - start > timeout) {
120
+ throw new Error(`timed out waiting for absence of ${re}\nlast frame:\n${frame.slice(0, 2000)}`);
121
+ }
122
+ await new Promise((r) => setTimeout(r, 25));
123
+ }
124
+ }
97
125
  // ANSI sequences Ink's parse-keypress recognizes.
98
126
  const KEY_HOME = '\x1b[H';
99
127
  const KEY_END = '\x1b[F';
@@ -263,14 +291,11 @@ describe('App', () => {
263
291
  // Wheel up ×12 (3 lines each = 36 > maxTop 32) → pinned at top.
264
292
  for (let i = 0; i < 12; i++)
265
293
  captured.scrollLines(-3);
266
- await new Promise((r) => setTimeout(r, 50));
267
- const top = lastFrame() ?? '';
268
- expect(top).toMatch(/MSG-00-tag/);
294
+ const top = await waitForMatch(lastFrame, /MSG-00-tag/);
269
295
  expect(top).not.toMatch(/MSG-24-tag/);
270
296
  // scrollToBottom → tail visible again.
271
297
  captured.scrollToBottom();
272
- await new Promise((r) => setTimeout(r, 50));
273
- expect(lastFrame() ?? '').toMatch(/MSG-24-tag/);
298
+ await waitForMatch(lastFrame, /MSG-24-tag/);
274
299
  });
275
300
  it('idle Ctrl+C quits (design.md §18)', async () => {
276
301
  const onSlash = vi.fn(async () => { });
@@ -342,19 +367,14 @@ describe('App', () => {
342
367
  await new Promise((r) => setTimeout(r, 50));
343
368
  expect(handle).not.toBeNull();
344
369
  handle.runTranscriptCommand('messages_half_page_up');
345
- await new Promise((r) => setTimeout(r, 50));
346
- let frame = lastFrame() ?? '';
347
370
  // Half page (10 lines) up from bottom (row 30 → 20): MSG-24 gone, MSG-14 in view.
348
- expect(frame).not.toMatch(/MSG-24-tag/);
349
- expect(frame).toMatch(/MSG-14-tag/);
371
+ await waitForAbsent(lastFrame, /MSG-24-tag/);
372
+ await waitForMatch(lastFrame, /MSG-14-tag/);
350
373
  handle.runTranscriptCommand('messages_first');
351
- await new Promise((r) => setTimeout(r, 50));
352
- frame = lastFrame() ?? '';
353
- expect(frame).toMatch(/MSG-00-tag/);
374
+ let frame = await waitForMatch(lastFrame, /MSG-00-tag/);
354
375
  expect(frame).not.toMatch(/MSG-24-tag/);
355
376
  handle.runTranscriptCommand('messages_last');
356
- await new Promise((r) => setTimeout(r, 50));
357
- expect(lastFrame() ?? '').toMatch(/MSG-24-tag/);
377
+ await waitForMatch(lastFrame, /MSG-24-tag/);
358
378
  });
359
379
  it('tool result patches the running item in place (no stale spinner)', async () => {
360
380
  let hooks = null;
@@ -364,13 +384,10 @@ describe('App', () => {
364
384
  await new Promise((r) => setTimeout(r, 50));
365
385
  expect(hooks).not.toBeNull();
366
386
  hooks.append({ id: 't1', kind: 'tool', name: 'read_file', id_call: 'c1', args: '{"path":"a.ts"}', status: 'running' });
367
- await new Promise((r) => setTimeout(r, 50));
368
- expect(lastFrame() ?? '').toMatch(/Read/);
387
+ await waitForMatch(lastFrame, /Read/);
369
388
  hooks.updateTool('c1', { result: 'ok', isError: false, latencyMs: 42, status: 'done' });
370
- await new Promise((r) => setTimeout(r, 50));
371
- const frame = lastFrame() ?? '';
372
389
  // Resolved with real latency — exactly one group (start item patched, no duplicate).
373
- expect(frame).toMatch(/42ms/);
390
+ const frame = await waitForMatch(lastFrame, /42ms/);
374
391
  expect(frame.match(/Read/g)?.length ?? 0).toBeLessThanOrEqual(2);
375
392
  });
376
393
  it('heavy transcript: frame bounded, input and tail visible', async () => {
@@ -405,10 +422,9 @@ describe('App', () => {
405
422
  .then((c) => {
406
423
  choice = c;
407
424
  });
408
- await new Promise((r) => setTimeout(r, 80));
409
425
  // The modal must actually render — previously it never mounted, so every
410
426
  // policy 'ask' hung the runtime forever.
411
- expect(lastFrame() ?? '').toMatch(/approval needed/i);
427
+ await waitForMatch(lastFrame, /approval needed/i);
412
428
  expect(bridge.resolve('deny')).toBe(true);
413
429
  await pending;
414
430
  expect(choice).toBe('deny');
@@ -429,12 +445,78 @@ describe('App', () => {
429
445
  } }));
430
446
  await new Promise((r) => setTimeout(r, 50));
431
447
  hooks.append({ id: 't1', kind: 'tool', name: 'read_file', id_call: 'c1', args: '{"path":"a.ts"}', status: 'running' });
432
- await new Promise((r) => setTimeout(r, 120));
433
448
  // Running group: spinner next to the verb (braille frame or fallback text).
434
- expect(lastFrame() ?? '').toMatch(/⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏|Read/);
449
+ await waitForMatch(lastFrame, /⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏|Read/);
435
450
  hooks.updateTool('c1', { result: 'ok', isError: false, latencyMs: 42, status: 'done' });
451
+ await waitForMatch(lastFrame, /42ms/);
452
+ });
453
+ it('thinking shows dim while working, clears on response', async () => {
454
+ let hooks = null;
455
+ const { lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, onMounted: (h) => {
456
+ hooks = { appendThinkingDelta: h.appendThinkingDelta, clearThinking: h.clearThinking };
457
+ } }));
458
+ await new Promise((r) => setTimeout(r, 50));
459
+ expect(hooks).not.toBeNull();
460
+ hooks.appendThinkingDelta('weighing two approaches... ');
461
+ hooks.appendThinkingDelta('leaning to the second.');
462
+ await waitForMatch(lastFrame, /weighing two approaches\.\.\. leaning to the second\./);
463
+ // Answered: thinking goes away, only the response stays.
464
+ hooks.clearThinking();
465
+ await waitForAbsent(lastFrame, /weighing two approaches/);
466
+ });
467
+ it('empty state: composer pinned to bottom, header first (TEST 1)', async () => {
468
+ const { lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true }));
469
+ await new Promise((r) => setTimeout(r, 50));
470
+ const rows = (lastFrame() ?? '').split('\n');
471
+ expect(rows[0]).toMatch(/KLYRO/);
472
+ const nonEmpty = rows.map((r, i) => ({ r, i })).filter((x) => x.r.trim().length > 0);
473
+ const last = nonEmpty[nonEmpty.length - 1];
474
+ expect(last.r).toMatch(/enter to send|for history|to attach/);
475
+ const placeholder = rows.findIndex((r) => r.includes('Message Klyro'));
476
+ expect(placeholder).toBeGreaterThanOrEqual(0);
477
+ expect(placeholder).toBeLessThan(last.i);
478
+ });
479
+ it('multiline input grows upward, status stays last (TEST 5)', async () => {
480
+ const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true }));
481
+ stdin.write('build authentication');
482
+ await new Promise((r) => setTimeout(r, 20));
483
+ stdin.write('\x1b[13;2u');
484
+ await new Promise((r) => setTimeout(r, 20));
485
+ stdin.write('add tests');
436
486
  await new Promise((r) => setTimeout(r, 50));
437
- expect(lastFrame() ?? '').toMatch(/42ms/);
487
+ const rows = (lastFrame() ?? '').split('\n');
488
+ expect(rows.join('\n')).toContain('build authentication');
489
+ expect(rows.join('\n')).toContain('add tests');
490
+ const nonEmpty = rows.map((r, i) => ({ r, i })).filter((x) => x.r.trim().length > 0);
491
+ const last = nonEmpty[nonEmpty.length - 1];
492
+ expect(last.r).toMatch(/enter to send|for history|to attach/);
493
+ expect(rows.length).toBeLessThanOrEqual(32);
494
+ });
495
+ it('tool events aggregate, no raw internals leak (TEST 13)', async () => {
496
+ let hooks = null;
497
+ const { lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, onMounted: (h) => {
498
+ hooks = { append: h.append, updateTool: h.updateTool };
499
+ } }));
500
+ await new Promise((r) => setTimeout(r, 50));
501
+ const tools = [
502
+ ['read_file', 'c1', '{"path":"a.ts"}'],
503
+ ['read_file', 'c2', '{"path":"b.ts"}'],
504
+ ['read_file', 'c3', '{"path":"c.ts"}'],
505
+ ['shell_exec', 'c4', '{"command":"npm test"}'],
506
+ ['shell_exec', 'c5', '{"command":"npm run build"}'],
507
+ ];
508
+ for (const [name, id, args] of tools) {
509
+ hooks.append({ id: `t-${id}`, kind: 'tool', name, id_call: id, args, status: 'running' });
510
+ }
511
+ await waitForMatch(lastFrame, /Read 3 files/);
512
+ for (const [, id] of tools) {
513
+ hooks.updateTool(id, { result: 'ok', isError: false, latencyMs: 5, status: 'done' });
514
+ }
515
+ const frame = await waitForMatch(lastFrame, /Ran 2 commands/);
516
+ expect(frame).toContain('Read 3 files');
517
+ expect(frame).not.toContain('tool_call');
518
+ expect(frame).not.toContain('{"path"');
519
+ expect(frame).not.toContain('queued:');
438
520
  });
439
521
  it('Shift+Up / Shift+Down scroll by one line', async () => {
440
522
  const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
@@ -29,6 +29,9 @@ export type BlockDesc = {
29
29
  } | {
30
30
  kind: 'assistant';
31
31
  text: string;
32
+ } | {
33
+ kind: 'reasoning';
34
+ text: string;
32
35
  } | {
33
36
  kind: 'group';
34
37
  count: number;
@@ -70,6 +70,8 @@ export function blockHeight(b, termWidth) {
70
70
  return wrapCount(b.text, termWidth) + 1;
71
71
  case 'assistant':
72
72
  return 1 + wrapCount(b.text, cw) + 1;
73
+ case 'reasoning':
74
+ return wrapCount(b.text, cw) + 1;
73
75
  case 'group':
74
76
  if (!b.expanded)
75
77
  return 1 + 1;
@@ -101,6 +103,8 @@ export function blockSig(b) {
101
103
  return `u:${b.text.length}:${b.text.slice(0, 16)}:${b.text.slice(-16)}`;
102
104
  case 'assistant':
103
105
  return `a:${b.text.length}:${b.text.slice(0, 16)}:${b.text.slice(-16)}`;
106
+ case 'reasoning':
107
+ return `th:${b.text.length}:${b.text.slice(0, 16)}:${b.text.slice(-16)}`;
104
108
  case 'group':
105
109
  return `g:${b.count}:${b.expanded ? 1 : 0}:${b.status}:${b.resultLen}`;
106
110
  case 'error':
@@ -12,6 +12,10 @@ export type TranscriptItem = {
12
12
  kind: 'text';
13
13
  text: string;
14
14
  role: 'user' | 'assistant';
15
+ } | {
16
+ id: string;
17
+ kind: 'thinking';
18
+ text: string;
15
19
  } | {
16
20
  id: string;
17
21
  kind: 'tool';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klyro",
3
- "version": "0.1.55",
3
+ "version": "0.1.57",
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",