klyro 0.1.8 → 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.
@@ -0,0 +1,19 @@
1
+ /**
2
+ * 5.6 Activity Line (Spinner) — TUI_DESIGN.md §5.6
3
+ * Composition: spinner · verb · elapsed · tokens up/down · hint
4
+ */
5
+ import React from 'react';
6
+ export interface ActivityLineProps {
7
+ verb?: string;
8
+ elapsedMs?: number;
9
+ tokensUp?: number;
10
+ tokensDown?: number;
11
+ hint?: string;
12
+ planProgress?: string;
13
+ isPaused?: boolean;
14
+ }
15
+ export declare function ActivityLine(props: ActivityLineProps): React.JSX.Element | null;
16
+ export declare function CompactionDivider(props: {
17
+ before: number;
18
+ after: number;
19
+ }): React.JSX.Element;
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import Spinner from 'ink-spinner';
4
+ import { tokens, glyphs } from './tokens.js';
5
+ function formatElapsed(ms) {
6
+ if (ms < 1000)
7
+ return `${ms}ms`;
8
+ const s = Math.floor(ms / 1000);
9
+ if (s < 60)
10
+ return `${s}s`;
11
+ const m = Math.floor(s / 60);
12
+ const rem = s % 60;
13
+ return `${m}:${String(rem).padStart(2, '0')}`;
14
+ }
15
+ export function ActivityLine(props) {
16
+ const { verb = 'Working…', elapsedMs = 0, tokensUp, tokensDown, hint = 'esc to interrupt', planProgress, isPaused } = props;
17
+ if (isPaused) {
18
+ return (_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: tokens.ansi.warning, children: "\u23F8 Paused \u2014 type an instruction to steer, or enter to continue, esc again to stop" }) }));
19
+ }
20
+ const elapsed = formatElapsed(elapsedMs);
21
+ const tokensPart = tokensUp !== undefined || tokensDown !== undefined
22
+ ? ` · ↑ ${tokensUp ?? 0} ${tokensDown !== undefined ? `↓ ${tokensDown}` : ''}`
23
+ : '';
24
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: tokens.ansi.info, children: [_jsx(Spinner, { type: "dots" }), " "] }), _jsxs(Text, { bold: true, children: [verb, " "] }), _jsxs(Text, { color: tokens.ansi.muted, dimColor: true, children: ["(", elapsed, tokensPart, " \u00B7 ", hint, ")"] })] }), planProgress ? (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { color: tokens.ansi.muted, dimColor: true, children: ["Plan ", planProgress] }) })) : null] }));
25
+ }
26
+ export function CompactionDivider(props) {
27
+ const pctBefore = Math.round(props.before / 1000);
28
+ const pctAfter = Math.round(props.after / 1000);
29
+ return (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsxs(Text, { color: tokens.ansi.muted, dimColor: true, children: ["\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 ", glyphs.compaction, " context compacted \u00B7 ", pctBefore, "% \u2192 ", pctAfter, "% \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"] }), _jsx(Text, { color: tokens.ansi.muted, dimColor: true, children: " kept: task, decisions, files changed, todos, last turns verbatim" })] }));
30
+ }
package/dist/tui/app.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Ink app rootstatus line + scrollable transcript + input box.
3
- * 1.4: history per project, multiline, Ctrl+C double, slash registry, Windows handling
2
+ * Klyro Full-Screen TUITUI_DESIGN.md §2, §24, §38 (Phase 1-4)
3
+ * Full viewport, conversation, input, status bar professional, dense, terminal-native
4
4
  */
5
5
  import React from 'react';
6
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
+ }