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.
- package/dist/agent/runtime.d.ts +19 -0
- package/dist/agent/runtime.js +27 -0
- package/dist/cli/repl.d.ts +2 -0
- package/dist/cli/repl.js +214 -47
- package/dist/cli/slash/parser.d.ts +3 -0
- package/dist/cli/slash/parser.js +4 -3
- package/dist/index.js +35 -2
- package/dist/tui/app.d.ts +12 -0
- package/dist/tui/app.js +48 -6
- package/dist/tui/app.test.js +8 -8
- package/dist/tui/approval.d.ts +42 -0
- package/dist/tui/approval.js +76 -0
- package/dist/tui/approval.test.d.ts +1 -0
- package/dist/tui/approval.test.js +93 -0
- package/dist/tui/diff-parser.d.ts +18 -0
- package/dist/tui/diff-parser.js +73 -0
- package/dist/tui/diff.d.ts +31 -0
- package/dist/tui/diff.js +25 -0
- package/dist/tui/diff.test.d.ts +1 -0
- package/dist/tui/diff.test.js +86 -0
- package/dist/tui/header.d.ts +20 -0
- package/dist/tui/header.js +16 -0
- package/dist/tui/header.test.d.ts +1 -0
- package/dist/tui/header.test.js +34 -0
- package/dist/tui/plan.d.ts +24 -0
- package/dist/tui/plan.js +25 -0
- package/dist/tui/plan.test.d.ts +1 -0
- package/dist/tui/plan.test.js +42 -0
- package/dist/tui/snapshot.test.d.ts +6 -0
- package/dist/tui/snapshot.test.js +87 -0
- package/dist/tui/transcript.d.ts +16 -3
- package/dist/tui/transcript.js +49 -3
- package/dist/tui/transcript.test.js +64 -16
- package/package.json +1 -1
package/dist/agent/runtime.d.ts
CHANGED
|
@@ -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';
|
package/dist/agent/runtime.js
CHANGED
|
@@ -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',
|
package/dist/cli/repl.d.ts
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
33
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
+
status: ev.isError ? 'error' : 'done',
|
|
126
222
|
});
|
|
127
223
|
}
|
|
128
224
|
else if (ev.kind === 'usage') {
|
|
129
|
-
|
|
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
|
-
|
|
246
|
+
queuedStatus({ status: 'aborted' });
|
|
133
247
|
}
|
|
134
248
|
},
|
|
135
249
|
}, { adapter, registry, policy, approval, systemPrompt: systemPromptFn });
|
|
136
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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 =
|
|
158
|
-
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
case 'model':
|
|
175
|
-
appG.__klyroAppAppend?.({
|
|
319
|
+
queuedAppend({
|
|
176
320
|
id: `stub-${Date.now()}`,
|
|
177
321
|
kind: 'text',
|
|
178
|
-
text:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
} | {
|
package/dist/cli/slash/parser.js
CHANGED
|
@@ -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
|
-
//
|
|
52
|
-
//
|
|
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;
|