klyro 0.1.7 → 0.1.9
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.js +9 -2
- package/dist/context/tokenizer.js +9 -4
- package/dist/persistence/store.js +53 -5
- package/dist/policy/secret-redactor.js +4 -1
- package/dist/tools/fs/edit-file.js +7 -3
- package/dist/tools/shell/shell-exec.js +5 -1
- package/dist/tui/activity-line.d.ts +19 -0
- package/dist/tui/activity-line.js +30 -0
- package/dist/tui/app.d.ts +6 -2
- package/dist/tui/app.js +163 -172
- package/dist/tui/app.legacy.d.ts +25 -0
- package/dist/tui/app.legacy.js +337 -0
- package/dist/tui/app.test.js +5 -2
- package/dist/tui/banner.d.ts +28 -0
- package/dist/tui/banner.js +15 -0
- package/dist/tui/input-box.d.ts +21 -0
- package/dist/tui/input-box.js +31 -0
- package/dist/tui/snapshot.test.js +16 -7
- package/dist/tui/thinking-block.d.ts +12 -0
- package/dist/tui/thinking-block.js +11 -0
- package/dist/tui/tokens.d.ts +67 -0
- package/dist/tui/tokens.js +88 -0
- package/package.json +1 -1
package/dist/tui/app.js
CHANGED
|
@@ -1,72 +1,47 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* Klyro TUI v2 — inline/scrollback architecture per TUI_DESIGN.md §1, §10-11
|
|
4
|
+
* Four disciplines:
|
|
5
|
+
* 1. History in <Static> — never re-rendered
|
|
6
|
+
* 2. Only live region is dynamic
|
|
7
|
+
* 3. Stream deltas batched at ~30fps
|
|
8
|
+
* 4. Exactly one useInput owner
|
|
5
9
|
*/
|
|
6
10
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
7
|
-
import { Box, Text, useInput } from 'ink';
|
|
11
|
+
import { Box, Text, Static, useInput } from 'ink';
|
|
12
|
+
import { Banner } from './banner.js';
|
|
13
|
+
import { ActivityLine } from './activity-line.js';
|
|
8
14
|
import { StatusLine } from './status.js';
|
|
9
15
|
import { Transcript } from './transcript.js';
|
|
10
|
-
import {
|
|
11
|
-
import { ApprovalModal, TuiApprovalBridge } from './approval.js';
|
|
12
|
-
import { PlanView } from './plan.js';
|
|
16
|
+
import { TuiApprovalBridge } from './approval.js';
|
|
13
17
|
import { parse as parseSlash } from '../cli/slash/parser.js';
|
|
18
|
+
import { tokens, glyphs } from './tokens.js';
|
|
14
19
|
import * as fs from 'node:fs';
|
|
15
20
|
import * as path from 'node:path';
|
|
16
21
|
import * as os from 'node:os';
|
|
17
22
|
import { spawnSync } from 'node:child_process';
|
|
18
|
-
let
|
|
19
|
-
function nextId(
|
|
20
|
-
|
|
21
|
-
return `${prefix}-${_itemCounter}`;
|
|
22
|
-
}
|
|
23
|
-
function getGitBranch(cwd) {
|
|
23
|
+
let _id = 0;
|
|
24
|
+
function nextId(p) { _id++; return `${p}-${_id}`; }
|
|
25
|
+
function getBranch(cwd) {
|
|
24
26
|
try {
|
|
25
27
|
const r = spawnSync('git', ['branch', '--show-current'], { cwd, encoding: 'utf-8', timeout: 800, windowsHide: true });
|
|
26
28
|
if (r.status === 0 && r.stdout)
|
|
27
29
|
return r.stdout.trim().slice(0, 40);
|
|
28
30
|
}
|
|
29
|
-
catch {
|
|
31
|
+
catch { }
|
|
30
32
|
return '';
|
|
31
33
|
}
|
|
32
34
|
function getHistoryPath() {
|
|
33
|
-
|
|
34
|
-
return path.join(home, '.klyro', 'history');
|
|
35
|
-
}
|
|
36
|
-
function loadHistory(cwd) {
|
|
37
|
-
try {
|
|
38
|
-
const raw = fs.readFileSync(getHistoryPath(), 'utf-8');
|
|
39
|
-
const lines = raw.split('\n').filter(Boolean);
|
|
40
|
-
const out = [];
|
|
41
|
-
for (const line of lines) {
|
|
42
|
-
try {
|
|
43
|
-
const obj = JSON.parse(line);
|
|
44
|
-
if (obj.cwd === cwd && typeof obj.text === 'string')
|
|
45
|
-
out.push(obj.text);
|
|
46
|
-
}
|
|
47
|
-
catch {
|
|
48
|
-
// legacy plain text per line
|
|
49
|
-
if (line.trim())
|
|
50
|
-
out.push(line.trim());
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
return out.slice(-200);
|
|
54
|
-
}
|
|
55
|
-
catch {
|
|
56
|
-
return [];
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
function appendHistory(cwd, text) {
|
|
60
|
-
try {
|
|
61
|
-
const p = getHistoryPath();
|
|
62
|
-
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
63
|
-
const entry = JSON.stringify({ cwd, text, ts: Date.now() });
|
|
64
|
-
fs.appendFileSync(p, entry + '\n', 'utf-8');
|
|
65
|
-
}
|
|
66
|
-
catch { /* ignore */ }
|
|
35
|
+
return path.join(os.homedir() || process.cwd(), '.klyro', 'history');
|
|
67
36
|
}
|
|
68
37
|
export function App(props) {
|
|
69
|
-
|
|
38
|
+
// Scrollback — committed once to <Static>, never re-rendered (discipline 1)
|
|
39
|
+
const [staticItems, setStaticItems] = useState(props.initialTranscript ?? []);
|
|
40
|
+
// Live region — only current turn's streaming text / active tool / activity
|
|
41
|
+
const [liveText, setLiveText] = useState('');
|
|
42
|
+
const [liveThinking, setLiveThinking] = useState('');
|
|
43
|
+
const [isThinkingExpanded, setThinkingExpanded] = useState(false);
|
|
44
|
+
const [activity, setActivity] = useState(null);
|
|
70
45
|
const [input, setInput] = useState('');
|
|
71
46
|
const [bridge] = useState(() => props.approvalBridge ?? new TuiApprovalBridge());
|
|
72
47
|
const [awaitingApproval, setAwaitingApproval] = useState(false);
|
|
@@ -82,43 +57,79 @@ export function App(props) {
|
|
|
82
57
|
status: 'idle',
|
|
83
58
|
...props.initialStatus,
|
|
84
59
|
});
|
|
85
|
-
const [history, setHistory] = useState(() =>
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
60
|
+
const [history, setHistory] = useState(() => {
|
|
61
|
+
try {
|
|
62
|
+
const raw = fs.readFileSync(getHistoryPath(), 'utf-8');
|
|
63
|
+
return raw.split('\n').filter(Boolean).slice(-200).map((l) => {
|
|
64
|
+
try {
|
|
65
|
+
const o = JSON.parse(l);
|
|
66
|
+
return o.cwd === props.cwd ? o.text ?? '' : '';
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return l;
|
|
70
|
+
}
|
|
71
|
+
}).filter(Boolean);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
const histIdx = useRef(-1);
|
|
78
|
+
const lastCtrlC = useRef(0);
|
|
89
79
|
const [queued, setQueued] = useState(null);
|
|
80
|
+
const [gitBranch, setGitBranch] = useState(() => getBranch(props.cwd));
|
|
81
|
+
const batchRef = useRef('');
|
|
82
|
+
const batchTimer = useRef(null);
|
|
90
83
|
useEffect(() => {
|
|
91
|
-
const t = setInterval(() => setGitBranch(
|
|
84
|
+
const t = setInterval(() => setGitBranch(getBranch(props.cwd)), 5000);
|
|
92
85
|
return () => clearInterval(t);
|
|
93
86
|
}, [props.cwd]);
|
|
94
|
-
useEffect(() =>
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
return [...prev, item];
|
|
108
|
-
});
|
|
109
|
-
}, []);
|
|
110
|
-
const updateStatus = useCallback((s) => {
|
|
111
|
-
setStatus((prev) => ({ ...prev, ...s }));
|
|
112
|
-
}, []);
|
|
113
|
-
const updatePlan = useCallback((p) => {
|
|
114
|
-
setPlan(p);
|
|
115
|
-
setPlanExpanded(true);
|
|
87
|
+
useEffect(() => bridge.subscribe((p) => setAwaitingApproval(p !== null)), [bridge]);
|
|
88
|
+
// Batched delta handler — 30fps (discipline 3)
|
|
89
|
+
const flushBatch = useCallback(() => {
|
|
90
|
+
if (batchRef.current) {
|
|
91
|
+
const chunk = batchRef.current;
|
|
92
|
+
batchRef.current = '';
|
|
93
|
+
setLiveText((prev) => prev + chunk);
|
|
94
|
+
}
|
|
95
|
+
if (batchTimer.current) {
|
|
96
|
+
clearTimeout(batchTimer.current);
|
|
97
|
+
batchTimer.current = null;
|
|
98
|
+
}
|
|
116
99
|
}, []);
|
|
100
|
+
const appendDeltaBatched = useCallback((text) => {
|
|
101
|
+
batchRef.current += text;
|
|
102
|
+
if (!batchTimer.current) {
|
|
103
|
+
batchTimer.current = setTimeout(flushBatch, 33); // ~30fps
|
|
104
|
+
}
|
|
105
|
+
}, [flushBatch]);
|
|
106
|
+
// Commit live region to scrollback atomically (discipline 1)
|
|
107
|
+
const commitLive = useCallback(() => {
|
|
108
|
+
flushBatch();
|
|
109
|
+
if (liveText) {
|
|
110
|
+
const item = { id: nextId('text'), kind: 'text', text: liveText, role: 'assistant' };
|
|
111
|
+
setStaticItems((prev) => [...prev, item]);
|
|
112
|
+
setLiveText('');
|
|
113
|
+
}
|
|
114
|
+
if (liveThinking) {
|
|
115
|
+
// Thinking is not committed unless expanded — spec says collapsed by default
|
|
116
|
+
setLiveThinking('');
|
|
117
|
+
}
|
|
118
|
+
setActivity(null);
|
|
119
|
+
}, [liveText, liveThinking, flushBatch]);
|
|
120
|
+
const appendStatic = useCallback((item) => {
|
|
121
|
+
// If live text is pending, commit first
|
|
122
|
+
if (liveText)
|
|
123
|
+
commitLive();
|
|
124
|
+
setStaticItems((prev) => [...prev, item]);
|
|
125
|
+
}, [liveText, commitLive]);
|
|
126
|
+
const updateStatus = useCallback((s) => setStatus((p) => ({ ...p, ...s })), []);
|
|
127
|
+
const updatePlan = useCallback((p) => { setPlan(p); setPlanExpanded(true); }, []);
|
|
117
128
|
const onMountedRef = useRef(props.onMounted);
|
|
118
129
|
useEffect(() => { onMountedRef.current = props.onMounted; }, [props.onMounted]);
|
|
119
130
|
useEffect(() => {
|
|
120
|
-
onMountedRef.current?.({ append, updateStatus, updatePlan });
|
|
121
|
-
globalThis.__klyroAppAppend =
|
|
131
|
+
onMountedRef.current?.({ append: appendStatic, updateStatus, updatePlan });
|
|
132
|
+
globalThis.__klyroAppAppend = appendStatic;
|
|
122
133
|
globalThis.__klyroAppStatus = updateStatus;
|
|
123
134
|
globalThis.__klyroAppPlan = updatePlan;
|
|
124
135
|
return () => {
|
|
@@ -126,19 +137,14 @@ export function App(props) {
|
|
|
126
137
|
delete globalThis.__klyroAppStatus;
|
|
127
138
|
delete globalThis.__klyroAppPlan;
|
|
128
139
|
};
|
|
129
|
-
}, [
|
|
130
|
-
//
|
|
131
|
-
const [rawModeWarning, setRawModeWarning] = useState(null);
|
|
140
|
+
}, [appendStatic, updateStatus, updatePlan]);
|
|
141
|
+
// Also handle batched text via global hook for streaming
|
|
132
142
|
useEffect(() => {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
catch { /* ignore */ }
|
|
140
|
-
}, []);
|
|
141
|
-
// Queue next message if typed during stream (2.4)
|
|
143
|
+
const origAppend = appendStatic;
|
|
144
|
+
globalThis.__klyroAppendDelta = appendDeltaBatched;
|
|
145
|
+
return () => { delete globalThis.__klyroAppendDelta; };
|
|
146
|
+
}, [appendDeltaBatched, appendStatic]);
|
|
147
|
+
// Queue handling (2.4)
|
|
142
148
|
useEffect(() => {
|
|
143
149
|
if (queued && status.status !== 'running' && !awaitingApproval) {
|
|
144
150
|
const toSend = queued;
|
|
@@ -146,84 +152,78 @@ export function App(props) {
|
|
|
146
152
|
const trimmed = toSend.trim();
|
|
147
153
|
if (!trimmed)
|
|
148
154
|
return;
|
|
149
|
-
|
|
155
|
+
const item = { id: nextId('text'), kind: 'text', text: toSend, role: 'user' };
|
|
156
|
+
setStaticItems((prev) => [...prev, item]);
|
|
157
|
+
try {
|
|
158
|
+
fs.mkdirSync(path.dirname(getHistoryPath()), { recursive: true });
|
|
159
|
+
fs.appendFileSync(getHistoryPath(), JSON.stringify({ cwd: props.cwd, text: toSend, ts: Date.now() }) + '\n');
|
|
160
|
+
}
|
|
161
|
+
catch { }
|
|
150
162
|
const cmd = parseSlash(trimmed);
|
|
151
163
|
if (cmd.kind === 'prompt')
|
|
152
164
|
void props.onPrompt(cmd.text);
|
|
153
|
-
else if (cmd.kind === 'plan') {
|
|
154
|
-
if (plan.length > 0)
|
|
155
|
-
setPlanExpanded((v) => !v);
|
|
156
|
-
}
|
|
157
165
|
else
|
|
158
166
|
void props.onSlash(cmd);
|
|
159
167
|
}
|
|
160
|
-
}, [queued, status.status, awaitingApproval
|
|
168
|
+
}, [queued, status.status, awaitingApproval]);
|
|
169
|
+
// Single useInput owner (discipline 4)
|
|
161
170
|
useInput((inputStr, key) => {
|
|
162
171
|
if (awaitingApproval)
|
|
163
|
-
return;
|
|
172
|
+
return; // approval modal owns input
|
|
164
173
|
if (status.status === 'running') {
|
|
165
174
|
if (key.ctrl && inputStr === 'c') {
|
|
166
175
|
void props.onSlash({ kind: 'quit' });
|
|
167
176
|
return;
|
|
168
177
|
}
|
|
169
178
|
if (key.return) {
|
|
170
|
-
const
|
|
171
|
-
if (!
|
|
179
|
+
const v = input.trim();
|
|
180
|
+
if (!v)
|
|
172
181
|
return;
|
|
173
|
-
setQueued(
|
|
182
|
+
setQueued(v);
|
|
174
183
|
setInput('');
|
|
175
|
-
|
|
184
|
+
// Show queued indicator in live region
|
|
176
185
|
return;
|
|
177
186
|
}
|
|
178
|
-
if (
|
|
179
|
-
|
|
187
|
+
if (key.ctrl && inputStr === 't') {
|
|
188
|
+
setThinkingExpanded((v) => !v);
|
|
180
189
|
return;
|
|
181
190
|
}
|
|
182
191
|
return;
|
|
183
192
|
}
|
|
184
|
-
// History navigation
|
|
185
193
|
if (key.upArrow) {
|
|
186
194
|
if (history.length === 0)
|
|
187
195
|
return;
|
|
188
|
-
if (
|
|
189
|
-
|
|
190
|
-
else if (
|
|
191
|
-
|
|
192
|
-
setInput(history[
|
|
196
|
+
if (histIdx.current === -1)
|
|
197
|
+
histIdx.current = history.length - 1;
|
|
198
|
+
else if (histIdx.current > 0)
|
|
199
|
+
histIdx.current--;
|
|
200
|
+
setInput(history[histIdx.current] ?? '');
|
|
193
201
|
return;
|
|
194
202
|
}
|
|
195
203
|
if (key.downArrow) {
|
|
196
|
-
if (
|
|
204
|
+
if (histIdx.current === -1)
|
|
197
205
|
return;
|
|
198
|
-
|
|
199
|
-
if (
|
|
200
|
-
|
|
206
|
+
histIdx.current++;
|
|
207
|
+
if (histIdx.current >= history.length) {
|
|
208
|
+
histIdx.current = -1;
|
|
201
209
|
setInput('');
|
|
202
210
|
}
|
|
203
|
-
else
|
|
204
|
-
setInput(history[
|
|
205
|
-
}
|
|
211
|
+
else
|
|
212
|
+
setInput(history[histIdx.current] ?? '');
|
|
206
213
|
return;
|
|
207
214
|
}
|
|
208
|
-
// Ctrl+R search — simple: cycle history
|
|
209
215
|
if (key.ctrl && inputStr === 'r') {
|
|
210
|
-
if (history.length === 0)
|
|
211
|
-
return;
|
|
212
216
|
const term = input.toLowerCase();
|
|
213
|
-
for (let i = history.length - 1; i >= 0; i--)
|
|
217
|
+
for (let i = history.length - 1; i >= 0; i--)
|
|
214
218
|
if (history[i].toLowerCase().includes(term)) {
|
|
215
219
|
setInput(history[i]);
|
|
216
220
|
return;
|
|
217
221
|
}
|
|
218
|
-
}
|
|
219
222
|
return;
|
|
220
223
|
}
|
|
221
224
|
if (key.return) {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const isShiftEnter = key.shift === true;
|
|
225
|
-
if (isShiftEnter || input.endsWith('\\')) {
|
|
226
|
-
// Replace trailing \ with newline, or just add newline for Shift+Enter
|
|
225
|
+
const isShift = key.shift === true;
|
|
226
|
+
if (isShift || input.endsWith('\\')) {
|
|
227
227
|
if (input.endsWith('\\'))
|
|
228
228
|
setInput((v) => v.slice(0, -1) + '\n');
|
|
229
229
|
else
|
|
@@ -231,87 +231,78 @@ export function App(props) {
|
|
|
231
231
|
return;
|
|
232
232
|
}
|
|
233
233
|
const value = input;
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
if (!trimmedOuter) {
|
|
234
|
+
const trimmed = value.replace(/^\s+|\s+$/g, '');
|
|
235
|
+
if (!trimmed) {
|
|
237
236
|
setInput('');
|
|
238
237
|
return;
|
|
239
238
|
}
|
|
240
|
-
//
|
|
241
|
-
if (
|
|
242
|
-
const atPath =
|
|
239
|
+
// @ and ! handling
|
|
240
|
+
if (trimmed.startsWith('@')) {
|
|
241
|
+
const atPath = trimmed.slice(1).trim().split(' ')[0] ?? '';
|
|
243
242
|
setInput('');
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const atText = `Reference file: ${atPath}`;
|
|
247
|
-
void props.onPrompt(atText);
|
|
243
|
+
setStaticItems((prev) => [...prev, { id: nextId('text'), kind: 'text', text: value, role: 'user' }]);
|
|
244
|
+
void props.onPrompt(`Reference file: ${atPath}`);
|
|
248
245
|
return;
|
|
249
246
|
}
|
|
250
|
-
if (
|
|
251
|
-
const cmdText =
|
|
247
|
+
if (trimmed.startsWith('!')) {
|
|
248
|
+
const cmdText = trimmed.slice(1).trim();
|
|
252
249
|
setInput('');
|
|
253
|
-
|
|
250
|
+
setStaticItems((prev) => [...prev, { id: nextId('text'), kind: 'text', text: value, role: 'user' }]);
|
|
254
251
|
import('../tools/shell/shell-exec.js').then(async ({ shellExecTool }) => {
|
|
255
252
|
const { builtinRegistry } = await import('../tools/registry.js');
|
|
256
253
|
const reg = builtinRegistry();
|
|
257
254
|
const r = await reg.execute('shell_exec', { command: cmdText }, { cwd: props.cwd, env: process.env, nonInteractive: true });
|
|
258
255
|
const out = r.ok ? JSON.stringify(r.value).slice(0, 500) : String(r.error.message);
|
|
259
|
-
|
|
256
|
+
setStaticItems((prev) => [...prev, { id: nextId('text'), kind: 'text', text: `!${cmdText}\n${out}`, role: 'assistant' }]);
|
|
260
257
|
});
|
|
261
258
|
return;
|
|
262
259
|
}
|
|
263
|
-
if (
|
|
264
|
-
const note =
|
|
265
|
-
// Append to .klyro/memory/session-notes.md
|
|
260
|
+
if (trimmed.startsWith('# ')) {
|
|
261
|
+
const note = trimmed.slice(2).trim();
|
|
266
262
|
import('node:fs/promises').then(async (fs) => {
|
|
267
|
-
const p =
|
|
268
|
-
await fs.mkdir(
|
|
263
|
+
const p = path.join(props.cwd, '.klyro', 'memory', 'session-notes.md');
|
|
264
|
+
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
269
265
|
await fs.appendFile(p, `- ${note}\n`, 'utf-8');
|
|
270
266
|
});
|
|
271
267
|
setInput('');
|
|
272
|
-
|
|
268
|
+
setStaticItems((prev) => [...prev, { id: nextId('text'), kind: 'text', text: `Note saved: ${note}`, role: 'assistant' }]);
|
|
273
269
|
return;
|
|
274
270
|
}
|
|
275
|
-
// Check for Ctrl+C double at empty prompt handled below, but here handle submit
|
|
276
271
|
setInput('');
|
|
277
|
-
|
|
278
|
-
// Save to history
|
|
272
|
+
histIdx.current = -1;
|
|
279
273
|
setHistory((prev) => {
|
|
280
274
|
const next = [...prev, value];
|
|
281
|
-
|
|
275
|
+
try {
|
|
276
|
+
fs.mkdirSync(path.dirname(getHistoryPath()), { recursive: true });
|
|
277
|
+
fs.appendFileSync(getHistoryPath(), JSON.stringify({ cwd: props.cwd, text: value, ts: Date.now() }) + '\n');
|
|
278
|
+
}
|
|
279
|
+
catch { }
|
|
282
280
|
return next.slice(-200);
|
|
283
281
|
});
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
282
|
+
const userItem = { id: nextId('text'), kind: 'text', text: value, role: 'user' };
|
|
283
|
+
setStaticItems((prev) => [...prev, userItem]);
|
|
284
|
+
const cmd = parseSlash(trimmed);
|
|
285
|
+
if (cmd.kind === 'prompt')
|
|
287
286
|
void props.onPrompt(cmd.text);
|
|
288
|
-
|
|
289
|
-
else if (cmd.kind === 'plan') {
|
|
290
|
-
if (plan.length > 0)
|
|
291
|
-
setPlanExpanded((v) => !v);
|
|
292
|
-
}
|
|
293
|
-
else {
|
|
287
|
+
else
|
|
294
288
|
void props.onSlash(cmd);
|
|
295
|
-
}
|
|
296
289
|
return;
|
|
297
290
|
}
|
|
298
291
|
if (key.backspace || key.delete) {
|
|
299
292
|
setInput((v) => v.slice(0, -1));
|
|
300
293
|
return;
|
|
301
294
|
}
|
|
302
|
-
// Ctrl+C double at empty prompt exits
|
|
303
295
|
if (key.ctrl && inputStr === 'c') {
|
|
304
296
|
if (input === '') {
|
|
305
297
|
const now = Date.now();
|
|
306
|
-
if (now -
|
|
298
|
+
if (now - lastCtrlC.current < 1500) {
|
|
307
299
|
void props.onSlash({ kind: 'quit' });
|
|
308
300
|
return;
|
|
309
301
|
}
|
|
310
|
-
|
|
311
|
-
|
|
302
|
+
lastCtrlC.current = now;
|
|
303
|
+
setStaticItems((prev) => [...prev, { id: nextId('text'), kind: 'text', text: '(press Ctrl+C again to exit)', role: 'assistant' }]);
|
|
312
304
|
return;
|
|
313
305
|
}
|
|
314
|
-
// Single Ctrl+C cancels input
|
|
315
306
|
setInput('');
|
|
316
307
|
return;
|
|
317
308
|
}
|
|
@@ -320,18 +311,18 @@ export function App(props) {
|
|
|
320
311
|
return;
|
|
321
312
|
}
|
|
322
313
|
if (key.ctrl && inputStr === 'l') {
|
|
323
|
-
|
|
324
|
-
|
|
314
|
+
setStaticItems((prev) => [...prev, { id: nextId('text'), kind: 'text', text: '(cleared)', role: 'assistant' }]);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (key.ctrl && inputStr === 't') {
|
|
318
|
+
setThinkingExpanded((v) => !v);
|
|
325
319
|
return;
|
|
326
320
|
}
|
|
327
|
-
// Handle bracketed paste: inputStr may contain \r\n or multiple lines
|
|
328
321
|
if (!key.ctrl && !key.meta) {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
const normalized = inputStr.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
332
|
-
setInput((v) => v + normalized);
|
|
322
|
+
const norm = inputStr.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
323
|
+
setInput((v) => v + norm);
|
|
333
324
|
}
|
|
334
325
|
});
|
|
335
326
|
const promptStr = `klyro › ${path.basename(props.cwd)}${gitBranch ? ` (${gitBranch})` : ''}`;
|
|
336
|
-
return (_jsxs(Box, { flexDirection: "column", width: "100%",
|
|
327
|
+
return (_jsxs(Box, { flexDirection: "column", width: "100%", children: [_jsx(Banner, { version: "0.1.8", cwd: props.cwd, branch: gitBranch, model: status.model, klyroMdLoaded: false, packageManager: "pnpm", testRunner: "vitest" }), _jsxs(Box, { flexDirection: "column", width: "100%", children: [_jsxs(Box, { borderStyle: "single", borderColor: tokens.ansi.accent, paddingX: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Box, { children: [_jsx(Text, { color: tokens.ansi.accent, bold: true, children: "KLYRO" }), _jsxs(Text, { color: tokens.ansi.muted, children: [" ", path.basename(props.cwd)] })] }), _jsx(Box, { children: _jsxs(Text, { color: tokens.ansi.muted, children: [status.model, " step ", status.step, "/", status.maxSteps] }) })] }), _jsx(StatusLine, { snapshot: status })] }), _jsx(Static, { items: staticItems, children: (item) => (_jsx(Box, { flexDirection: "column", width: "100%", children: item.kind === 'text' && item.role === 'user' ? (_jsxs(Box, { children: [_jsxs(Text, { color: tokens.ansi.accent, children: [glyphs.prompt, " "] }), _jsx(Text, { children: item.text })] })) : item.kind === 'text' ? (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { children: item.text }) })) : (_jsx(Transcript, { items: [item] })) }, item.id)) }), _jsxs(Box, { flexDirection: "column", width: "100%", children: [liveThinking ? (_jsxs(Box, { paddingX: 1, children: [_jsx(Text, { color: tokens.ansi.muted, dimColor: true, children: "\u2234 Thinking\u2026" }), isThinkingExpanded ? _jsxs(Text, { color: tokens.ansi.muted, dimColor: true, children: [" ", liveThinking] }) : _jsx(Text, { color: tokens.ansi.muted, dimColor: true, children: " ctrl+t to show" })] })) : null, liveText ? (_jsx(Box, { paddingX: 1, flexDirection: "column", children: _jsxs(Text, { children: [liveText, "\u258D"] }) })) : null, activity ? (_jsx(ActivityLine, { verb: activity.verb, elapsedMs: Date.now() - activity.start, hint: "esc to interrupt" })) : null, queued ? (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: tokens.ansi.muted, dimColor: true, children: ["\u23F3 queued: \"", queued.slice(0, 60), "\""] }) })) : null, _jsxs(Box, { borderStyle: "round", borderColor: awaitingApproval ? tokens.ansi.warning : tokens.ansi.accent, paddingX: 1, children: [_jsxs(Text, { color: tokens.ansi.muted, dimColor: true, children: [promptStr, " "] }), _jsxs(Text, { children: [input, "\u258F"] })] }), _jsxs(Box, { paddingX: 1, justifyContent: "space-between", children: [_jsx(Text, { color: tokens.ansi.muted, dimColor: true, children: "Tab: slash completion \u00B7 Shift+Enter: newline \u00B7 Ctrl+C twice: exit" }), _jsxs(Text, { color: tokens.ansi.muted, dimColor: true, children: [status.model, " \u00B7 ctx ", Math.round(((status.usageInput + status.usageOutput) / 128000) * 100), "% \u00B7 $", ((status.usageInput / 1000) * 0.003 + (status.usageOutput / 1000) * 0.015).toFixed(2)] })] })] })] }));
|
|
337
328
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ink app root — status line + scrollable transcript + input box.
|
|
3
|
+
* 1.4: history per project, multiline, Ctrl+C double, slash registry, Windows handling
|
|
4
|
+
*/
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import { type StatusSnapshot } from './status.js';
|
|
7
|
+
import { type TranscriptItem } from './transcript.js';
|
|
8
|
+
import { TuiApprovalBridge } from './approval.js';
|
|
9
|
+
import type { PlanStep } from '../agent/runtime.js';
|
|
10
|
+
export interface AppProps {
|
|
11
|
+
initialModel: string;
|
|
12
|
+
maxSteps: number;
|
|
13
|
+
cwd: string;
|
|
14
|
+
onPrompt: (text: string) => void | Promise<void>;
|
|
15
|
+
onSlash: (cmd: import('../cli/slash/parser.js').SlashCommand) => void | Promise<void>;
|
|
16
|
+
initialTranscript?: TranscriptItem[];
|
|
17
|
+
initialStatus?: Partial<StatusSnapshot>;
|
|
18
|
+
approvalBridge?: TuiApprovalBridge;
|
|
19
|
+
onMounted?: (hooks: {
|
|
20
|
+
append: (i: TranscriptItem) => void;
|
|
21
|
+
updateStatus: (s: Partial<StatusSnapshot>) => void;
|
|
22
|
+
updatePlan: (p: PlanStep[]) => void;
|
|
23
|
+
}) => void;
|
|
24
|
+
}
|
|
25
|
+
export declare function App(props: AppProps): React.JSX.Element;
|