klyro 0.1.9 → 0.1.10

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/tui/app.d.ts CHANGED
@@ -1,10 +1,6 @@
1
1
  /**
2
- * Klyro TUI v2 inline/scrollback architecture per TUI_DESIGN.md §1, §10-11
3
- * Four disciplines:
4
- * 1. History in <Static> — never re-rendered
5
- * 2. Only live region is dynamic
6
- * 3. Stream deltas batched at ~30fps
7
- * 4. Exactly one useInput owner
2
+ * Klyro Full-Screen TUI — TUI_DESIGN.md §2, §24, §38 (Phase 1-4)
3
+ * Full viewport, conversation, input, status bar — professional, dense, terminal-native
8
4
  */
9
5
  import React from 'react';
10
6
  import { type StatusSnapshot } from './status.js';
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Klyro Full-Screen TUI — TUI_DESIGN.md §2, §24, §38 (Phase 1-4)
3
+ * Full viewport, conversation, input, status bar — professional, dense, terminal-native
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;
@@ -0,0 +1,97 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Klyro Full-Screen TUI — TUI_DESIGN.md §2, §24, §38 (Phase 1-4)
4
+ * Full viewport, conversation, input, status bar — professional, dense, terminal-native
5
+ */
6
+ import { useState, useEffect, useRef, useCallback } from 'react';
7
+ import { Box, Text, useInput, useStdout } from 'ink';
8
+ import { TuiApprovalBridge } from './approval.js';
9
+ import { PlanView } from './plan.js';
10
+ import { parse as parseSlash } from '../cli/slash/parser.js';
11
+ import { tokens } from './tokens.js';
12
+ let _id = 0;
13
+ function nextId(p) { _id++; return `${p}-${_id}`; }
14
+ export function App(props) {
15
+ const { stdout } = useStdout();
16
+ const [transcript, setTranscript] = useState(props.initialTranscript ?? []);
17
+ const [input, setInput] = useState('');
18
+ const [bridge] = useState(() => props.approvalBridge ?? new TuiApprovalBridge());
19
+ const [awaitingApproval, setAwaitingApproval] = useState(false);
20
+ const [plan, setPlan] = useState([]);
21
+ const [status, setStatus] = useState({
22
+ model: props.initialModel,
23
+ step: 0,
24
+ maxSteps: props.maxSteps,
25
+ usageInput: 0,
26
+ usageOutput: 0,
27
+ repairs: 0,
28
+ status: 'idle',
29
+ ...props.initialStatus,
30
+ });
31
+ const [elapsed, setElapsed] = useState(0);
32
+ useEffect(() => bridge.subscribe((p) => setAwaitingApproval(p !== null)), [bridge]);
33
+ useEffect(() => {
34
+ if (status.status !== 'running')
35
+ return;
36
+ const start = Date.now() - elapsed;
37
+ const t = setInterval(() => setElapsed(Date.now() - start), 1000);
38
+ return () => clearInterval(t);
39
+ }, [status.status, elapsed]);
40
+ const append = useCallback((item) => {
41
+ setTranscript((prev) => {
42
+ const last = prev[prev.length - 1];
43
+ if (last?.kind === 'text' && item.kind === 'text' && last.role === 'assistant' && item.role === 'assistant' && last.id === item.id) {
44
+ return [...prev.slice(0, -1), { ...last, text: last.text + item.text }];
45
+ }
46
+ return [...prev, item];
47
+ });
48
+ }, []);
49
+ const updateStatus = useCallback((s) => setStatus((p) => ({ ...p, ...s })), []);
50
+ const updatePlan = useCallback((p) => setPlan(p), []);
51
+ const onMountedRef = useRef(props.onMounted);
52
+ useEffect(() => { onMountedRef.current = props.onMounted; }, [props.onMounted]);
53
+ useEffect(() => {
54
+ onMountedRef.current?.({ append, updateStatus, updatePlan });
55
+ globalThis.__klyroAppAppend = append;
56
+ globalThis.__klyroAppStatus = updateStatus;
57
+ globalThis.__klyroAppPlan = updatePlan;
58
+ return () => {
59
+ delete globalThis.__klyroAppAppend;
60
+ delete globalThis.__klyroAppStatus;
61
+ delete globalThis.__klyroAppPlan;
62
+ };
63
+ }, [append, updateStatus, updatePlan]);
64
+ // Single useInput owner
65
+ useInput((inputStr, key) => {
66
+ if (awaitingApproval)
67
+ return;
68
+ if (status.status === 'running' && key.ctrl && inputStr === 'c') {
69
+ void props.onSlash({ kind: 'quit' });
70
+ return;
71
+ }
72
+ if (key.return) {
73
+ const v = input.trim();
74
+ if (!v)
75
+ return;
76
+ setInput('');
77
+ const item = { id: nextId('text'), kind: 'text', text: v, role: 'user' };
78
+ setTranscript((prev) => [...prev, item]);
79
+ const cmd = parseSlash(v);
80
+ if (cmd.kind === 'prompt')
81
+ void props.onPrompt(cmd.text);
82
+ else
83
+ void props.onSlash(cmd);
84
+ return;
85
+ }
86
+ if (key.backspace || key.delete) {
87
+ setInput((v) => v.slice(0, -1));
88
+ return;
89
+ }
90
+ if (!key.ctrl && !key.meta)
91
+ setInput((v) => v + inputStr);
92
+ });
93
+ const width = stdout?.columns ?? 100;
94
+ const height = stdout?.rows ?? 30;
95
+ const isSmall = width < 80;
96
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height - 1, children: [_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: tokens.ansi.border, paddingX: 1, children: [_jsx(Text, { bold: true, children: "KLYRO v0.1.9" }), _jsxs(Text, { color: tokens.ansi.muted, children: [status.model, " \u00B7 API Usage Billing \u00B7 step ", status.step, "/", status.maxSteps, " \u00B7 ", status.status, " \u00B7 repairs ", status.repairs] }), _jsx(Text, { color: tokens.ansi.muted, children: props.cwd })] }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", paddingX: 1, paddingY: 1, children: [transcript.length === 0 ? (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { color: tokens.ansi.muted, children: "No conversation yet. Try \"fix the failing login test\"" }) })) : (transcript.map((item) => (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: item.kind === 'text' && item.role === 'user' ? (_jsxs(Text, { children: ["\u203A ", item.text] })) : item.kind === 'text' ? (_jsxs(Text, { children: [" ", item.text] })) : item.kind === 'tool' ? (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: tokens.ansi.border, paddingX: 1, children: [_jsxs(Text, { children: [item.name, " ", item.isError ? '✗' : '✓', " ", item.latencyMs ?? 0, "ms"] }), item.result ? _jsx(Text, { color: tokens.ansi.muted, children: String(item.result).slice(0, 200) }) : null] })) : (_jsx(Text, { children: JSON.stringify(item).slice(0, 100) })) }, item.id)))), status.status === 'running' ? (_jsxs(Box, { children: [_jsx(Text, { color: tokens.ansi.info, children: "\u2726 Thinking..." }), _jsxs(Text, { color: tokens.ansi.muted, children: [" \u00B7 ", Math.round(elapsed / 1000), "s"] })] })) : null, plan.length > 0 ? _jsx(PlanView, { steps: plan, expanded: false, onToggle: () => { } }) : null] }), _jsxs(Box, { borderStyle: "single", borderColor: tokens.ansi.accent, paddingX: 1, children: [_jsx(Text, { children: "\u203A " }), _jsxs(Text, { children: [input, "\u258F"] })] }), _jsxs(Box, { justifyContent: "space-between", paddingX: 1, borderStyle: "single", borderColor: tokens.ansi.border, children: [_jsxs(Text, { color: tokens.ansi.muted, children: [status.model, " \u00B7 ", status.usageInput + status.usageOutput, " tokens \u00B7 $", (status.usageInput / 1000 * 0.003 + status.usageOutput / 1000 * 0.015).toFixed(2), " \u00B7 ", Math.round(elapsed / 1000), "s"] }), _jsx(Text, { color: tokens.ansi.muted, children: isSmall ? 'Ctrl+C interrupt' : 'Ctrl+C interrupt · Ctrl+O expand · ↑↓ scroll' })] })] }));
97
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Klyro TUI v2 — inline/scrollback architecture per TUI_DESIGN.md §1, §10-11
3
+ * Four disciplines:
4
+ * 1. History in <Static> — never re-rendered
5
+ * 2. Only live region is dynamic
6
+ * 3. Stream deltas batched at ~30fps
7
+ * 4. Exactly one useInput owner
8
+ */
9
+ import React from 'react';
10
+ import { type StatusSnapshot } from './status.js';
11
+ import { type TranscriptItem } from './transcript.js';
12
+ import { TuiApprovalBridge } from './approval.js';
13
+ import type { PlanStep } from '../agent/runtime.js';
14
+ export interface AppProps {
15
+ initialModel: string;
16
+ maxSteps: number;
17
+ cwd: string;
18
+ onPrompt: (text: string) => void | Promise<void>;
19
+ onSlash: (cmd: import('../cli/slash/parser.js').SlashCommand) => void | Promise<void>;
20
+ initialTranscript?: TranscriptItem[];
21
+ initialStatus?: Partial<StatusSnapshot>;
22
+ approvalBridge?: TuiApprovalBridge;
23
+ onMounted?: (hooks: {
24
+ append: (i: TranscriptItem) => void;
25
+ updateStatus: (s: Partial<StatusSnapshot>) => void;
26
+ updatePlan: (p: PlanStep[]) => void;
27
+ }) => void;
28
+ }
29
+ export declare function App(props: AppProps): React.JSX.Element;
@@ -0,0 +1,336 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
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
9
+ */
10
+ import { useState, useCallback, useEffect, useRef } from 'react';
11
+ import { Box, Text, Static, useInput } from 'ink';
12
+ import { Banner } from './banner.js';
13
+ import { ActivityLine } from './activity-line.js';
14
+ import { StatusLine } from './status.js';
15
+ import { Transcript } from './transcript.js';
16
+ import { TuiApprovalBridge } from './approval.js';
17
+ import { parse as parseSlash } from '../cli/slash/parser.js';
18
+ import { tokens, glyphs } from './tokens.js';
19
+ import * as fs from 'node:fs';
20
+ import * as path from 'node:path';
21
+ import * as os from 'node:os';
22
+ import { spawnSync } from 'node:child_process';
23
+ let _id = 0;
24
+ function nextId(p) { _id++; return `${p}-${_id}`; }
25
+ function getBranch(cwd) {
26
+ try {
27
+ const r = spawnSync('git', ['branch', '--show-current'], { cwd, encoding: 'utf-8', timeout: 800, windowsHide: true });
28
+ if (r.status === 0 && r.stdout)
29
+ return r.stdout.trim().slice(0, 40);
30
+ }
31
+ catch { }
32
+ return '';
33
+ }
34
+ function getHistoryPath() {
35
+ return path.join(os.homedir() || process.cwd(), '.klyro', 'history');
36
+ }
37
+ export function App(props) {
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);
45
+ const [input, setInput] = useState('');
46
+ const [bridge] = useState(() => props.approvalBridge ?? new TuiApprovalBridge());
47
+ const [awaitingApproval, setAwaitingApproval] = useState(false);
48
+ const [plan, setPlan] = useState([]);
49
+ const [planExpanded, setPlanExpanded] = useState(false);
50
+ const [status, setStatus] = useState({
51
+ model: props.initialModel,
52
+ step: 0,
53
+ maxSteps: props.maxSteps,
54
+ usageInput: 0,
55
+ usageOutput: 0,
56
+ repairs: 0,
57
+ status: 'idle',
58
+ ...props.initialStatus,
59
+ });
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);
79
+ const [queued, setQueued] = useState(null);
80
+ const [gitBranch, setGitBranch] = useState(() => getBranch(props.cwd));
81
+ const batchRef = useRef('');
82
+ const batchTimer = useRef(null);
83
+ useEffect(() => {
84
+ const t = setInterval(() => setGitBranch(getBranch(props.cwd)), 5000);
85
+ return () => clearInterval(t);
86
+ }, [props.cwd]);
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
+ }
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); }, []);
128
+ const onMountedRef = useRef(props.onMounted);
129
+ useEffect(() => { onMountedRef.current = props.onMounted; }, [props.onMounted]);
130
+ useEffect(() => {
131
+ onMountedRef.current?.({ append: appendStatic, updateStatus, updatePlan });
132
+ globalThis.__klyroAppAppend = appendStatic;
133
+ globalThis.__klyroAppStatus = updateStatus;
134
+ globalThis.__klyroAppPlan = updatePlan;
135
+ return () => {
136
+ delete globalThis.__klyroAppAppend;
137
+ delete globalThis.__klyroAppStatus;
138
+ delete globalThis.__klyroAppPlan;
139
+ };
140
+ }, [appendStatic, updateStatus, updatePlan]);
141
+ // Also handle batched text via global hook for streaming
142
+ useEffect(() => {
143
+ const origAppend = appendStatic;
144
+ globalThis.__klyroAppendDelta = appendDeltaBatched;
145
+ return () => { delete globalThis.__klyroAppendDelta; };
146
+ }, [appendDeltaBatched, appendStatic]);
147
+ // Queue handling (2.4)
148
+ useEffect(() => {
149
+ if (queued && status.status !== 'running' && !awaitingApproval) {
150
+ const toSend = queued;
151
+ setQueued(null);
152
+ const trimmed = toSend.trim();
153
+ if (!trimmed)
154
+ return;
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 { }
162
+ const cmd = parseSlash(trimmed);
163
+ if (cmd.kind === 'prompt')
164
+ void props.onPrompt(cmd.text);
165
+ else
166
+ void props.onSlash(cmd);
167
+ }
168
+ }, [queued, status.status, awaitingApproval]);
169
+ // Single useInput owner (discipline 4)
170
+ useInput((inputStr, key) => {
171
+ if (awaitingApproval)
172
+ return; // approval modal owns input
173
+ if (status.status === 'running') {
174
+ if (key.ctrl && inputStr === 'c') {
175
+ void props.onSlash({ kind: 'quit' });
176
+ return;
177
+ }
178
+ if (key.return) {
179
+ const v = input.trim();
180
+ if (!v)
181
+ return;
182
+ setQueued(v);
183
+ setInput('');
184
+ appendStatic({ id: nextId('text'), kind: 'text', text: `queued: ${v.slice(0, 80)}`, role: 'assistant' });
185
+ return;
186
+ }
187
+ if (key.ctrl && inputStr === 't') {
188
+ setThinkingExpanded((v) => !v);
189
+ return;
190
+ }
191
+ if (key.backspace || key.delete) {
192
+ setInput((v) => v.slice(0, -1));
193
+ return;
194
+ }
195
+ if (!key.ctrl && !key.meta) {
196
+ const norm = inputStr.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
197
+ setInput((v) => v + norm);
198
+ }
199
+ return;
200
+ }
201
+ if (key.upArrow) {
202
+ if (history.length === 0)
203
+ return;
204
+ if (histIdx.current === -1)
205
+ histIdx.current = history.length - 1;
206
+ else if (histIdx.current > 0)
207
+ histIdx.current--;
208
+ setInput(history[histIdx.current] ?? '');
209
+ return;
210
+ }
211
+ if (key.downArrow) {
212
+ if (histIdx.current === -1)
213
+ return;
214
+ histIdx.current++;
215
+ if (histIdx.current >= history.length) {
216
+ histIdx.current = -1;
217
+ setInput('');
218
+ }
219
+ else
220
+ setInput(history[histIdx.current] ?? '');
221
+ return;
222
+ }
223
+ if (key.ctrl && inputStr === 'r') {
224
+ const term = input.toLowerCase();
225
+ for (let i = history.length - 1; i >= 0; i--)
226
+ if (history[i].toLowerCase().includes(term)) {
227
+ setInput(history[i]);
228
+ return;
229
+ }
230
+ return;
231
+ }
232
+ if (key.return) {
233
+ const isShift = key.shift === true;
234
+ if (isShift || input.endsWith('\\')) {
235
+ if (input.endsWith('\\'))
236
+ setInput((v) => v.slice(0, -1) + '\n');
237
+ else
238
+ setInput((v) => v + '\n');
239
+ return;
240
+ }
241
+ const value = input;
242
+ const trimmed = value.replace(/^\s+|\s+$/g, '');
243
+ if (!trimmed) {
244
+ setInput('');
245
+ return;
246
+ }
247
+ // @ and ! handling
248
+ if (trimmed.startsWith('@')) {
249
+ const atPath = trimmed.slice(1).trim().split(' ')[0] ?? '';
250
+ setInput('');
251
+ setStaticItems((prev) => [...prev, { id: nextId('text'), kind: 'text', text: value, role: 'user' }]);
252
+ void props.onPrompt(`Reference file: ${atPath}`);
253
+ return;
254
+ }
255
+ if (trimmed.startsWith('!')) {
256
+ const cmdText = trimmed.slice(1).trim();
257
+ setInput('');
258
+ setStaticItems((prev) => [...prev, { id: nextId('text'), kind: 'text', text: value, role: 'user' }]);
259
+ import('../tools/shell/shell-exec.js').then(async ({ shellExecTool }) => {
260
+ const { builtinRegistry } = await import('../tools/registry.js');
261
+ const reg = builtinRegistry();
262
+ const r = await reg.execute('shell_exec', { command: cmdText }, { cwd: props.cwd, env: process.env, nonInteractive: true });
263
+ const out = r.ok ? JSON.stringify(r.value).slice(0, 500) : String(r.error.message);
264
+ setStaticItems((prev) => [...prev, { id: nextId('text'), kind: 'text', text: `!${cmdText}\n${out}`, role: 'assistant' }]);
265
+ });
266
+ return;
267
+ }
268
+ if (trimmed.startsWith('# ')) {
269
+ const note = trimmed.slice(2).trim();
270
+ import('node:fs/promises').then(async (fs) => {
271
+ const p = path.join(props.cwd, '.klyro', 'memory', 'session-notes.md');
272
+ await fs.mkdir(path.dirname(p), { recursive: true });
273
+ await fs.appendFile(p, `- ${note}\n`, 'utf-8');
274
+ });
275
+ setInput('');
276
+ setStaticItems((prev) => [...prev, { id: nextId('text'), kind: 'text', text: `Note saved: ${note}`, role: 'assistant' }]);
277
+ return;
278
+ }
279
+ setInput('');
280
+ histIdx.current = -1;
281
+ setHistory((prev) => {
282
+ const next = [...prev, value];
283
+ try {
284
+ fs.mkdirSync(path.dirname(getHistoryPath()), { recursive: true });
285
+ fs.appendFileSync(getHistoryPath(), JSON.stringify({ cwd: props.cwd, text: value, ts: Date.now() }) + '\n');
286
+ }
287
+ catch { }
288
+ return next.slice(-200);
289
+ });
290
+ const userItem = { id: nextId('text'), kind: 'text', text: value, role: 'user' };
291
+ setStaticItems((prev) => [...prev, userItem]);
292
+ const cmd = parseSlash(trimmed);
293
+ if (cmd.kind === 'prompt')
294
+ void props.onPrompt(cmd.text);
295
+ else
296
+ void props.onSlash(cmd);
297
+ return;
298
+ }
299
+ if (key.backspace || key.delete) {
300
+ setInput((v) => v.slice(0, -1));
301
+ return;
302
+ }
303
+ if (key.ctrl && inputStr === 'c') {
304
+ if (input === '') {
305
+ const now = Date.now();
306
+ if (now - lastCtrlC.current < 1500) {
307
+ void props.onSlash({ kind: 'quit' });
308
+ return;
309
+ }
310
+ lastCtrlC.current = now;
311
+ setStaticItems((prev) => [...prev, { id: nextId('text'), kind: 'text', text: '(press Ctrl+C again to exit)', role: 'assistant' }]);
312
+ return;
313
+ }
314
+ setInput('');
315
+ return;
316
+ }
317
+ if (key.ctrl && inputStr === 'd') {
318
+ void props.onSlash({ kind: 'quit' });
319
+ return;
320
+ }
321
+ if (key.ctrl && inputStr === 'l') {
322
+ setStaticItems((prev) => [...prev, { id: nextId('text'), kind: 'text', text: '(cleared)', role: 'assistant' }]);
323
+ return;
324
+ }
325
+ if (key.ctrl && inputStr === 't') {
326
+ setThinkingExpanded((v) => !v);
327
+ return;
328
+ }
329
+ if (!key.ctrl && !key.meta) {
330
+ const norm = inputStr.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
331
+ setInput((v) => v + norm);
332
+ }
333
+ });
334
+ const promptStr = `klyro › ${path.basename(props.cwd)}${gitBranch ? ` (${gitBranch})` : ''}`;
335
+ 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)] })] })] })] }));
336
+ }
package/dist/tui/app.js CHANGED
@@ -1,52 +1,24 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
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
3
+ * Klyro Full-Screen TUI — TUI_DESIGN.md §2, §24, §38 (Phase 1-4)
4
+ * Full viewport, conversation, input, status bar — professional, dense, terminal-native
9
5
  */
