klyro 0.1.53 → 0.1.55

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.
Files changed (50) hide show
  1. package/dist/agent/anthropic-adapter.js +22 -2
  2. package/dist/agent/registry.js +11 -1
  3. package/dist/agent/retry.d.ts +5 -0
  4. package/dist/agent/retry.js +28 -1
  5. package/dist/agent/runtime.d.ts +2 -0
  6. package/dist/agent/runtime.js +48 -35
  7. package/dist/chat.js +2 -2
  8. package/dist/checkpoints/store.js +86 -7
  9. package/dist/cli/config.js +13 -1
  10. package/dist/cli/repl.js +200 -36
  11. package/dist/cli/run.js +88 -69
  12. package/dist/context/klyro-md.d.ts +5 -0
  13. package/dist/context/klyro-md.js +28 -6
  14. package/dist/context/tokenizer.js +8 -1
  15. package/dist/index.js +49 -14
  16. package/dist/persistence/session.d.ts +2 -0
  17. package/dist/persistence/session.js +11 -5
  18. package/dist/persistence/store.d.ts +7 -0
  19. package/dist/persistence/store.js +77 -28
  20. package/dist/policy/engine.d.ts +10 -1
  21. package/dist/policy/engine.js +33 -2
  22. package/dist/tools/fs/apply-patch.d.ts +16 -1
  23. package/dist/tools/fs/apply-patch.js +163 -75
  24. package/dist/tools/fs/edit-file.d.ts +13 -0
  25. package/dist/tools/fs/edit-file.js +96 -54
  26. package/dist/tools/fs/list-dir.d.ts +2 -0
  27. package/dist/tools/fs/list-dir.js +7 -5
  28. package/dist/tools/fs/multi-edit.js +12 -1
  29. package/dist/tools/fs/read-file.js +1 -1
  30. package/dist/tools/fs/read-history.d.ts +2 -2
  31. package/dist/tools/fs/read-history.js +39 -7
  32. package/dist/tools/fs/write-file.js +1 -1
  33. package/dist/tools/normalize.js +11 -1
  34. package/dist/tools/shell/background.d.ts +5 -0
  35. package/dist/tools/shell/background.js +29 -0
  36. package/dist/tools/shell/shell-exec.d.ts +9 -0
  37. package/dist/tools/shell/shell-exec.js +54 -8
  38. package/dist/tools/verify/run-verify.js +2 -2
  39. package/dist/tui/app.js +10 -5
  40. package/dist/tui/app.test.js +42 -0
  41. package/dist/tui/approval.d.ts +1 -1
  42. package/dist/tui/approval.js +20 -5
  43. package/dist/tui/diff.d.ts +5 -19
  44. package/dist/tui/diff.js +9 -25
  45. package/dist/tui/status.d.ts +4 -8
  46. package/dist/tui/status.js +8 -17
  47. package/dist/tui/transcript.d.ts +13 -20
  48. package/dist/tui/transcript.js +9 -74
  49. package/dist/verification/engine.js +4 -4
  50. package/package.json +1 -1
