klyro 0.1.3 → 0.1.4

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.
@@ -11,5 +11,7 @@ export interface ReplOptions {
11
11
  maxSteps?: number;
12
12
  model?: string;
13
13
  nonInteractive?: boolean;
14
+ /** Force TUI even when stdin is not a TTY (e.g. for testing or explicit flag). */
15
+ forceTty?: boolean;
14
16
  }
15
17
  export declare function startRepl(opts?: ReplOptions): Promise<number>;
package/dist/cli/repl.js CHANGED
@@ -9,6 +9,7 @@ import React from 'react';
9
9
  import { render } from 'ink';
10
10
  import { App } from '../tui/app.js';
11
11
  import { httpChatAdapter } from '../agent/provider-adapter.js';
12
+ import { anthropicAdapter } from '../agent/anthropic-adapter.js';
12
13
  import { run } from '../agent/runtime.js';
13
14
  import { builtinRegistry } from '../tools/registry.js';
14
15
  import { builtinRules, DEFAULT_POLICY_CONFIG, PolicyEngine } from '../policy/engine.js';
@@ -16,22 +17,39 @@ import { buildLevel6Context } from '../context/level6.js';
16
17
  import { DenyAllApprovalPrompt, StdinApprovalPrompt } from '../policy/approval.js';
17
18
  import { TuiApprovalBridge } from '../tui/approval.js';
18
19
  import { parseUnifiedDiff } from '../tui/diff-parser.js';