10
- import { useState, useCallback, useEffect, useRef } from 'react';
11
- import { Box, Text, Static, useInput } from 'ink';
12
- import { Banner } from './banner.js';
13
- import { ActivityLine } from './activity-line.js';
14
- import { StatusLine } from './status.js';
6
+ import { useState, useEffect, useRef, useCallback } from 'react';
7
+ import { Box, Text, useInput, useStdout } from 'ink';
15
8
  import { Transcript } from './transcript.js';
16
9
  import { TuiApprovalBridge } from './approval.js';
10
+ import { PlanView } from './plan.js';
17
11
  import { parse as parseSlash } from '../cli/slash/parser.js';
18
- import { tokens, glyphs } from './tokens.js';
19
- import * as fs from 'node:fs';
20
- import * as path from 'node:path';
21
- import * as os from 'node:os';
22
- import { spawnSync } from 'node:child_process';
12
+ import { tokens } from './tokens.js';
23
13
  let _id = 0;
24
14
  function nextId(p) { _id++; return `${p}-${_id}`; }
25
- function getBranch(cwd) {
26
- try {
27
- const r = spawnSync('git', ['branch', '--show-current'], { cwd, encoding: 'utf-8', timeout: 800, windowsHide: true });
28
- if (r.status === 0 && r.stdout)
29
- return r.stdout.trim().slice(0, 40);
30
- }
31
- catch { }
32
- return '';
33
- }
34
- function getHistoryPath() {
35
- return path.join(os.homedir() || process.cwd(), '.klyro', 'history');
36
- }
37
15
  export function App(props) {
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);
16
+ const { stdout } = useStdout();
17
+ const [transcript, setTranscript] = useState(props.initialTranscript ?? []);
45
18
  const [input, setInput] = useState('');
