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 +2 -6
- package/dist/tui/app.fullscreen.d.ts +25 -0
- package/dist/tui/app.fullscreen.js +97 -0
- package/dist/tui/app.inline.d.ts +29 -0
- package/dist/tui/app.inline.js +336 -0
- package/dist/tui/app.js +60 -255
- package/dist/tui/app.test.js +5 -3
- package/dist/tui/components/Header.d.ts +7 -0
- package/dist/tui/components/Header.js +5 -0
- package/dist/tui/state.d.ts +66 -0
- package/dist/tui/state.js +20 -0
- package/package.json +1 -1
package/dist/tui/app.d.ts
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Klyro TUI
|
|
3
|
-
*
|
|
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
|
|
4
|
-
*
|
|
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,
|
|
11
|
-
import { Box, Text,
|
|
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
|
|
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
|
-
|
|
39
|
-
const [
|
|
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 [
|
|
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
|
-
//
|
|
89
|
-
|
|
90
|
-
if (
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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) =>
|
|
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
|
|
132
|
-
globalThis.__klyroAppAppend =
|
|
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
|
-
}, [
|
|
141
|
-
//
|
|
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;
|
|
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
|
-
|
|
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.
|
|
188
|
-
|
|
98
|
+
if (key.backspace || key.delete) {
|
|
99
|
+
setInput((v) => v.slice(0, -1));
|
|
189
100
|
return;
|
|
190
101
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
226
|
-
if (
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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 &&
|
|
296
|
-
|
|
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
|
|
327
|
-
|
|
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
|
}
|
package/dist/tui/app.test.js
CHANGED
|
@@ -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('
|
|
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
|
-
|
|
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,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
|
+
}
|