klyro 0.1.9 → 0.1.11

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/cli/repl.js CHANGED
@@ -150,16 +150,20 @@ export async function startRepl(opts = {}) {
150
150
  // 2.3 image handling: extract @img refs
151
151
  const { text: cleanText, images } = parseImageInput(text);
152
152
  const taskText = images.length > 0 ? `${cleanText}\n\n[images: ${images.join(', ')}]` : cleanText;
153
- // Level 9 — create session for this prompt if not already (one session per prompt)
153
+ // Only create session for non-trivial tasks (with tools/verify) — plain chat like "hello" is not a persisted session
154
+ const isSimpleChat = taskText.trim().split(/\s+/).length <= 5 && !taskText.toLowerCase().includes('fix') && !taskText.toLowerCase().includes('add') && !taskText.toLowerCase().includes('create');
154
155
  let sessionId;
155
- try {
156
- const rec = await tuiStore.create({ cwd, task: taskText, config: { model, maxSteps: opts.maxSteps ?? 30 } });
157
- sessionId = rec.id;
158
- tuiSessionId = rec.id;
159
- queuedAppend({ id: `sess-${Date.now()}`, kind: 'text', text: `session ${rec.id.slice(0, 8)} started`, role: 'assistant' });
160
- }
161
- catch {
162
- // best-effort
156
+ if (!isSimpleChat) {
157
+ try {
158
+ const rec = await tuiStore.create({ cwd, task: taskText, config: { model, maxSteps: opts.maxSteps ?? 30 } });
159
+ sessionId = rec.id;
160
+ tuiSessionId = rec.id;
161
+ // Session info goes to status bar, not transcript (clean like Claude Code)
162
+ queuedStatus({ status: 'running', step: 0, model });
163
+ }
164
+ catch {
165
+ // best-effort
166
+ }
163
167
  }
164
168
  queuedStatus({ status: 'running', step: 0, model });
165
169
  let textBuf = '';
@@ -303,12 +307,19 @@ export async function startRepl(opts = {}) {
303
307
  queuedAppend({ id: `verr-${Date.now()}`, kind: 'error', message: `Verification failed after ${result.verification?.attempts ?? 3} attempts. Check output above.` });
304
308
  queuedStatus({ status: 'error', errorMessage: 'verification failed' });
305
309
  }
310
+ else if (result.status === 'no_final' && result.finalText) {
311
+ // Simple chat with text but no tool calls — treat as done (provider finished)
312
+ queuedStatus({ status: 'done', repairs: result.repairs ?? 0 });
313
+ }
306
314
  else {
307
315
  queuedStatus({ status: 'complete' === result.status ? 'done' : 'error', repairs: result.repairs ?? 0 });
308
316
  }
309
317
  if (sessionId) {
310
318
  queuedAppend({ id: `sess-end-${Date.now()}`, kind: 'text', text: `session ${sessionId.slice(0, 8)} ${result.status} — ${result.steps} steps, ${result.toolCalls} tool calls`, role: 'assistant' });
311
319
  }
320
+ else if (result.status === 'no_final' && !result.finalText) {
321
+ queuedAppend({ id: `no_final-${Date.now()}`, kind: 'error', message: 'Provider returned no final text — check model/provider' });
322
+ }
312
323
  }
313
324
  catch (err) {
314
325
  const message = err instanceof Error ? err.message : String(err);
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.11" }), _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.11",
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",