46
19
  const [bridge] = useState(() => props.approvalBridge ?? new TuiApprovalBridge());
47
20
  const [awaitingApproval, setAwaitingApproval] = useState(false);
48
21
  const [plan, setPlan] = useState([]);
49
- const [planExpanded, setPlanExpanded] = useState(false);
50
22
  const [status, setStatus] = useState({
51
23
  model: props.initialModel,
52
24
  step: 0,
@@ -57,79 +29,46 @@ export function App(props) {
57
29
  status: 'idle',
58
30
  ...props.initialStatus,
59
31
  });
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);
32
+ const [elapsed, setElapsed] = useState(0);
79
33
  const [queued, setQueued] = useState(null);
80
- const [gitBranch, setGitBranch] = useState(() => getBranch(props.cwd));
81
- const batchRef = useRef('');
82
- const batchTimer = useRef(null);
83
- useEffect(() => {
84
- const t = setInterval(() => setGitBranch(getBranch(props.cwd)), 5000);
85
- return () => clearInterval(t);
86
- }, [props.cwd]);
87
34
  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;
35
+ // 2.4 send queued when idle
36
+ useEffect(() => {
37
+ if (queued && status.status !== 'running' && !awaitingApproval) {
38
+ const toSend = queued;
39
+ setQueued(null);
40
+ const item = { id: nextId('text'), kind: 'text', text: toSend, role: 'user' };
41
+ setTranscript((prev) => [...prev, item]);
42
+ const cmd = parseSlash(toSend.trim());
43
+ if (cmd.kind === 'prompt')
44
+ void props.onPrompt(cmd.text);
45
+ else
46
+ void props.onSlash(cmd);
98
47
  }
48
+ }, [queued, status.status, awaitingApproval]);
49
+ useEffect(() => {
50
+ if (status.status !== 'running')
51
+ return;
52
+ const start = Date.now() - elapsed;
53
+ const t = setInterval(() => setElapsed(Date.now() - start), 1000);
54
+ return () => clearInterval(t);
55
+ }, [status.status, elapsed]);
56
+ const append = useCallback((item) => {
57
+ setTranscript((prev) => {
58
+ const last = prev[prev.length - 1];
59
+ if (last?.kind === 'text' && item.kind === 'text' && last.role === 'assistant' && item.role === 'assistant' && last.id === item.id) {
60
+ return [...prev.slice(0, -1), { ...last, text: last.text + item.text }];
61
+ }
62
+ return [...prev, item];
63
+ });
99
64
  }, []);
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
65
  const updateStatus = useCallback((s) => setStatus((p) => ({ ...p, ...s })), []);