19
- function readEnv(name, fallback) {
20
- const v = process.env[name];
21
- return v && v.length > 0 ? v : fallback;
22
- }
20
+ import { resolveProvider, providerHelp } from '../providers.js';
21
+ import { inferProviderFromBaseURL } from '../agent/registry.js';
23
22
  export async function startRepl(opts = {}) {
24
- const baseUrl = readEnv('KLYRO_BASE_URL');
25
- const apiKey = readEnv('KLYRO_API_KEY');
26
- const model = opts.model ?? readEnv('KLYRO_MODEL');
27
- if (!baseUrl || !apiKey || !model) {
28
- process.stderr.write('klyro: KLYRO_BASE_URL, KLYRO_API_KEY, and KLYRO_MODEL must be set\n');
23
+ // Reuse the same provider resolution as legacy repl.ts — probes local
24
+ // Ollama / LM Studio / vLLM when env is not fully set, so bare `klyro`
25
+ // works with a local model just like `klyro chat` does.
26
+ const resolved = await resolveProvider();
27
+ if (!resolved) {
28
+ process.stderr.write('klyro: no provider available.\n');
29
+ process.stderr.write(` ${providerHelp(null)}\n`);
30
+ process.stderr.write(' Set KLYRO_BASE_URL and KLYRO_API_KEY, or run a local server (Ollama, LM Studio, vLLM).\n');
31
+ process.stderr.write(' Examples:\n');
32
+ process.stderr.write(' set KLYRO_BASE_URL=https://api.openai.com/v1\n');
33
+ process.stderr.write(' set KLYRO_API_KEY=sk-...\n');
34
+ process.stderr.write(' ollama serve # then KLYRO_BASE_URL=http://localhost:11434/v1 KLYRO_MODEL=llama3.2\n');
29
35
  return 2;
30
36
  }
37
+ const baseUrl = resolved.baseURL;
38
+ const apiKey = resolved.apiKey;
39
+ let model = opts.model ?? resolved.model;
31
40
  const cwd = opts.cwd ?? process.cwd();
32
41
  const registry = builtinRegistry();
33
42
  const policy = new PolicyEngine(builtinRules(), DEFAULT_POLICY_CONFIG);
34
- const adapter = httpChatAdapter({ baseURL: baseUrl, apiKey, timeoutMs: 60_000 });
43
+ const providerKind = inferProviderFromBaseURL(baseUrl);
44
+ // Local Ollama exposes OpenAI-compat but hostname could contain "anthropic"
45
+ // via proxy — don't try anthropic adapter with empty key (would 401).
46
+ const effectiveProvider = providerKind === 'anthropic' && !apiKey ? 'openai' : providerKind;
47
+ if (providerKind === 'anthropic' && !apiKey) {
48
+ process.stderr.write('klyro: anthropic provider detected but KLYRO_API_KEY is empty — falling back to OpenAI-compatible adapter\n');
49
+ }
50
+ const adapter = effectiveProvider === 'anthropic'
51
+ ? anthropicAdapter({ baseURL: baseUrl, apiKey, timeoutMs: 60_000 })
52
+ : httpChatAdapter({ baseURL: baseUrl, apiKey, timeoutMs: 60_000 });
35
53
  const ctxBlock = await buildLevel6Context({ cwd });
36
54
  const ctxPrefix = ctxBlock.formatted ? `\n\n<context>\n${ctxBlock.formatted}\n</context>` : '';
37
55
  const systemPromptFn = (_ctx) => {
@@ -40,18 +58,42 @@ export async function startRepl(opts = {}) {
40
58
  return base + ctxPrefix + t;
41
59
  };
42
60
  const ac = new AbortController();
43
- process.on('SIGINT', () => ac.abort());
44
61
  // When the TUI is mounted, use the inline Ink prompt. Otherwise
45
62
  // fall back to stdin readline. The bridge is shared between the
46
63
  // App and the runtime so the modal can resolve the runtime's ask().
47
64
  const tuiBridge = new TuiApprovalBridge();
65
+ const useTui = opts.forceTty || process.stdin.isTTY;
48
66
  const approval = opts.nonInteractive
49
67
  ? new DenyAllApprovalPrompt()
50
- : (process.stdin.isTTY ? tuiBridge : new StdinApprovalPrompt());
68
+ : (useTui ? tuiBridge : new StdinApprovalPrompt());
51
69
  let inflight = null;
52
- let transcriptRef = [];
53
70
  let lastStatus = null;
54
- const app = render(React.createElement(App, {
71
+ const pendingQueue = [];
72
+ let isMounted = false;
73
+ let directHooks;
74
+ function queuedAppend(item) {
75
+ if (isMounted && directHooks)
76
+ directHooks.append(item);
77
+ else
78
+ pendingQueue.push({ kind: 'append', item });
79
+ }
80
+ function queuedStatus(s) {
81
+ lastStatus = { ...(lastStatus ?? { model: model ?? '', step: 0, maxSteps: opts.maxSteps ?? 30, usageInput: 0, usageOutput: 0, repairs: 0, status: 'idle' }), ...s };
82
+ if (isMounted && directHooks)
83
+ directHooks.updateStatus(s);
84
+ else
85
+ pendingQueue.push({ kind: 'status', patch: s });
86
+ }
87
+ function queuedPlan(p) {
88
+ if (isMounted && directHooks)
89
+ directHooks.updatePlan(p);
90
+ else
91
+ pendingQueue.push({ kind: 'plan', plan: p });
92
+ }
93
+ // Declare app before handler to avoid TDZ; handler added after render
94
+ let app;
95
+ let sigintHandler;
96
+ app = render(React.createElement(App, {
55
97
  initialModel: model,
56
98
  maxSteps: opts.maxSteps ?? 30,
57
99
  cwd,
@@ -65,14 +107,39 @@ export async function startRepl(opts = {}) {
65
107
  onSlash: async (cmd) => {
66
108
  await handleSlash(cmd);
67
109
  },
110
+ onMounted: (hooks) => {
111
+ directHooks = hooks;
112
+ isMounted = true;
113
+ for (const ev of pendingQueue) {
114
+ if (ev.kind === 'status')
115
+ hooks.updateStatus(ev.patch);
116
+ else if (ev.kind === 'plan')
117
+ hooks.updatePlan(ev.plan);
118
+ else
119
+ hooks.append(ev.item);
120
+ }
121
+ pendingQueue.length = 0;
122
+ },
68
123
  }));
69
- // Bridge: subscribes to global hooks installed by App.useEffect.
70
- // Every time the runtime emits, we translate to a transcript item or
71
- // a status update.
72
- const appG = globalThis;
124
+ // Install SIGINT handler only after app exists (avoids TDZ) and use once
125
+ sigintHandler = () => {
126
+ ac.abort();
127
+ queuedStatus({ status: 'aborted' });
128
+ try {
129
+ app?.unmount();
130
+ }
131
+ catch { /* ignore */ }
132
+ };
133
+ process.once('SIGINT', sigintHandler);
73
134
  async function runWithBridge(text) {
74
- appG.__klyroAppStatus?.({ status: 'running', step: 0 });
135
+ if (!model) {
136
+ queuedAppend({ id: `err-${Date.now()}`, kind: 'error', message: 'no model configured' });
137
+ queuedStatus({ status: 'error', errorMessage: 'no model configured' });
138
+ return;
139
+ }
140
+ queuedStatus({ status: 'running', step: 0, model });
75
141
  let textBuf = '';
142
+ let pendingTextId = null;
76
143
  let activeCallId = null;
77
144
  let activeCallName = null;
78
145
  let activeCallArgs = '';
@@ -80,17 +147,36 @@ export async function startRepl(opts = {}) {
80
147
  const result = await run({
81
148
  task: text,
82
149
  cwd,
83
- model: model,
150
+ model,
84
151
  maxSteps: opts.maxSteps ?? 30,
85
152
  signal: ac.signal,
86
153
  nonInteractive: opts.nonInteractive ?? false,
87
154
  onEvent: (ev) => {
88
155
  if (ev.kind === 'step_start') {
89
- appG.__klyroAppStatus?.({ step: ev.step });
156
+ // Flush coalesced text before new step
157
+ pendingTextId = null;
158
+ queuedStatus({ step: ev.step });
90
159
  }
91
160
  else if (ev.kind === 'text_delta') {
92
161
  textBuf += ev.text;
93
- appG.__klyroAppAppend?.({ id: `text-${ev.kind}-${Date.now()}-${Math.random()}`, kind: 'text', text: ev.text, role: 'assistant' });
162
+ // Coalesce: reuse pending text item if still queued, otherwise create one.
163
+ // App.tsx also coalesces post-mount, so we only need to avoid queue bloat.
164
+ if (pendingTextId) {
165
+ const last = pendingQueue[pendingQueue.length - 1];
166
+ if (last?.kind === 'append' && last.item.kind === 'text' && last.item.id === pendingTextId) {
167
+ last.item.text += ev.text;
168
+ return;
169
+ }
170
+ }
171
+ // For mounted case, App will merge via its own coalescing (same id)
172
+ // so reuse pendingTextId to let App merge
173
+ if (pendingTextId && isMounted) {
174
+ queuedAppend({ id: pendingTextId, kind: 'text', text: ev.text, role: 'assistant' });
175
+ return;
176
+ }
177
+ const id = `text-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
178
+ pendingTextId = id;
179
+ queuedAppend({ id, kind: 'text', text: ev.text, role: 'assistant' });
94
180
  }
95
181
  else if (ev.kind === 'tool_call_start') {
96
182
  activeCallId = ev.id;
@@ -101,7 +187,7 @@ export async function startRepl(opts = {}) {
101
187
  activeCallArgs += ev.argsJson;
102
188
  }
103
189
  else if (ev.kind === 'tool_call_end') {
104
- appG.__klyroAppAppend?.({
190
+ queuedAppend({
105
191
  id: `tool-${ev.id}-${Date.now()}`,
106
192
  kind: 'tool',
107
193
  name: ev.name,
@@ -114,7 +200,7 @@ export async function startRepl(opts = {}) {
114
200
  activeCallArgs = '';
115
201
  }
116
202
  else if (ev.kind === 'policy_decision') {
117
- appG.__klyroAppAppend?.({
203
+ queuedAppend({
118
204
  id: `pol-${ev.id}-${Date.now()}`,
119
205
  kind: 'policy',
120
206
  name: ev.name,
@@ -123,7 +209,7 @@ export async function startRepl(opts = {}) {
123
209
  });
124
210
  }
125
211
  else if (ev.kind === 'tool_result') {
126
- appG.__klyroAppAppend?.({
212
+ queuedAppend({
127
213
  id: `tres-${ev.id}-${Date.now()}`,
128
214
  kind: 'tool',
129
215
  name: ev.name,
@@ -136,13 +222,13 @@ export async function startRepl(opts = {}) {
136
222
  });
137
223
  }
138
224
  else if (ev.kind === 'usage') {
139
- appG.__klyroAppStatus?.({ usageInput: ev.input, usageOutput: ev.output });
225
+ queuedStatus({ usageInput: ev.input, usageOutput: ev.output });
140
226
  }
141
227
  else if (ev.kind === 'plan_update') {
142
- appG.__klyroAppPlan?.(ev.plan);
228
+ queuedPlan(ev.plan);
143
229
  }
144
230
  else if (ev.kind === 'file_changed') {
145
- appG.__klyroAppAppend?.({
231
+ queuedAppend({
146
232
  id: `fc-${ev.path}-${Date.now()}`,
147
233
  kind: 'file_changed',
148
234
  path: ev.path,
@@ -150,57 +236,69 @@ export async function startRepl(opts = {}) {
150
236
  });
151
237
  }
152
238
  else if (ev.kind === 'verification_failed') {
153
- appG.__klyroAppAppend?.({
239
+ queuedAppend({
154
240
  id: `vf-${ev.step}-${Date.now()}`,
155
241
  kind: 'error',
156
242
  message: `verification failed at ${ev.step}: ${ev.reason}`,
157
243
  });
158
244
  }
159
245
  else if (ev.kind === 'aborted') {
160
- appG.__klyroAppStatus?.({ status: 'aborted' });
246
+ queuedStatus({ status: 'aborted' });
161
247
  }
162
248
  },
163
249
  }, { adapter, registry, policy, approval, systemPrompt: systemPromptFn });
164
- appG.__klyroAppStatus?.({ status: 'complete' === result.status ? 'done' : 'error', repairs: result.repairs ?? 0 });
250
+ queuedStatus({ status: 'complete' === result.status ? 'done' : 'error', repairs: result.repairs ?? 0 });
165
251
  }
166
252
  catch (err) {
167
253
  const message = err instanceof Error ? err.message : String(err);
168
- appG.__klyroAppAppend?.({ id: `err-${Date.now()}`, kind: 'error', message });
169
- appG.__klyroAppStatus?.({ status: 'error', errorMessage: message });
254
+ queuedAppend({ id: `err-${Date.now()}`, kind: 'error', message });
255
+ queuedStatus({ status: 'error', errorMessage: message });
170
256
  }
171
257
  }
172
258
  async function handleSlash(cmd) {
173
259
  switch (cmd.kind) {
174
260
  case 'quit':
175
- app.unmount();
176
- process.exit(0);
261
+ try {
262
+ app?.unmount();
263
+ }
264
+ catch { /* ignore */ }
265
+ // Listener cleanup is handled by the waitUntilExit resolver below
177
266
  return;
178
267
  case 'clear':
179
- // Bypass via the app's setTranscript by re-rendering is awkward;
180
- // for MVP, append a marker and rely on the user to scroll.
181
- // A real implementation would expose a clear() method.
182
- appG.__klyroAppAppend?.({ id: `sep-${Date.now()}`, kind: 'text', text: '--- cleared ---', role: 'assistant' });
268
+ queuedAppend({ id: `sep-${Date.now()}`, kind: 'text', text: '--- cleared ---', role: 'assistant' });
183
269
  return;
184
270
  case 'help': {
185
- const helpText = 'commands: /clear /compact /model <id> /diff /status /quit';
186
- appG.__klyroAppAppend?.({ id: `help-${Date.now()}`, kind: 'text', text: helpText, role: 'assistant' });
271
+ const helpText = [
272
+ 'commands:',
273
+ ' /clear — clear transcript marker',
274
+ ' /diff — show git diff',
275
+ ' /status — show session status',
276
+ ' /compact — (stub) context compaction',
277
+ ' /model <id> — switch model mid-session',
278
+ ' /quit — exit',
279
+ `provider: ${effectiveProvider} model: ${model} cwd: ${cwd}`,
280
+ ].join('\n');
281
+ queuedAppend({ id: `help-${Date.now()}`, kind: 'text', text: helpText, role: 'assistant' });
187
282
  return;
188
283
  }
189
284
  case 'status': {
190
285
  if (lastStatus) {
191
- appG.__klyroAppAppend?.({
286
+ queuedAppend({
192
287
  id: `stat-${Date.now()}`,
193
288
  kind: 'text',
194
289
  text: JSON.stringify(lastStatus, null, 2),
195
290
  role: 'assistant',
196
291
  });
197
292
  }
293
+ else {
294
+ queuedAppend({ id: `stat2-${Date.now()}`, kind: 'text', text: `model: ${model} provider: ${effectiveProvider} cwd: ${cwd}`, role: 'assistant' });
295
+ }
198
296
  return;
199
297
  }
200
298
  case 'diff': {
201
299
  const r = await registry.execute('git_diff', {}, { cwd, env: process.env, nonInteractive: true });
202
300
  if (!r.ok) {
203
- appG.__klyroAppAppend?.({
301
+ queuedAppend({
204
302
  id: `diff-err-${Date.now()}`,
205
303
  kind: 'error',
206
304
  message: `git_diff failed: ${r.error.message ?? r.error.code}`,
@@ -209,7 +307,7 @@ export async function startRepl(opts = {}) {
209
307
  }
210
308
  const out = r.value;
211
309
  const hunks = parseUnifiedDiff(out.diff);
212
- appG.__klyroAppAppend?.({
310
+ queuedAppend({
213
311
  id: `diff-${Date.now()}`,
214
312
  kind: 'diff',
215
313
  hunks,
@@ -218,16 +316,27 @@ export async function startRepl(opts = {}) {
218
316
  return;
219
317
  }
220
318
  case 'compact':
221
- case 'model':
222
- appG.__klyroAppAppend?.({
319
+ queuedAppend({
223
320
  id: `stub-${Date.now()}`,
224
321
  kind: 'text',
225
- text: `/${cmd.kind} is a stub in this build. (${cmd.kind === 'model' ? `requested: ${cmd.model}` : 'persistence integration pending'})`,
322
+ text: `/compact is a stub in this build. (persistence integration pending)`,
226
323
  role: 'assistant',
227
324
  });
228
325
  return;
326
+ case 'model': {
327
+ const next = cmd.model?.trim();
328
+ if (!next) {
329
+ queuedAppend({ id: `mdl-${Date.now()}`, kind: 'text', text: `current model: ${model}`, role: 'assistant' });
330
+ }
331
+ else {
332
+ queuedStatus({ model: next });
333
+ queuedAppend({ id: `mdl2-${Date.now()}`, kind: 'text', text: `model switched to ${next} (takes effect on next prompt)`, role: 'assistant' });
334
+ model = next;
335
+ }
336
+ return;
337
+ }
229
338
  case 'unknown':
230
- appG.__klyroAppAppend?.({
339
+ queuedAppend({
231
340
  id: `unk-${Date.now()}`,
232
341
  kind: 'error',
233
342
  message: `unknown command: ${cmd.raw} (try /help)`,
@@ -235,7 +344,18 @@ export async function startRepl(opts = {}) {
235
344
  return;
236
345
  }
237
346
  }
347
+ // Keep process alive until user quits; resolve on unmount or SIGINT.
348
+ // ac.aborted indicates SIGINT; return 130 (128+SIGINT) like shells do.
238
349
  return new Promise((resolve) => {
239
- process.on('exit', () => resolve(0));
350
+ const onExit = () => {
351
+ if (sigintHandler)
352
+ process.removeListener('SIGINT', sigintHandler);
353
+ resolve(ac.signal.aborted ? 130 : 0);
354
+ };
355
+ if (!app) {
356
+ resolve(1);
357
+ return;
358
+ }
359
+ app.waitUntilExit().then(onExit, onExit);
240
360
  });
241
361
  }
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ import { fileURLToPath } from 'node:url';
17
17
  import { dirname, resolve } from 'node:path';
18
18
  import { chat } from './chat.js';
19
19
  import { repl } from './repl.js';
20
+ import { startRepl } from './cli/repl.js';
20
21
  import { runOnce } from './cli/run.js';
21
22
  import { runEval } from './cli/eval.js';
22
23
  // Read version from package.json so `klyro --version` always matches the
@@ -48,10 +49,42 @@ async function main() {
48
49
  .version(VERSION, '-V, --version', 'Print the version number')
49
50
  .helpOption('-h, --help', 'Print this help message')
50
51
  .showHelpAfterError();
51
- // Default action: TUI REPL. For now this falls back to the legacy REPL
52
- // until the Ink-based TUI lands. The wiring point is cli/repl.ts.
52
+ // Top-level TUI overrides single definition; commander auto-creates --no-tui negation
53
+ // Note: -m/--model and --max-steps are defined only on the `tui` subcommand to avoid
54
+ // CommanderError "option already exists" (parent options are inherited by subcommands).
55
+ program
56
+ .option('--tui', 'Force Ink TUI even when stdin is not a TTY')
57
+ .option('--chat', 'Alias for --no-tui (force legacy chat REPL)');
58
+ // Explicit `klyro tui` command — always uses the Ink UI.
59
+ program
60
+ .command('tui')
61
+ .description('Start the Ink TUI REPL (same as bare `klyro` on a TTY)')
62
+ .option('-m, --model <id>', 'Model id (default: auto-detected)')
63
+ .option('--max-steps <n>', 'Max agent steps (default 30)', (v) => parsePositiveInt('--max-steps', v))
64
+ .action(async (opts) => {
65
+ const code = await startRepl({ model: opts.model, maxSteps: opts.maxSteps, forceTty: true });
66
+ process.exit(code);
67
+ });
53
68
  program
54
69
  .action(async () => {
70
+ const opts = program.opts();
71
+ const forceTui = opts.tui === true;
72
+ const forceLegacy = opts.chat === true || opts.tui === false;
73
+ if (forceTui) {
74
+ const code = await startRepl({ forceTty: true });
75
+ process.exit(code);
76
+ }
77
+ if (forceLegacy) {
78
+ await repl('You are a helpful assistant.');
79
+ return;
80
+ }
81
+ if (process.stdin.isTTY) {
82
+ const code = await startRepl();
83
+ process.exit(code);
84
+ }
85
+ // Non-TTY without explicit flag: explain UI requires TTY
86
+ process.stderr.write('klyro: no TTY detected — starting legacy REPL (pipe mode)\n');
87
+ process.stderr.write(' Tip: run `klyro tui` or `klyro --tui` to force the Ink UI, or `klyro --help` for options.\n');
55
88
  await repl('You are a helpful assistant.');
56
89
  });
57
90
  program
package/dist/tui/app.d.ts CHANGED
@@ -14,6 +14,7 @@ import React from 'react';
14
14
  import { type StatusSnapshot } from './status.js';
15
15
  import { type TranscriptItem } from './transcript.js';
16
16
  import { TuiApprovalBridge } from './approval.js';
17
+ import type { PlanStep } from '../agent/runtime.js';
17
18
  export interface AppProps {
18
19
  initialModel: string;
19
20
  maxSteps: number;
@@ -28,5 +29,11 @@ export interface AppProps {
28
29
  initialStatus?: Partial<StatusSnapshot>;
29
30
  /** Optional approval bridge — when set, the modal prompts inline. */
30
31
  approvalBridge?: TuiApprovalBridge;
32
+ /** Called once after mount with direct hooks; also installs global compat hooks. */
33
+ onMounted?: (hooks: {
34
+ append: (i: TranscriptItem) => void;
35
+ updateStatus: (s: Partial<StatusSnapshot>) => void;
36
+ updatePlan: (p: PlanStep[]) => void;
37
+ }) => void;
31
38
  }
32
39
  export declare function App(props: AppProps): React.JSX.Element;
package/dist/tui/app.js CHANGED
@@ -11,7 +11,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
11
11
  * RuntimeEvents via the onEvent callback wired by cli/repl.ts and
12
12
  * translates them into transcript/status updates.
13
13
  */
14
- import { useState, useCallback, useEffect } from 'react';
14
+ import { useState, useCallback, useEffect, useRef } from 'react';
15
15
  import { Box, Text, useInput } from 'ink';
16
16
  import { StatusLine } from './status.js';
17
17
  import { Transcript } from './transcript.js';
@@ -46,21 +46,42 @@ export function App(props) {
46
46
  return bridge.subscribe((p) => setAwaitingApproval(p !== null));
47
47
  }, [bridge]);
48
48
  const append = useCallback((item) => {
49
- setTranscript((prev) => [...prev, item]);
49
+ setTranscript((prev) => {
50
+ const last = prev[prev.length - 1];
51
+ // Only coalesce when IDs match — separate turns have different IDs
52
+ if (last?.kind === 'text' &&
53
+ item.kind === 'text' &&
54
+ last.role === 'assistant' &&
55
+ item.role === 'assistant' &&
56
+ last.id === item.id) {
57
+ return [...prev.slice(0, -1), { ...last, text: last.text + item.text }];
58
+ }
59
+ return [...prev, item];
60
+ });
61
+ }, []);
62
+ const updateStatus = useCallback((s) => {
63
+ setStatus((prev) => ({ ...prev, ...s }));
50
64
  }, []);
65
+ const updatePlan = useCallback((p) => {
66
+ setPlan(p);
67
+ setPlanExpanded(true);
68
+ }, []);
69
+ // Stabilize onMounted to avoid re-installing hooks on every parent re-render
70
+ const onMountedRef = useRef(props.onMounted);
71
+ useEffect(() => { onMountedRef.current = props.onMounted; }, [props.onMounted]);
51
72
  useEffect(() => {
73
+ // Instance-local hooks via callback (preferred)
74
+ onMountedRef.current?.({ append, updateStatus, updatePlan });
75
+ // Global compat hooks for tests / legacy callers (single instance at a time)
52
76
  globalThis.__klyroAppAppend = append;
53
- globalThis.__klyroAppStatus = (s) => setStatus((prev) => ({ ...prev, ...s }));
54
- globalThis.__klyroAppPlan = (p) => {
55
- setPlan(p);
56
- setPlanExpanded(true);
57
- };
77
+ globalThis.__klyroAppStatus = updateStatus;
78
+ globalThis.__klyroAppPlan = updatePlan;
58
79
  return () => {
59
80
  delete globalThis.__klyroAppAppend;
60
81
  delete globalThis.__klyroAppStatus;
61
82
  delete globalThis.__klyroAppPlan;
62
83
  };
63
- }, [append]);
84
+ }, [append, updateStatus, updatePlan]);
64
85
  useInput((inputStr, key) => {
65
86
  if (status.status === 'running' || awaitingApproval)
66
87
  return;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Snapshot tests for the full TUI App — render various states and assert
3
+ * the exact visible frame. This is the "what does the user actually see"
4
+ * ground truth.
5
+ */
6
+ export {};
@@ -0,0 +1,87 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * Snapshot tests for the full TUI App — render various states and assert
4
+ * the exact visible frame. This is the "what does the user actually see"
5
+ * ground truth.
6
+ */
7
+ import { describe, it, expect } from 'vitest';
8
+ import { render } from 'ink-testing-library';
9
+ import { App } from './app.js';
10
+ const DEFAULT_PROPS = {
11
+ cwd: '/projects/demo',
12
+ onPrompt: async () => { },
13
+ onSlash: async () => { },
14
+ };
15
+ describe('App visual snapshot', () => {
16
+ it('renders header + statusline + transcript + input at idle', () => {
17
+ const { lastFrame } = render(_jsx(App, { ...DEFAULT_PROPS, initialModel: "gpt-4o-mini", maxSteps: 30, initialStatus: { status: 'idle', model: 'gpt-4o-mini', step: 0, maxSteps: 30, usageInput: 0, usageOutput: 0, repairs: 0 } }));
18
+ const frame = lastFrame();
19
+ // Header should be visible (uppercase KLYRO as rendered)
20
+ expect(frame).toContain('KLYRO');
21
+ expect(frame).toContain('demo'); // cwd basename
22
+ expect(frame).toContain('gpt-4o-mini');
23
+ // Status line should show
24
+ expect(frame).toMatch(/idle/i);
25
+ // Input prompt should be visible
26
+ expect(frame).toContain('>');
27
+ });
28
+ it('renders a transcript with assistant text', () => {
29
+ const { lastFrame } = render(_jsx(App, { ...DEFAULT_PROPS, initialModel: "gpt-4o-mini", maxSteps: 30, initialStatus: { status: 'idle', model: 'gpt-4o-mini', step: 0, maxSteps: 30, usageInput: 0, usageOutput: 0, repairs: 0 }, initialTranscript: [
30
+ { id: 'u1', kind: 'text', text: 'hello', role: 'user' },
31
+ { id: 'a1', kind: 'text', text: 'hi there', role: 'assistant' },
32
+ ] }));
33
+ const frame = lastFrame();
34
+ expect(frame).toContain('hello');
35
+ expect(frame).toContain('hi there');
36
+ });
37
+ it('does not render plan view when plan is empty', () => {
38
+ const { lastFrame } = render(_jsx(App, { ...DEFAULT_PROPS, initialModel: "gpt-4o-mini", maxSteps: 30, initialStatus: { status: 'idle', model: 'gpt-4o-mini', step: 0, maxSteps: 30, usageInput: 0, usageOutput: 0, repairs: 0 }, initialTranscript: [] }));
39
+ const frame = lastFrame();
40
+ // When plan is empty, no plan section should be visible
41
+ expect(frame).not.toContain('Plan');
42
+ });
43
+ it('renders plan view when plan is populated via mounted hooks', async () => {
44
+ const { lastFrame } = render(_jsx(App, { ...DEFAULT_PROPS, initialModel: "gpt-4o-mini", maxSteps: 30, initialStatus: { status: 'idle', model: 'gpt-4o-mini', step: 0, maxSteps: 30, usageInput: 0, usageOutput: 0, repairs: 0 } }));
45
+ const g = globalThis;
46
+ // Poll for hooks installed by useEffect (avoids flaky fixed timeout)
47
+ for (let i = 0; i < 10 && !g.__klyroAppPlan; i++)
48
+ await new Promise((r) => setTimeout(r, 10));
49
+ g.__klyroAppPlan?.([
50
+ { id: '1', title: 'Read files', status: 'done' },
51
+ { id: '2', title: 'Edit code', status: 'in_progress', files: ['src/x.ts'] },
52
+ ]);
53
+ await new Promise((r) => setTimeout(r, 20));
54
+ const frame = lastFrame();
55
+ expect(frame).toContain('Read files');
56
+ expect(frame).toContain('Edit code');
57
+ });
58
+ it('renders file_changed inline (the colored line)', () => {
59
+ const { lastFrame } = render(_jsx(App, { ...DEFAULT_PROPS, initialModel: "gpt-4o-mini", maxSteps: 30, initialStatus: { status: 'idle', model: 'gpt-4o-mini', step: 0, maxSteps: 30, usageInput: 0, usageOutput: 0, repairs: 0 }, initialTranscript: [
60
+ { id: 'fc1', kind: 'file_changed', path: 'src/foo.ts', op: 'modified' },
61
+ ] }));
62
+ const frame = lastFrame();
63
+ expect(frame).toContain('src/foo.ts');
64
+ expect(frame).toMatch(/modified|\~/);
65
+ });
66
+ it('diff transcript item renders the diff box', () => {
67
+ const { lastFrame } = render(_jsx(App, { ...DEFAULT_PROPS, initialModel: "gpt-4o-mini", maxSteps: 30, initialStatus: { status: 'idle', model: 'gpt-4o-mini', step: 0, maxSteps: 30, usageInput: 0, usageOutput: 0, repairs: 0 }, initialTranscript: [
68
+ {
69
+ id: 'd1',
70
+ kind: 'diff',
71
+ summary: '1 file(s) changed',
72
+ hunks: [{
73
+ path: 'src/x.ts',
74
+ lines: [
75
+ { kind: 'header', text: '@@ -1 +1 @@' },
76
+ { kind: 'remove', text: 'const a = 1;' },
77
+ { kind: 'add', text: 'const a = 2;' },
78
+ ],
79
+ }],
80
+ },
81
+ ] }));
82
+ const frame = lastFrame();
83
+ expect(frame).toContain('src/x.ts');
84
+ expect(frame).toContain('const a = 2;');
85
+ expect(frame).toContain('+ ');
86
+ });
87
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klyro",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
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",