@@ -34,7 +34,7 @@ export const writeFileTool = defineTool({
34
34
  let needsApproval = false;
35
35
  try {
36
36
  existing = await fs.readFile(resolved, 'utf-8');
37
- if (!wasRead(input.path) && existing.length > 200) {
37
+ if (!wasRead(input.path, ctx.cwd) && existing.length > 200) {
38
38
  needsApproval = true;
39
39
  }
40
40
  }
@@ -40,8 +40,18 @@ export function toToolError(err, fallbackCode = TOOL_ERROR_CODES.INTERNAL) {
40
40
  }
41
41
  return { code: fallbackCode, message: String(err) };
42
42
  }
43
+ const CUSTOM_TOOL_CODES = new Set([
44
+ 'MATCH_NOT_FOUND',
45
+ 'MATCH_AMBIGUOUS',
46
+ 'UNKNOWN_TOOL',
47
+ 'INVALID_INPUT',
48
+ 'INVALID_PATCH',
49
+ 'HUNK_MISMATCH',
50
+ 'POLICY_DENIED',
51
+ 'STALE',
52
+ ]);
43
53
  function isToolErrorCode(code) {
44
- return Object.values(TOOL_ERROR_CODES).includes(code) || code === 'MATCH_NOT_FOUND' || code === 'MATCH_AMBIGUOUS' || code === 'UNKNOWN_TOOL' || code === 'INVALID_INPUT';
54
+ return Object.values(TOOL_ERROR_CODES).includes(code) || CUSTOM_TOOL_CODES.has(code);
45
55
  }
46
56
  function mapNodeErrorCode(code) {
47
57
  switch (code) {
@@ -2,6 +2,11 @@
2
2
  * 4.3 — Background shell: shell(run_in_background), bash_output, kill_shell, /jobs
3
3
  */
4
4
  export declare function startBackground(command: string, cwd: string): string;
5
+ /**
6
+ * argv variant (shell:false) — no shell interpolation possible. Used for
7
+ * constructed commands (e.g. opening an editor on a user-supplied path).
8
+ */
9
+ export declare function startBackgroundArgv(argv: string[], cwd: string): string;
5
10
  export declare function getOutput(id: string, filter?: string): string;
6
11
  export declare function killJob(id: string): void;
7
12
  export declare function listJobs(): Array<{
@@ -2,6 +2,7 @@
2
2
  * 4.3 — Background shell: shell(run_in_background), bash_output, kill_shell, /jobs
3
3
  */
4
4
  import { spawn } from 'node:child_process';
5
+ import { findBlockedReason } from './shell-exec.js';
5
6
  const jobs = new Map();
6
7
  let counter = 0;
7
8
  const MAX_JOBS = 5;
@@ -26,6 +27,10 @@ export function startBackground(command, cwd) {
26
27
  pruneJobs();
27
28
  if (jobs.size >= MAX_JOBS)
28
29
  throw new Error(`Too many background jobs (max ${MAX_JOBS}) — kill one with /jobs`);
30
+ // Same destructive-pattern gate as shell_exec (spawn is shell:true here too).
31
+ const blocked = findBlockedReason(command);
32
+ if (blocked)
33
+ throw new Error(`Command blocked: ${blocked}`);
29
34
  const id = `job-${++counter}-${Date.now().toString(36)}`;
30
35
  const proc = spawn(command, { cwd, shell: true, windowsHide: true });
31
36
  const job = { id, command, cwd, proc, output: '', start: Date.now() };
@@ -40,6 +45,30 @@ export function startBackground(command, cwd) {
40
45
  });
41
46
  return id;
42
47
  }
48
+ /**
49
+ * argv variant (shell:false) — no shell interpolation possible. Used for
50
+ * constructed commands (e.g. opening an editor on a user-supplied path).
51
+ */
52
+ export function startBackgroundArgv(argv, cwd) {
53
+ pruneJobs();
54
+ if (jobs.size >= MAX_JOBS)
55
+ throw new Error(`Too many background jobs (max ${MAX_JOBS}) — kill one with /jobs`);
56
+ if (argv.length === 0)
57
+ throw new Error('startBackgroundArgv requires argv');
58
+ const id = `job-${++counter}-${Date.now().toString(36)}`;
59
+ const proc = spawn(argv[0], argv.slice(1), { cwd, shell: false, windowsHide: true });
60
+ const job = { id, command: argv.join(' '), cwd, proc, output: '', start: Date.now() };
61
+ jobs.set(id, job);
62
+ proc.stdout?.on('data', (b) => { job.output += b.toString(); if (job.output.length > 1_000_000)
63
+ job.output = job.output.slice(-1_000_000); });
64
+ proc.stderr?.on('data', (b) => { job.output += b.toString(); if (job.output.length > 1_000_000)
65
+ job.output = job.output.slice(-1_000_000); });
66
+ proc.on('close', () => {
67
+ setTimeout(() => { if (jobs.get(id)?.proc.exitCode !== null)
68
+ jobs.delete(id); }, JOB_TTL_MS);
69
+ });
70
+ return id;
71
+ }
43
72
  export function getOutput(id, filter) {
44
73
  const job = jobs.get(id);
45
74
  if (!job)
@@ -15,6 +15,15 @@ declare const InputSchema: z.ZodObject<{
15
15
  timeoutMs: z.ZodOptional<z.ZodNumber>;
16
16
  env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
17
17
  }, z.core.$strip>;
18
+ /** Reset the sticky cwd (e.g. after /cd changes the session directory). */
19
+ export declare function resetPersistentCwd(): void;
20
+ /**
21
+ * Shared dangerous-command check (also used by background shells).
22
+ * Returns the block reason, or null when the command is not hard-blocked.
23
+ * NOTE: this covers destructive patterns only — it is NOT an allowlist.
24
+ * Interactive/user-typed commands intentionally bypass it.
25
+ */
26
+ export declare function findBlockedReason(command: string): string | null;
18
27
  export interface ShellOutput {
19
28
  command: string;
20
29
  exitCode: number | null;
@@ -35,6 +35,10 @@ const FULL_OUTPUT_DIR = (() => {
35
35
  })();
36
36
  // Persistent cwd across calls (3.3)
37
37
  let persistentCwd = null;
38
+ /** Reset the sticky cwd (e.g. after /cd changes the session directory). */
39
+ export function resetPersistentCwd() {
40
+ persistentCwd = null;
41
+ }
38
42
  // Filtered env — only safe vars, secrets stripped
39
43
  const ALLOWED_ENV_PREFIXES = ['PATH', 'HOME', 'USER', 'SHELL', 'TERM', 'LANG', 'NODE_', 'NPM_', 'PNPM_', 'YARN_'];
40
44
  function filteredEnv(extra) {
@@ -96,6 +100,19 @@ const DANGEROUS_PATTERNS = [
96
100
  { pattern: /&&\s*rm\s+-rf/, reason: 'chained rm -rf' },
97
101
  { pattern: /\|\|\s*rm\s+-rf/, reason: 'chained rm -rf' },
98
102
  ];
103
+ /**
104
+ * Shared dangerous-command check (also used by background shells).
105
+ * Returns the block reason, or null when the command is not hard-blocked.
106
+ * NOTE: this covers destructive patterns only — it is NOT an allowlist.
107
+ * Interactive/user-typed commands intentionally bypass it.
108
+ */
109
+ export function findBlockedReason(command) {
110
+ for (const { pattern, reason } of DANGEROUS_PATTERNS) {
111
+ if (pattern.test(command))
112
+ return reason;
113
+ }
114
+ return null;
115
+ }
99
116
  export const shellExecTool = defineTool({
100
117
  name: 'shell_exec',
101
118
  description: 'Execute a shell command. Default timeout 120s (max 600s). Output head+tail 30k chars, full to ~/.klyro/tool-output/<id>.txt. Persistent cwd, filtered env, tree-kill on timeout.',
@@ -112,10 +129,9 @@ export const shellExecTool = defineTool({
112
129
  throw Object.assign(new Error(`Interactive command blocked: ${input.command.split(' ')[0]} — hint: use non-interactive mode or run in background`), { code: TOOL_ERROR_CODES.COMMAND_DENIED });
113
130
  }
114
131
  }
115
- for (const { pattern, reason } of DANGEROUS_PATTERNS) {
116
- if (pattern.test(input.command)) {
117
- throw Object.assign(new Error(`Command blocked: ${reason}`), { code: TOOL_ERROR_CODES.COMMAND_DENIED });
118
- }
132
+ const blocked = findBlockedReason(input.command);
133
+ if (blocked) {
134
+ throw Object.assign(new Error(`Command blocked: ${blocked}`), { code: TOOL_ERROR_CODES.COMMAND_DENIED });
119
135
  }
120
136
  // Persistent cwd
121
137
  let cwd = persistentCwd ?? ctx.cwd;
@@ -137,6 +153,8 @@ export const shellExecTool = defineTool({
137
153
  const timeoutMs = Math.min(input.timeoutMs ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
138
154
  const env = filteredEnv(input.env);
139
155
  const start = Date.now();
156
+ // Detached on POSIX so timeout kills the whole process GROUP
157
+ // (child.kill alone orphans grandchildren holding the pipes).
140
158
  const child = spawn(input.command, {
141
159
  cwd,
142
160
  env: env,
@@ -144,6 +162,7 @@ export const shellExecTool = defineTool({
144
162
  stdio: ['ignore', 'pipe', 'pipe'],
145
163
  windowsHide: true,
146
164
  signal: ctx.signal,
165
+ detached: process.platform !== 'win32',
147
166
  });
148
167
  const stdoutChunks = [];
149
168
  const stderrChunks = [];
@@ -151,9 +170,20 @@ export const shellExecTool = defineTool({
151
170
  const fullStderr = [];
152
171
  let totalOut = 0;
153
172
  let totalErr = 0;
173
+ // Full-output caps: an infinite emitter (e.g. `yes`) must not OOM the
174
+ // process or fill the disk. Past the cap we keep counting but drop bytes.
175
+ let fullOutBytes = 0;
176
+ let fullErrBytes = 0;
177
+ const MAX_FULL_BYTES = 512 * 1024;
154
178
  let truncated = false;
155
179
  child.stdout?.on('data', (chunk) => {
156
- fullStdout.push(chunk);
180
+ if (fullOutBytes + chunk.length <= MAX_FULL_BYTES) {
181
+ fullStdout.push(chunk);
182
+ fullOutBytes += chunk.length;
183
+ }
184
+ else {
185
+ truncated = true;
186
+ }
157
187
  if (totalOut + chunk.length > MAX_OUTPUT_CHARS) {
158
188
  truncated = true;
159
189
  // Keep head+tail: drop middle, keep first half and last half later
@@ -165,7 +195,13 @@ export const shellExecTool = defineTool({
165
195
  totalOut += chunk.length;
166
196
  });
167
197
  child.stderr?.on('data', (chunk) => {
168
- fullStderr.push(chunk);
198
+ if (fullErrBytes + chunk.length <= MAX_FULL_BYTES) {
199
+ fullStderr.push(chunk);
200
+ fullErrBytes += chunk.length;
201
+ }
202
+ else {
203
+ truncated = true;
204
+ }
169
205
  if (totalErr + chunk.length > MAX_OUTPUT_CHARS) {
170
206
  truncated = true;
171
207
  if (stderrChunks.length < 10)
@@ -200,10 +236,20 @@ export const shellExecTool = defineTool({
200
236
  catch { /* already gone */ }
201
237
  }
202
238
  else {
239
+ // Kill the whole process group (spawned detached); fall back to
240
+ // the direct child if the group signal fails.
203
241
  try {
204
- child.kill('SIGKILL');
242
+ if (child.pid !== undefined)
243
+ process.kill(-child.pid, 'SIGKILL');
244
+ else
245
+ child.kill('SIGKILL');
246
+ }
247
+ catch {
248
+ try {
249
+ child.kill('SIGKILL');
250
+ }
251
+ catch { /* already dead */ }
205
252
  }
206
- catch { /* already dead */ }
207
253
  }
208
254
  finish({ code: null, signal: 'SIGKILL', timedOut: true });
209
255
  }, timeoutMs);
@@ -6,7 +6,7 @@
6
6
  * engine) can pick a more permissive policy for the verify command: it can
7
7
  * run `npm test`, which shell_exec might want approval for in some configs.
8
8
  */
9
- import { spawn } from 'node:child_process';
9
+ import { spawn, spawnSync } from 'node:child_process';
10
10
  import { z } from 'zod';
11
11
  import { defineTool } from '../types.js';
12
12
  import { safe, TOOL_ERROR_CODES } from '../normalize.js';
@@ -71,7 +71,7 @@ export const runVerifyTool = defineTool({
71
71
  // Windows grandchild cleanup — see shell-exec.ts for why.
72
72
  if (process.platform === 'win32') {
73
73
  try {
74
- require('node:child_process').spawnSync('taskkill', ['/F', '/T', '/PID', String(child.pid)], { windowsHide: true });
74
+ spawnSync('taskkill', ['/F', '/T', '/PID', String(child.pid)], { windowsHide: true });
75
75
  }
76
76
  catch { /* already gone */ }
77
77
  }
package/dist/tui/app.js CHANGED
@@ -5,14 +5,16 @@ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-run
5
5
  */
6
6
  import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
7
7
  import { Box, Text, useInput, useStdout } from 'ink';
8
+ import Spinner from 'ink-spinner';
8
9
  import { execFileSync } from 'node:child_process';
9
- import { TuiApprovalBridge } from './approval.js';
10
+ import { TuiApprovalBridge, ApprovalModal } from './approval.js';
10
11
  import { parse as parseSlash, suggestCommands } from '../cli/slash/parser.js';
11
12
  import { tokens, g } from './tokens.js';
12
13
  import { initialScroll, scrollReducer, resolveTopRow, maxTopFor, } from './scroll-model.js';
13
14
  import { buildIndex, itemAtRow, MeasureCache } from './measure.js';
14
15
  import { renderMarkdownLines } from './markdown.js';
15
16
  import { getTranscriptCommand, } from './transcript-commands.js';
17
+ import { estimateCost, getModelInfo } from '../providers/model-info.js';
16
18
  let _id = 0;
17
19
  function nextId(p) { _id++; return `${p}-${_id}`; }
18
20
  function Header({ cwd, model, version, width }) {
@@ -633,9 +635,11 @@ export function App(props) {
633
635
  });
634
636
  const ver = props.version ?? '0.1.27';
635
637
  const rule = g('rule').repeat(Math.max(10, width - 2));
636
- const cost = (status.usageInput / 1000 * 0.003 + status.usageOutput / 1000 * 0.015);
638
+ // Single cost/context source: model-aware registry rates + window.
639
+ const cost = estimateCost(status.model, status.usageInput, status.usageOutput);
637
640
  const totalTokens = status.usageInput + status.usageOutput;
638
- const ctxPct = totalTokens > 0 ? Math.round((totalTokens / 120_000) * 100) : 0;
641
+ const ctxWindow = getModelInfo(status.model).contextWindow;
642
+ const ctxPct = totalTokens > 0 ? Math.round((totalTokens / ctxWindow) * 100) : 0;
639
643
  // Narrow terminals: compact hints so the status bar never wraps mid-word.
640
644
  const baseHints = width < 90
641
645
  ? status.status === 'running' ? 'ctrl+c stop · enter queue' : 'enter send · / commands'
@@ -680,7 +684,8 @@ export function App(props) {
680
684
  const right = gr.status === 'running' ? `${(elapsed / 1000).toFixed(1)}s` : gr.status === 'error' ? g('failure') : `${gr.totalMs}ms`;
681
685
  const marker = isExpanded ? g('expanded') : g('collapsed');
682
686
  const markerColor = gr.status === 'error' ? tokens.colors.err : gr.status === 'running' ? tokens.colors.warn : tokens.colors.ok;
683
- 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) => {
687
+ const running = gr.status === 'running';
688
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), running ? _jsxs(Text, { color: markerColor, children: [_jsx(Spinner, { type: "dots" }), " "] }) : null, _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) => {
684
689
  let friendly = '';
685
690
  try {
686
691
  const a = JSON.parse(it.args);
@@ -718,5 +723,5 @@ export function App(props) {
718
723
  if (it.kind === 'diff')
719
724
  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));
720
725
  return null;
721
- }) : 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... (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, _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, "% ctx \u00B7 ", status.model, status.status === 'running' ? ' ●' : ''] })] })] }));
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' ? ' ●' : ''] })] })] }));
722
727
  }
@@ -394,6 +394,48 @@ describe('App', () => {
394
394
  expect(frame).toContain('Answer 29');
395
395
  expect(frame).not.toContain('user message number 0');
396
396
  });
397
+ it('renders the approval modal when a prompt is pending (no deadlock)', async () => {
398
+ const { TuiApprovalBridge } = await import('./approval.js');
399
+ const bridge = new TuiApprovalBridge();
400
+ const { lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, approvalBridge: bridge }));
401
+ await new Promise((r) => setTimeout(r, 50));
402
+ let choice;
403
+ const pending = bridge
404
+ .ask({ toolName: 'shell_exec', reason: 'not in allowlist', summary: 'echo hi' })
405
+ .then((c) => {
406
+ choice = c;
407
+ });
408
+ await new Promise((r) => setTimeout(r, 80));
409
+ // The modal must actually render — previously it never mounted, so every
410
+ // policy 'ask' hung the runtime forever.
411
+ expect(lastFrame() ?? '').toMatch(/approval needed/i);
412
+ expect(bridge.resolve('deny')).toBe(true);
413
+ await pending;
414
+ expect(choice).toBe('deny');
415
+ });
416
+ it('shows a working spinner while running (Thinking + status)', async () => {
417
+ const { lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, initialStatus: { status: 'running' } }));
418
+ await new Promise((r) => setTimeout(r, 120));
419
+ const frame = lastFrame() ?? '';
420
+ // Animated dots spinner (any braille frame) or its labels must show.
421
+ expect(frame).toMatch(/⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏|Thinking|working/);
422
+ expect(frame).toContain('Thinking');
423
+ expect(frame).toContain('working');
424
+ });
425
+ it('running tool group shows a spinner, resolved group shows latency', async () => {
426
+ let hooks = null;
427
+ const { lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialStatus: { status: 'running' }, onMounted: (h) => {
428
+ hooks = { append: h.append, updateTool: h.updateTool };
429
+ } }));
430
+ await new Promise((r) => setTimeout(r, 50));
431
+ 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
+ // Running group: spinner next to the verb (braille frame or fallback text).
434
+ expect(lastFrame() ?? '').toMatch(/⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏|Read/);
435
+ hooks.updateTool('c1', { result: 'ok', isError: false, latencyMs: 42, status: 'done' });
436
+ await new Promise((r) => setTimeout(r, 50));
437
+ expect(lastFrame() ?? '').toMatch(/42ms/);
438
+ });
397
439
  it('Shift+Up / Shift+Down scroll by one line', async () => {
398
440
  const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
399
441
  await new Promise((r) => setTimeout(r, 50));
@@ -27,7 +27,7 @@ interface PendingPrompt {
27
27
  }
28
28
  declare class TuiApprovalBridge implements ApprovalPrompt {
29
29
  private pending;
30
- private listener;
30
+ private listeners;
31
31
  ask(req: ApprovalRequest): Promise<ApprovalChoice>;
32
32
  /** Called by the App's useInput to consume the pending prompt. */
33
33
  resolve(choice: ApprovalChoice): boolean;
@@ -23,11 +23,19 @@ import { useEffect, useState } from 'react';
23
23
  import { Box, Text, useInput } from 'ink';
24
24
  class TuiApprovalBridge {
25
25
  pending = null;
26
- listener = null;
26
+ // Fan-out set: BOTH the App (awaitingApproval gate) and the ApprovalModal
27
+ // subscribe. The old single slot meant the last subscriber silently
28
+ // unsubscribed the other — the modal never rendered and approvals hung.
29
+ listeners = new Set();
27
30
  ask(req) {
28
31
  return new Promise((resolve) => {
29
32
  this.pending = { req, resolve };
30
- this.listener?.(this.pending);
33
+ for (const l of this.listeners) {
34
+ try {
35
+ l(this.pending);
36
+ }
37
+ catch { /* ignore listener errors */ }
38
+ }
31
39
  });
32
40
  }
33
41
  /** Called by the App's useInput to consume the pending prompt. */
@@ -36,7 +44,12 @@ class TuiApprovalBridge {
36
44
  return false;
37
45
  const p = this.pending;
38
46
  this.pending = null;
39
- this.listener?.(null);
47
+ for (const l of this.listeners) {
48
+ try {
49
+ l(null);
50
+ }
51
+ catch { /* ignore listener errors */ }
52
+ }
40
53
  p.resolve(choice);
41
54
  return true;
42
55
  }
@@ -45,8 +58,10 @@ class TuiApprovalBridge {
45
58
  }
46
59
  /** Used by useSyncExternalStore-ish wiring in <ApprovalModal/>. */
47
60
  subscribe(listener) {
48
- this.listener = listener;
49
- return () => { this.listener = null; };
61
+ this.listeners.add(listener);
62
+ return () => {
63
+ this.listeners.delete(listener);
64
+ };
50
65
  }
51
66
  }
52
67
  export { TuiApprovalBridge };
@@ -1,19 +1,11 @@
1
1
  /**
2
- * DiffView — a scrollable unified diff display.
2
+ * Diff data model shared by the transcript renderer (app.tsx) and the
3
+ * unified-diff parser (diff-parser.ts).
3
4
  *
4
- * Data model: a list of `DiffHunk` (one per file). Each hunk has a path
5
- * and a list of `DiffLine` with kind (add/remove/context/header).
6
- *
7
- * src/auth/service.ts
8
- * - const old = 1;
9
- * + const old = 2;
10
- * const same = true;
11
- * + // new line
12
- *
13
- * Lines are truncated to 200 chars and color-coded. The view returns
14
- * null when there's no diff to show.
5
+ * NOTE: the old DiffView component was removed: App renders diff hunks
6
+ * inline, and the standalone component's tests gave false coverage for UI
7
+ * nobody sees. Only the types remain.
15
8
  */
16
- import React from 'react';
17
9
  export type DiffLineKind = 'add' | 'remove' | 'context' | 'header';
18
10
  export interface DiffLine {
19
11
  kind: DiffLineKind;
@@ -23,9 +15,3 @@ export interface DiffHunk {
23
15
  path: string;
24
16
  lines: DiffLine[];
25
17
  }
26
- export interface DiffViewProps {
27
- hunks: DiffHunk[];
28
- /** Optional: total file count summary. */
29
- summary?: string;
30
- }
31
- export declare function DiffView({ hunks, summary }: DiffViewProps): React.JSX.Element | null;
package/dist/tui/diff.js CHANGED
@@ -1,25 +1,9 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text } from 'ink';
3
- const LINE_COLORS = {
4
- add: 'green',
5
- remove: 'red',
6
- context: 'gray',
7
- header: 'cyan',
8
- };
9
- const GLYPHS = {
10
- add: '+',
11
- remove: '-',
12
- context: ' ',
13
- header: '@',
14
- };
15
- function truncate(s, max) {
16
- if (s.length <= max)
17
- return s;
18
- return s.slice(0, max) + '…';
19
- }
20
- export function DiffView({ hunks, summary }) {
21
- if (hunks.length === 0) {
22
- return (_jsx(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, marginY: 1, children: _jsx(Text, { color: "gray", children: "(no working-tree changes)" }) }));
23
- }
24
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, marginY: 1, children: [summary ? (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: "\uD83D\uDCDD Diff " }), _jsxs(Text, { color: "gray", children: [" ", summary] })] })) : null, hunks.map((h, i) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsxs(Text, { color: "cyan", bold: true, children: ["\u2500\u2500 ", h.path, " "] }), _jsxs(Text, { color: "gray", children: ["(", h.lines.filter((l) => l.kind === 'add').length, "+ /", ' ', h.lines.filter((l) => l.kind === 'remove').length, "-)"] })] }), h.lines.map((l, j) => (_jsxs(Box, { children: [_jsxs(Text, { color: LINE_COLORS[l.kind], children: [GLYPHS[l.kind], " "] }), _jsx(Text, { color: LINE_COLORS[l.kind], children: truncate(l.text, 200) })] }, j)))] }, `${h.path}-${i}`)))] }));
25
- }
1
+ /**
2
+ * Diff data model shared by the transcript renderer (app.tsx) and the
3
+ * unified-diff parser (diff-parser.ts).
4
+ *
5
+ * NOTE: the old DiffView component was removed: App renders diff hunks
6
+ * inline, and the standalone component's tests gave false coverage for UI
7
+ * nobody sees. Only the types remain.
8
+ */
9
+ export {};
@@ -1,11 +1,10 @@
1
1
  /**
2
- * Status line — model name, step counter, usage, repair counter.
2
+ * Status snapshot — session state pushed from app.tsx via hooks.
3
3
  *
4
- * Pure presentation: takes a snapshot of session state, renders a single
5
- * Ink <Box> with the relevant fields. State changes are pushed from
6
- * app.tsx via React props.
4
+ * NOTE: the old StatusLine component was removed: App renders its own
5
+ * status row inline, and the standalone component's tests gave false
6
+ * coverage for UI nobody sees.
7
7
  */
