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.
- package/dist/tui/activity-line.d.ts +19 -0
- package/dist/tui/activity-line.js +30 -0
- package/dist/tui/app.d.ts +2 -2
- 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 +56 -260
- package/dist/tui/app.legacy.d.ts +25 -0
- package/dist/tui/app.legacy.js +337 -0
- package/dist/tui/app.test.js +10 -5
- package/dist/tui/banner.d.ts +28 -0
- package/dist/tui/banner.js +15 -0
- package/dist/tui/components/Header.d.ts +7 -0
- package/dist/tui/components/Header.js +5 -0
- package/dist/tui/input-box.d.ts +21 -0
- package/dist/tui/input-box.js +31 -0
- package/dist/tui/snapshot.test.js +16 -7
- package/dist/tui/state.d.ts +66 -0
- package/dist/tui/state.js +20 -0
- package/dist/tui/thinking-block.d.ts +12 -0
- package/dist/tui/thinking-block.js +11 -0
- package/dist/tui/tokens.d.ts +67 -0
- package/dist/tui/tokens.js +88 -0
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
3
|
-
*
|
|
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
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
|
+
}
|