klyro 0.1.2 → 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.
@@ -61,6 +61,14 @@ export interface RunOptions {
61
61
  */
62
62
  onEvent?: (ev: RuntimeEvent) => void;
63
63
  }
64
+ /** A single plan step emitted by the agent. */
65
+ export interface PlanStep {
66
+ id: string;
67
+ title: string;
68
+ status: 'pending' | 'in_progress' | 'done' | 'failed' | 'skipped';
69
+ /** Optional files the step will touch (for L6 diff). */
70
+ files?: string[];
71
+ }
64
72
  /** High-level event stream the runtime emits. Safe for UI consumption. */
65
73
  export type RuntimeEvent = {
66
74
  kind: 'step_start';
@@ -106,6 +114,17 @@ export type RuntimeEvent = {
106
114
  text: string;
107
115
  } | {
108
116
  kind: 'aborted';
117
+ } | {
118
+ kind: 'plan_update';
119
+ plan: PlanStep[];
120
+ } | {
121
+ kind: 'file_changed';
122
+ path: string;
123
+ op: 'created' | 'modified' | 'deleted';
124
+ } | {
125
+ kind: 'verification_failed';
126
+ step: string;
127
+ reason: string;
109
128
  };
110
129
  export interface RunResult {
111
130
  status: 'complete' | 'max_steps' | 'aborted' | 'no_final';
@@ -190,6 +190,11 @@ export async function run(opts, deps) {
190
190
  telemetry.recordError(`${code}: ${call.name}`);
191
191
  }
192
192
  emit?.({ kind: 'tool_result', id: call.id, name: call.name, output, isError: !obs.ok, latencyMs });
193
+ if (obs.ok) {
194
+ const fileChanged = inferFileChanged(call.name, call.input, obs.value);
195
+ if (fileChanged)
196
+ emit?.({ kind: 'file_changed', path: fileChanged.path, op: fileChanged.op });
197
+ }
193
198
  }
194
199
  emit?.({ kind: 'step_end', step: steps });
195
200
  }
@@ -207,6 +212,28 @@ function redactOutput(v) {
207
212
  return v; // structured outputs are not redacted wholesale
208
213
  return v;
209
214
  }
215
+ /**
216
+ * Best-effort inference of "this tool call changed a file" for the
217
+ * file_changed event. Returns null when we don't have a clear answer.
218
+ *
219
+ * Today: write_file, edit_file, write_file (truncate via empty content).
220
+ * Tomorrow: shell_exec that ran `rm` or `git mv` would need to grep
221
+ * the output, but that's out of scope.
222
+ */
223
+ function inferFileChanged(toolName, input, output) {
224
+ const inPath = typeof input.path === 'string' ? input.path : null;
225
+ if (!inPath)
226
+ return null;
227
+ if (toolName === 'write_file' || toolName === 'edit_file') {
228
+ // Distinguish create vs modify by the output shape: write_file returns
229
+ // { path, bytesWritten }; edit_file returns { path, replacements, diff }.
230
+ // Both are "modified" semantically; we don't have the pre-state easily
231
+ // from inside the runtime. A more precise implementation would stat the
232
+ // file before/after the call. For now: treat both as 'modified'.
233
+ return { path: inPath, op: 'modified' };
234
+ }
235
+ return null;
236
+ }
210
237
  export function defaultSystemPrompt(ctx) {
211
238
  const base = [
212
239
  'You are Klyro, an autonomous coding harness. You solve the user\'s task by',
@@ -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,28 +9,47 @@ 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';
15
16
  import { buildLevel6Context } from '../context/level6.js';
16
17
  import { DenyAllApprovalPrompt, StdinApprovalPrompt } from '../policy/approval.js';
17
- function readEnv(name, fallback) {
18
- const v = process.env[name];
19
- return v && v.length > 0 ? v : fallback;
20
- }
18
+ import { TuiApprovalBridge } from '../tui/approval.js';
19
+ import { parseUnifiedDiff } from '../tui/diff-parser.js';
20
+ import { resolveProvider, providerHelp } from '../providers.js';
21
+ import { inferProviderFromBaseURL } from '../agent/registry.js';
21
22
  export async function startRepl(opts = {}) {
22
- const baseUrl = readEnv('KLYRO_BASE_URL');
23
- const apiKey = readEnv('KLYRO_API_KEY');
24
- const model = opts.model ?? readEnv('KLYRO_MODEL');
25
- if (!baseUrl || !apiKey || !model) {
26
- 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');
27
35
  return 2;
28
36
  }
37
+ const baseUrl = resolved.baseURL;
38
+ const apiKey = resolved.apiKey;
39
+ let model = opts.model ?? resolved.model;
29
40
  const cwd = opts.cwd ?? process.cwd();
30
41
  const registry = builtinRegistry();
31
42
  const policy = new PolicyEngine(builtinRules(), DEFAULT_POLICY_CONFIG);
32
- const adapter = httpChatAdapter({ baseURL: baseUrl, apiKey, timeoutMs: 60_000 });
33
- const approval = opts.nonInteractive ? new DenyAllApprovalPrompt() : new StdinApprovalPrompt();
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 });
34
53
  const ctxBlock = await buildLevel6Context({ cwd });
35
54
  const ctxPrefix = ctxBlock.formatted ? `\n\n<context>\n${ctxBlock.formatted}\n</context>` : '';
36
55
  const systemPromptFn = (_ctx) => {
@@ -39,14 +58,47 @@ export async function startRepl(opts = {}) {
39
58
  return base + ctxPrefix + t;
40
59
  };
41
60
  const ac = new AbortController();
42
- process.on('SIGINT', () => ac.abort());
61
+ // When the TUI is mounted, use the inline Ink prompt. Otherwise
62
+ // fall back to stdin readline. The bridge is shared between the
63
+ // App and the runtime so the modal can resolve the runtime's ask().
64
+ const tuiBridge = new TuiApprovalBridge();
65
+ const useTui = opts.forceTty || process.stdin.isTTY;
66
+ const approval = opts.nonInteractive
67
+ ? new DenyAllApprovalPrompt()
68
+ : (useTui ? tuiBridge : new StdinApprovalPrompt());
43
69
  let inflight = null;
44
- let transcriptRef = [];
45
70
  let lastStatus = null;
46
- 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, {
47
97
  initialModel: model,
48
98
  maxSteps: opts.maxSteps ?? 30,
99
+ cwd,
49
100
  initialStatus: { status: 'idle' },
101
+ approvalBridge: tuiBridge,
50
102
  onPrompt: async (text) => {
51
103
  inflight = runWithBridge(text);
52
104
  await inflight;
@@ -55,14 +107,39 @@ export async function startRepl(opts = {}) {
55
107
  onSlash: async (cmd) => {
56
108
  await handleSlash(cmd);
57
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
+ },
58
123
  }));
59
- // Bridge: subscribes to global hooks installed by App.useEffect.
60
- // Every time the runtime emits, we translate to a transcript item or
61
- // a status update.
62
- 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);
63
134
  async function runWithBridge(text) {
64
- 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 });
65
141
  let textBuf = '';