8
- import React from 'react';
9
8
  export interface StatusSnapshot {
10
9
  model: string;
11
10
  step: number;
@@ -16,6 +15,3 @@ export interface StatusSnapshot {
16
15
  status: 'idle' | 'running' | 'done' | 'error' | 'aborted';
17
16
  errorMessage?: string;
18
17
  }
19
- export declare function StatusLine({ snapshot }: {
20
- snapshot: StatusSnapshot;
21
- }): React.JSX.Element;
@@ -1,17 +1,8 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Text, Box } from 'ink';
3
- import { estimateCost } from '../providers/model-info.js';
4
- export function StatusLine({ snapshot }) {
5
- const { model, step, maxSteps, usageInput, usageOutput, repairs, status } = snapshot;
6
- const statusColor = status === 'running' ? 'cyan' :
7
- status === 'done' ? 'green' :
8
- status === 'error' ? 'red' :
9
- status === 'aborted' ? 'yellow' :
10
- 'gray';
11
- const usageKbIn = (usageInput / 1024).toFixed(1);
12
- const usageKbOut = (usageOutput / 1024).toFixed(1);
13
- const cost = estimateCost(model, usageInput, usageOutput);
14
- const costStr = cost > 0 ? `$${cost.toFixed(3)}` : `$0.000`;
15
- const ctxPct = Math.min(99, Math.round(((usageInput + usageOutput) / 128000) * 100));
16
- return (_jsxs(Box, { borderStyle: "round", borderColor: "gray", paddingX: 1, justifyContent: "space-between", children: [_jsxs(Text, { children: [_jsx(Text, { color: "gray", children: "model " }), _jsx(Text, { bold: true, children: model }), _jsx(Text, { color: "gray", children: " step " }), _jsx(Text, { bold: true, children: step }), _jsxs(Text, { color: "gray", children: ["/", maxSteps] }), _jsx(Text, { color: "gray", children: " repairs " }), _jsx(Text, { bold: true, children: repairs })] }), _jsxs(Text, { children: [_jsx(Text, { color: "gray", children: "tokens " }), _jsxs(Text, { color: "cyan", children: [usageKbIn, "K"] }), _jsx(Text, { color: "gray", children: " in / " }), _jsxs(Text, { color: "cyan", children: [usageKbOut, "K"] }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { color: "green", children: costStr }), _jsxs(Text, { color: "gray", children: [" \u00B7 ", ctxPct, "% ctx"] })] }), _jsx(Text, { children: _jsxs(Text, { color: statusColor, children: ["\u25CF ", status] }) })] }));
17
- }
1
+ /**
2
+ * Status snapshot — session state pushed from app.tsx via hooks.
3
+ *
4
+ * NOTE: the old StatusLine component was removed: App renders its own
5
+ * status row inline, and the standalone component's tests gave false
6
+ * coverage for UI nobody sees.
7
+ */
8
+ export {};
@@ -1,23 +1,12 @@
1
1
  /**
2
- * Transcript — a scrollable list of transcript entries.
2
+ * Transcript item types — the scrollable entry list owned by App.
3
3
  *
4
- * Each entry is a typed TranscriptItem (text delta, tool call block,
5
- * tool result, policy decision, error, file changed). The component
6
- * takes a flat list and renders it. Tool blocks use a card layout
7
- * (┌─ name ─┐) and show a spinner while running.
8
- *
9
- * State management: parent owns the list, passes a fresh array on every
10
- * update. This component is pure presentational.
4
+ * NOTE: the old Transcript/TranscriptRow/ToolCard presentational components
5
+ * were removed: App renders entries inline (grouped tool activity), and the
6
+ * standalone components' tests gave false coverage for UI nobody sees.
7
+ * Only the item types remain.
11
8
  */
12
- import React from 'react';
13
- import { type DiffHunk } from './diff.js';
14
- /** Patch applied to a running tool item when its result arrives. */
15
- export interface ToolResultPatch {
16
- result: string;
17
- isError: boolean;
18
- latencyMs: number;
19
- status: 'done' | 'error';
20
- }
9
+ import type { DiffHunk } from './diff.js';
21
10
  export type TranscriptItem = {
22
11
  id: string;
23
12
  kind: 'text';
@@ -55,6 +44,10 @@ export type TranscriptItem = {
55
44
  hunks: DiffHunk[];
56
45
  summary?: string;
57
46
  };
58
- export declare function Transcript({ items }: {
59
- items: TranscriptItem[];
60
- }): React.JSX.Element;
47
+ /** Patch applied to a running tool item when its result arrives. */
48
+ export interface ToolResultPatch {
49
+ result: string;
50
+ isError: boolean;
51
+ latencyMs: number;
52
+ status: 'done' | 'error';
53
+ }