127
- const updatePlan = useCallback((p) => { setPlan(p); setPlanExpanded(true); }, []);
66
+ const updatePlan = useCallback((p) => setPlan(p), []);
128
67
  const onMountedRef = useRef(props.onMounted);
129
68
  useEffect(() => { onMountedRef.current = props.onMounted; }, [props.onMounted]);
130
69
  useEffect(() => {
131
- onMountedRef.current?.({ append: appendStatic, updateStatus, updatePlan });
132
- globalThis.__klyroAppAppend = appendStatic;
70
+ onMountedRef.current?.({ append, updateStatus, updatePlan });
71
+ globalThis.__klyroAppAppend = append;
133
72
  globalThis.__klyroAppStatus = updateStatus;
134
73
  globalThis.__klyroAppPlan = updatePlan;
135
74
  return () => {
@@ -137,39 +76,11 @@ export function App(props) {
137
76
  delete globalThis.__klyroAppStatus;
138
77
  delete globalThis.__klyroAppPlan;
139
78
  };
140
- }, [appendStatic, updateStatus, updatePlan]);
141
- // Also handle batched text via global hook for streaming
142
- useEffect(() => {
143
- const origAppend = appendStatic;
144
- globalThis.__klyroAppendDelta = appendDeltaBatched;
145
- return () => { delete globalThis.__klyroAppendDelta; };
146
- }, [appendDeltaBatched, appendStatic]);
147
- // Queue handling (2.4)
148
- useEffect(() => {
149
- if (queued && status.status !== 'running' && !awaitingApproval) {
150
- const toSend = queued;
151
- setQueued(null);
152
- const trimmed = toSend.trim();
153
- if (!trimmed)
154
- return;
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 { }
162
- const cmd = parseSlash(trimmed);
163
- if (cmd.kind === 'prompt')
164
- void props.onPrompt(cmd.text);
165
- else
166
- void props.onSlash(cmd);
167
- }
168
- }, [queued, status.status, awaitingApproval]);
169
- // Single useInput owner (discipline 4)
79
+ }, [append, updateStatus, updatePlan]);
80
+ // Single useInput owner handles queued when running (2.4)
170
81
  useInput((inputStr, key) => {
171
82
  if (awaitingApproval)
172
- return; // approval modal owns input
83
+ return;
173
84
  if (status.status === 'running') {
174
85
  if (key.ctrl && inputStr === 'c') {
175
86
  void props.onSlash({ kind: 'quit' });
@@ -181,107 +92,27 @@ export function App(props) {
181
92
  return;
182
93
  setQueued(v);
183
94
  setInput('');
184
- // Show queued indicator in live region
95
+ setTranscript((prev) => [...prev, { id: nextId('text'), kind: 'text', text: `queued: ${v.slice(0, 80)}`, role: 'assistant' }]);
185
96
  return;
186
97
  }
187
- if (key.ctrl && inputStr === 't') {
188
- setThinkingExpanded((v) => !v);
98
+ if (key.backspace || key.delete) {
99
+ setInput((v) => v.slice(0, -1));
189
100
  return;
190
101
  }
191
- return;
192
- }
193
- if (key.upArrow) {
194
- if (history.length === 0)
195
- return;
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] ?? '');
201
- return;
202
- }
203
- if (key.downArrow) {
204
- if (histIdx.current === -1)
205
- return;
206
- histIdx.current++;
207
- if (histIdx.current >= history.length) {
208
- histIdx.current = -1;
209
- setInput('');
102
+ if (!key.ctrl && !key.meta) {
103
+ const norm = inputStr.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
104
+ setInput((v) => v + norm);
210
105
  }
211
- else
212
- setInput(history[histIdx.current] ?? '');
213
- return;
214
- }
215
- if (key.ctrl && inputStr === 'r') {
216
- const term = input.toLowerCase();
217
- for (let i = history.length - 1; i >= 0; i--)
218
- if (history[i].toLowerCase().includes(term)) {
219
- setInput(history[i]);
220
- return;
221
- }
222
106
  return;
223
107
  }
224
108
  if (key.return) {
225
- const isShift = key.shift === true;
226
- if (isShift || input.endsWith('\\')) {
227
- if (input.endsWith('\\'))
228
- setInput((v) => v.slice(0, -1) + '\n');
229
- else
230
- setInput((v) => v + '\n');
231
- return;
232
- }
233
- const value = input;
234
- const trimmed = value.replace(/^\s+|\s+$/g, '');
235
- if (!trimmed) {
236
- setInput('');
237
- return;
238
- }
239
- // @ and ! handling
240
- if (trimmed.startsWith('@')) {
241
- const atPath = trimmed.slice(1).trim().split(' ')[0] ?? '';
242
- setInput('');
243
- setStaticItems((prev) => [...prev, { id: nextId('text'), kind: 'text', text: value, role: 'user' }]);
244
- void props.onPrompt(`Reference file: ${atPath}`);
245
- return;
246
- }
247
- if (trimmed.startsWith('!')) {
248
- const cmdText = trimmed.slice(1).trim();
249
- setInput('');
250
- setStaticItems((prev) => [...prev, { id: nextId('text'), kind: 'text', text: value, role: 'user' }]);
251
- import('../tools/shell/shell-exec.js').then(async ({ shellExecTool }) => {
252
- const { builtinRegistry } = await import('../tools/registry.js');
253
- const reg = builtinRegistry();
254
- const r = await reg.execute('shell_exec', { command: cmdText }, { cwd: props.cwd, env: process.env, nonInteractive: true });
255
- const out = r.ok ? JSON.stringify(r.value).slice(0, 500) : String(r.error.message);
256
- setStaticItems((prev) => [...prev, { id: nextId('text'), kind: 'text', text: `!${cmdText}\n${out}`, role: 'assistant' }]);
257
- });
258
- return;
259
- }
260
- if (trimmed.startsWith('# ')) {
261
- const note = trimmed.slice(2).trim();
262
- import('node:fs/promises').then(async (fs) => {
263
- const p = path.join(props.cwd, '.klyro', 'memory', 'session-notes.md');
264
- await fs.mkdir(path.dirname(p), { recursive: true });
265
- await fs.appendFile(p, `- ${note}\n`, 'utf-8');
266
- });
267
- setInput('');
268
- setStaticItems((prev) => [...prev, { id: nextId('text'), kind: 'text', text: `Note saved: ${note}`, role: 'assistant' }]);
109
+ const v = input.trim();
110
+ if (!v)
269
111
  return;
270
- }
271
112
  setInput('');
272
- histIdx.current = -1;
273
- setHistory((prev) => {
274
- const next = [...prev, value];
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 { }
280
- return next.slice(-200);
281
- });
282
- const userItem = { id: nextId('text'), kind: 'text', text: value, role: 'user' };
283
- setStaticItems((prev) => [...prev, userItem]);
284
- const cmd = parseSlash(trimmed);
113
+ const item = { id: nextId('text'), kind: 'text', text: v, role: 'user' };
114
+ setTranscript((prev) => [...prev, item]);
115
+ const cmd = parseSlash(v);
285
116
  if (cmd.kind === 'prompt')
286
117
  void props.onPrompt(cmd.text);
287
118
  else
@@ -292,37 +123,11 @@ export function App(props) {
292
123
  setInput((v) => v.slice(0, -1));
293
124
  return;
294
125
  }
295
- if (key.ctrl && inputStr === 'c') {
296
- if (input === '') {
297
- const now = Date.now();
298
- if (now - lastCtrlC.current < 1500) {
299
- void props.onSlash({ kind: 'quit' });
300
- return;
301
- }
302
- lastCtrlC.current = now;
303
- setStaticItems((prev) => [...prev, { id: nextId('text'), kind: 'text', text: '(press Ctrl+C again to exit)', role: 'assistant' }]);
304
- return;
305
- }
306
- setInput('');
307
- return;
308
- }
309
- if (key.ctrl && inputStr === 'd') {
310
- void props.onSlash({ kind: 'quit' });
311
- return;
312
- }
313
- if (key.ctrl && inputStr === 'l') {
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);
319
- return;
320
- }
321
- if (!key.ctrl && !key.meta) {
322
- const norm = inputStr.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
323
- setInput((v) => v + norm);
324
- }
126
+ if (!key.ctrl && !key.meta)
127
+ setInput((v) => v + inputStr);
325
128
  });
326
- const promptStr = `klyro ${path.basename(props.cwd)}${gitBranch ? ` (${gitBranch})` : ''}`;
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)] })] })] })] }));
129
+ const width = stdout?.columns ?? 100;
130
+ const height = stdout?.rows ?? 30;
131
+ const isSmall = width < 80;
132
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height - 1, children: [_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: tokens.ansi.border, paddingX: 1, children: [_jsx(Text, { bold: true, children: "KLYRO v0.1.10" }), _jsxs(Text, { color: tokens.ansi.muted, children: [status.model, " \u00B7 API Usage Billing \u00B7 step ", status.step, "/", status.maxSteps, " \u00B7 ", status.status, " \u00B7 repairs ", status.repairs] }), _jsx(Text, { color: tokens.ansi.muted, children: props.cwd })] }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", paddingX: 1, paddingY: 1, children: [transcript.length === 0 ? (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { color: tokens.ansi.muted, children: "No conversation yet. Try \"fix the failing login test\"" }) })) : (transcript.map((item) => (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: item.kind === 'text' && item.role === 'user' ? (_jsxs(Text, { children: ["\u203A ", item.text] })) : item.kind === 'text' ? (_jsxs(Text, { children: [" ", item.text] })) : item.kind === 'tool' ? (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: tokens.ansi.border, paddingX: 1, children: [_jsxs(Text, { children: [item.name, " ", item.isError ? '✗' : '✓', " ", item.latencyMs ?? 0, "ms"] }), item.result ? _jsx(Text, { color: tokens.ansi.muted, children: String(item.result).slice(0, 200) }) : null] })) : item.kind === 'diff' ? (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: tokens.ansi.border, paddingX: 1, children: [_jsx(Text, { bold: true, children: item.summary ?? 'Diff' }), item.hunks.map((h, i) => (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: tokens.ansi.info, children: h.path }), h.lines.map((l, j) => (_jsxs(Text, { color: l.kind === 'add' ? tokens.ansi.success : l.kind === 'remove' ? tokens.ansi.error : tokens.ansi.muted, children: [l.kind === 'add' ? '+ ' : l.kind === 'remove' ? '- ' : ' ', l.text] }, j)))] }, i)))] })) : (_jsx(Transcript, { items: [item] })) }, item.id)))), status.status === 'running' ? (_jsxs(Box, { children: [_jsx(Text, { color: tokens.ansi.info, children: "\u2726 Thinking..." }), _jsxs(Text, { color: tokens.ansi.muted, children: [" \u00B7 ", Math.round(elapsed / 1000), "s"] })] })) : null, plan.length > 0 ? _jsx(PlanView, { steps: plan, expanded: false, onToggle: () => { } }) : null] }), _jsxs(Box, { borderStyle: "single", borderColor: tokens.ansi.accent, paddingX: 1, children: [_jsx(Text, { children: "\u203A " }), _jsxs(Text, { children: [input, "\u258F"] })] }), _jsxs(Box, { justifyContent: "space-between", paddingX: 1, borderStyle: "single", borderColor: tokens.ansi.border, children: [_jsxs(Text, { color: tokens.ansi.muted, children: [status.model, " \u00B7 ", status.usageInput + status.usageOutput, " tokens \u00B7 $", (status.usageInput / 1000 * 0.003 + status.usageOutput / 1000 * 0.015).toFixed(2), " \u00B7 ", Math.round(elapsed / 1000), "s"] }), _jsx(Text, { color: tokens.ansi.muted, children: isSmall ? 'Ctrl+C interrupt' : 'Ctrl+C interrupt · Ctrl+O expand · ↑↓ scroll' })] })] }));
328
133
  }