142
+ let pendingTextId = null;
66
143
  let activeCallId = null;
67
144
  let activeCallName = null;
68
145
  let activeCallArgs = '';
@@ -70,17 +147,36 @@ export async function startRepl(opts = {}) {
70
147
  const result = await run({
71
148
  task: text,
72
149
  cwd,
73
- model: model,
150
+ model,
74
151
  maxSteps: opts.maxSteps ?? 30,
75
152
  signal: ac.signal,
76
153
  nonInteractive: opts.nonInteractive ?? false,
77
154
  onEvent: (ev) => {
78
155
  if (ev.kind === 'step_start') {
79
- appG.__klyroAppStatus?.({ step: ev.step });
156
+ // Flush coalesced text before new step
157
+ pendingTextId = null;
158
+ queuedStatus({ step: ev.step });
80
159
  }
81
160
  else if (ev.kind === 'text_delta') {
82
161
  textBuf += ev.text;
83
- 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' });
84
180
  }
85
181
  else if (ev.kind === 'tool_call_start') {
86
182
  activeCallId = ev.id;
@@ -91,20 +187,20 @@ export async function startRepl(opts = {}) {
91
187
  activeCallArgs += ev.argsJson;
92
188
  }
93
189
  else if (ev.kind === 'tool_call_end') {
94
- appG.__klyroAppAppend?.({
190
+ queuedAppend({
95
191
  id: `tool-${ev.id}-${Date.now()}`,
96
192
  kind: 'tool',
97
193
  name: ev.name,
98
194
  id_call: ev.id,
99
195
  args: JSON.stringify(ev.input, null, 2),
100
- collapsed: true,
196
+ status: 'running',
101
197
  });
102
198
  activeCallId = null;
103
199
  activeCallName = null;
104
200
  activeCallArgs = '';
105
201
  }
106
202
  else if (ev.kind === 'policy_decision') {
107
- appG.__klyroAppAppend?.({
203
+ queuedAppend({
108
204
  id: `pol-${ev.id}-${Date.now()}`,
109
205
  kind: 'policy',
110
206
  name: ev.name,
@@ -113,7 +209,7 @@ export async function startRepl(opts = {}) {
113
209
  });
114
210
  }
115
211
  else if (ev.kind === 'tool_result') {
116
- appG.__klyroAppAppend?.({
212
+ queuedAppend({
117
213
  id: `tres-${ev.id}-${Date.now()}`,
118
214
  kind: 'tool',
119
215
  name: ev.name,
@@ -122,65 +218,125 @@ export async function startRepl(opts = {}) {
122
218
  result: typeof ev.output === 'string' ? ev.output : JSON.stringify(ev.output, null, 2),
123
219
  isError: ev.isError,
124
220
  latencyMs: ev.latencyMs,
125
- collapsed: true,
221
+ status: ev.isError ? 'error' : 'done',
126
222
  });
127
223
  }
128
224
  else if (ev.kind === 'usage') {
129
- appG.__klyroAppStatus?.({ usageInput: ev.input, usageOutput: ev.output });
225
+ queuedStatus({ usageInput: ev.input, usageOutput: ev.output });
226
+ }
227
+ else if (ev.kind === 'plan_update') {
228
+ queuedPlan(ev.plan);
229
+ }
230
+ else if (ev.kind === 'file_changed') {
231
+ queuedAppend({
232
+ id: `fc-${ev.path}-${Date.now()}`,
233
+ kind: 'file_changed',
234
+ path: ev.path,
235
+ op: ev.op,
236
+ });
237
+ }
238
+ else if (ev.kind === 'verification_failed') {
239
+ queuedAppend({
240
+ id: `vf-${ev.step}-${Date.now()}`,
241
+ kind: 'error',
242
+ message: `verification failed at ${ev.step}: ${ev.reason}`,
243
+ });
130
244
  }
131
245
  else if (ev.kind === 'aborted') {
132
- appG.__klyroAppStatus?.({ status: 'aborted' });
246
+ queuedStatus({ status: 'aborted' });
133
247
  }
134
248
  },
135
249
  }, { adapter, registry, policy, approval, systemPrompt: systemPromptFn });
136
- appG.__klyroAppStatus?.({ status: 'complete' === result.status ? 'done' : 'error', repairs: result.repairs ?? 0 });
250
+ queuedStatus({ status: 'complete' === result.status ? 'done' : 'error', repairs: result.repairs ?? 0 });
137
251
  }
138
252
  catch (err) {
139
253
  const message = err instanceof Error ? err.message : String(err);
140
- appG.__klyroAppAppend?.({ id: `err-${Date.now()}`, kind: 'error', message });
141
- appG.__klyroAppStatus?.({ status: 'error', errorMessage: message });
254
+ queuedAppend({ id: `err-${Date.now()}`, kind: 'error', message });
255
+ queuedStatus({ status: 'error', errorMessage: message });
142
256
  }
143
257
  }
144
258
  async function handleSlash(cmd) {
145
259
  switch (cmd.kind) {
146
260
  case 'quit':
147
- app.unmount();
148
- process.exit(0);
261
+ try {
262
+ app?.unmount();
263
+ }
264
+ catch { /* ignore */ }
265
+ // Listener cleanup is handled by the waitUntilExit resolver below
149
266
  return;
150
267
  case 'clear':
151
- // Bypass via the app's setTranscript by re-rendering is awkward;
152
- // for MVP, append a marker and rely on the user to scroll.
153
- // A real implementation would expose a clear() method.
154
- appG.__klyroAppAppend?.({ id: `sep-${Date.now()}`, kind: 'text', text: '--- cleared ---', role: 'assistant' });
268
+ queuedAppend({ id: `sep-${Date.now()}`, kind: 'text', text: '--- cleared ---', role: 'assistant' });
155
269
  return;
156
270
  case 'help': {
157
- const helpText = 'commands: /clear /compact /model <id> /diff /status /quit';
158
- 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' });
159
282
  return;
160
283
  }
161
284
  case 'status': {
162
285
  if (lastStatus) {
163
- appG.__klyroAppAppend?.({
286
+ queuedAppend({
164
287
  id: `stat-${Date.now()}`,
165
288
  kind: 'text',
166
289
  text: JSON.stringify(lastStatus, null, 2),
167
290
  role: 'assistant',
168
291
  });
169
292
  }
293
+ else {
294
+ queuedAppend({ id: `stat2-${Date.now()}`, kind: 'text', text: `model: ${model} provider: ${effectiveProvider} cwd: ${cwd}`, role: 'assistant' });
295
+ }
296
+ return;
297
+ }
298
+ case 'diff': {
299
+ const r = await registry.execute('git_diff', {}, { cwd, env: process.env, nonInteractive: true });
300
+ if (!r.ok) {
301
+ queuedAppend({
302
+ id: `diff-err-${Date.now()}`,
303
+ kind: 'error',
304
+ message: `git_diff failed: ${r.error.message ?? r.error.code}`,
305
+ });
306
+ return;
307
+ }
308
+ const out = r.value;
309
+ const hunks = parseUnifiedDiff(out.diff);
310
+ queuedAppend({
311
+ id: `diff-${Date.now()}`,
312
+ kind: 'diff',
313
+ hunks,
314
+ summary: `${out.patchedFiles.length} file(s) changed${out.stat ? ' — ' + out.stat.split('\n').pop() : ''}`,
315
+ });
170
316
  return;
171
317
  }
172
318
  case 'compact':
173
- case 'diff':
174
- case 'model':
175
- appG.__klyroAppAppend?.({
319
+ queuedAppend({
176
320
  id: `stub-${Date.now()}`,
177
321
  kind: 'text',
178
- 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)`,
179
323
  role: 'assistant',
180
324
  });
181
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
+ }
182
338
  case 'unknown':
183
- appG.__klyroAppAppend?.({
339
+ queuedAppend({
184
340
  id: `unk-${Date.now()}`,
185
341
  kind: 'error',
186
342
  message: `unknown command: ${cmd.raw} (try /help)`,
@@ -188,7 +344,18 @@ export async function startRepl(opts = {}) {
188
344
  return;
189
345
  }
190
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.
191
349
  return new Promise((resolve) => {
192
- 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);
193
360
  });
194
361
  }
@@ -6,6 +6,7 @@
6
6
  * /compact — ask the agent to compact its own context
7
7
  * /model <id> — switch the active model mid-session
8
8
  * /diff — show working-tree diff (git diff)
9
+ * /plan — toggle the plan view (if a plan is loaded)
9
10
  * /status — show session status (model, steps, usage)
10
11
  * /quit — exit the REPL
11
12
  * /help — list available commands
@@ -22,6 +23,8 @@ export type SlashCommand = {
22
23
  model: string;
23
24
  } | {
24
25
  kind: 'diff';
26
+ } | {
27
+ kind: 'plan';
25
28
  } | {
26
29
  kind: 'status';
27
30
  } | {
@@ -6,6 +6,7 @@
6
6
  * /compact — ask the agent to compact its own context
7
7
  * /model <id> — switch the active model mid-session
8
8
  * /diff — show working-tree diff (git diff)
9
+ * /plan — toggle the plan view (if a plan is loaded)
9
10
  * /status — show session status (model, steps, usage)
10
11
  * /quit — exit the REPL
11
12
  * /help — list available commands
@@ -13,7 +14,7 @@
13
14
  * Anything not starting with "/" is a regular prompt and yields
14
15
  * { kind: 'prompt', text }.
15
16
  */
16
- const KNOWN = ['clear', 'compact', 'model', 'diff', 'status', 'quit', 'help'];
17
+ const KNOWN = ['clear', 'compact', 'model', 'diff', 'plan', 'status', 'quit', 'help'];
17
18
  export function parse(input) {
18
19
  const trimmed = input.trim();
19
20
  if (!trimmed.startsWith('/')) {
@@ -26,6 +27,7 @@ export function parse(input) {
26
27
  case 'clear': return { kind: 'clear' };
27
28
  case 'compact': return { kind: 'compact' };
28
29
  case 'diff': return { kind: 'diff' };
30
+ case 'plan': return { kind: 'plan' };
29
31
  case 'status': return { kind: 'status' };
30
32
  case 'quit':
31
33
  case 'exit':
@@ -38,8 +40,7 @@ export function parse(input) {
38
40
  return { kind: 'unknown', raw: trimmed };
39
41
  return { kind: 'model', model: rest };
40
42
  }
41
- default:
42
- return { kind: 'unknown', raw: trimmed };
43
+ default: return { kind: 'unknown', raw: trimmed };
43
44
  }
44
45
  }
45
46
  export function listCommands() {
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
@@ -13,9 +13,13 @@
13
13
  import React from 'react';
14
14
  import { type StatusSnapshot } from './status.js';
15
15
  import { type TranscriptItem } from './transcript.js';
16
+ import { TuiApprovalBridge } from './approval.js';
17
+ import type { PlanStep } from '../agent/runtime.js';
16
18
  export interface AppProps {
17
19
  initialModel: string;
18
20
  maxSteps: number;
21
+ /** Working directory to display in the header. */
22
+ cwd: string;
19
23
  /** Called when the user submits a non-slash prompt. */
20
24
  onPrompt: (text: string) => void | Promise<void>;
21
25
  /** Called when the user types a slash command. */
@@ -23,5 +27,13 @@ export interface AppProps {
23
27
  /** Initial state (e.g. when resuming a session). */
24
28
  initialTranscript?: TranscriptItem[];
25
29
  initialStatus?: Partial<StatusSnapshot>;
30
+ /** Optional approval bridge — when set, the modal prompts inline. */
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;
26
38
  }
27
39
  export declare function App(props: AppProps): React.JSX.Element;