@@ -57,14 +57,16 @@ describe('App', () => {
57
57
  const call = onSlash.mock.calls[0]?.[0];
58
58
  expect(call?.kind).toBe('help');
59
59
  });
60
- it('ignores Enter while status is running', async () => {
60
+ it('queues Enter while status is running (2.4)', async () => {
61
61
  const onPrompt = vi.fn(async () => { });
62
- const { stdin } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: onPrompt, onSlash: async () => { }, initialStatus: { status: 'running' } }));
62
+ const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: onPrompt, onSlash: async () => { }, initialStatus: { status: 'running' } }));
63
63
  stdin.write('hello');
64
64
  await new Promise((r) => setTimeout(r, 20));
65
65
  stdin.write('\x0d');
66
66
  await new Promise((r) => setTimeout(r, 50));
67
- expect(onPrompt).not.toHaveBeenCalled();
67
+ // Per TUI_DESIGN.md §5.2 queued message while running, not immediate
68
+ expect(lastFrame()).toMatch(/queued|hello/);
69
+ expect(onPrompt).not.toHaveBeenCalled(); // not yet, queued
68
70
  });
69
71
  it('routes /quit to onSlash as a quit command', async () => {
70
72
  const onSlash = vi.fn(async () => { });
@@ -0,0 +1,7 @@
1
+ import React from 'react';
2
+ export declare function Header(props: {
3
+ version: string;
4
+ model: string;
5
+ provider?: string;
6
+ cwd: string;
7
+ }): React.JSX.Element;
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ export function Header(props) {
4
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "KLYRO" }), _jsxs(Text, { color: "gray", children: [" v", props.version] })] }), _jsxs(Box, { children: [_jsx(Text, { color: "gray", children: props.model }), props.provider ? _jsxs(Text, { color: "gray", children: [" \u00B7 ", props.provider] }) : null, _jsxs(Text, { color: "gray", children: [" \u00B7 ", props.cwd] })] })] }));
5
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * TUI State Store — TUI_DESIGN.md §27
3
+ * Centralized state for the full-screen TUI.
4
+ * Agent Runtime -> Event Stream -> TUI State Store -> Terminal Renderer
5
+ */
6
+ import type { TranscriptItem } from './transcript.js';
7
+ import type { PlanStep } from '../agent/runtime.js';
8
+ export interface TuiState {
9
+ session: {
10
+ id: string;
11
+ cwd: string;
12
+ startedAt: number;
13
+ };
14
+ agent: {
15
+ status: 'idle' | 'running' | 'thinking' | 'verifying' | 'repairing';
16
+ currentTask?: string;
17
+ elapsedMs: number;
18
+ };
19
+ conversation: TranscriptItem[];
20
+ tools: Array<{
21
+ id: string;
22
+ name: string;
23
+ status: 'pending' | 'running' | 'success' | 'failed';
24
+ input: unknown;
25
+ output?: unknown;
26
+ durationMs?: number;
27
+ }>;
28
+ plan: PlanStep[];
29
+ files: {
30
+ changed: Array<{
31
+ path: string;
32
+ status: 'M' | 'A' | 'D';
33
+ additions?: number;
34
+ deletions?: number;
35
+ }>;
36
+ };
37
+ verification: {
38
+ checks: Array<{
39
+ name: string;
40
+ status: 'pending' | 'success' | 'failed';
41
+ durationMs?: number;
42
+ message?: string;
43
+ }>;
44
+ };
45
+ usage: {
46
+ inputTokens: number;
47
+ outputTokens: number;
48
+ cost: number;
49
+ };
50
+ permissions: {
51
+ pending: Array<{
52
+ toolName: string;
53
+ reason?: string;
54
+ }>;
55
+ };
56
+ input: {
57
+ value: string;
58
+ cursor: number;
59
+ suggestions: string[];
60
+ };
61
+ ui: {
62
+ showThinking: boolean;
63
+ expandedTools: Set<string>;
64
+ };
65
+ }
66
+ export declare function createInitialState(cwd: string, model: string): TuiState;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * TUI State Store — TUI_DESIGN.md §27
3
+ * Centralized state for the full-screen TUI.
4
+ * Agent Runtime -> Event Stream -> TUI State Store -> Terminal Renderer
5
+ */
6
+ export function createInitialState(cwd, model) {
7
+ return {
8
+ session: { id: `sess-${Date.now().toString(36)}`, cwd, startedAt: Date.now() },
9
+ agent: { status: 'idle', elapsedMs: 0 },
10
+ conversation: [],
11
+ tools: [],
12
+ plan: [],
13
+ files: { changed: [] },
14
+ verification: { checks: [] },
15
+ usage: { inputTokens: 0, outputTokens: 0, cost: 0 },
16
+ permissions: { pending: [] },
17
+ input: { value: '', cursor: 0, suggestions: [] },
18
+ ui: { showThinking: false, expandedTools: new Set() },
19
+ };
20
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klyro",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
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",