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/app.js CHANGED
@@ -1,77 +1,24 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
- * Ink app root — status line + scrollable transcript + input box.
4
- * 1.4: history per project, multiline, Ctrl+C double, slash registry, Windows handling
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
5
  */
6
- import { useState, useCallback, useEffect, useRef } from 'react';
7
- import { Box, Text, useInput } from 'ink';
8
- import { StatusLine } from './status.js';
6
+ import { useState, useEffect, useRef, useCallback } from 'react';
7
+ import { Box, Text, useInput, useStdout } from 'ink';
9
8
  import { Transcript } from './transcript.js';
10
- import { Header } from './header.js';
11
- import { ApprovalModal, TuiApprovalBridge } from './approval.js';
9
+ import { TuiApprovalBridge } from './approval.js';
12
10
  import { PlanView } from './plan.js';
13
11
  import { parse as parseSlash } from '../cli/slash/parser.js';
14
- import * as fs from 'node:fs';
15
- import * as path from 'node:path';
16
- import * as os from 'node:os';
17
- import { spawnSync } from 'node:child_process';
18
- let _itemCounter = 0;
19
- function nextId(prefix) {
20
- _itemCounter += 1;
21
- return `${prefix}-${_itemCounter}`;
22
- }
23
- function getGitBranch(cwd) {
24
- try {
25
- const r = spawnSync('git', ['branch', '--show-current'], { cwd, encoding: 'utf-8', timeout: 800, windowsHide: true });
26
- if (r.status === 0 && r.stdout)
27
- return r.stdout.trim().slice(0, 40);
28
- }
29
- catch { /* ignore */ }
30
- return '';
31
- }
32
- function getHistoryPath() {
33
- const home = os.homedir() || process.cwd();
34
- return path.join(home, '.klyro', 'history');
35
- }
36
- function loadHistory(cwd) {
37
- try {
38
- const raw = fs.readFileSync(getHistoryPath(), 'utf-8');
39
- const lines = raw.split('\n').filter(Boolean);
40
- const out = [];
41
- for (const line of lines) {
42
- try {
43
- const obj = JSON.parse(line);
44
- if (obj.cwd === cwd && typeof obj.text === 'string')
45
- out.push(obj.text);
46
- }
47
- catch {
48
- // legacy plain text per line
49
- if (line.trim())
50
- out.push(line.trim());
51
- }
52
- }
53
- return out.slice(-200);
54
- }
55
- catch {
56
- return [];
57
- }
58
- }
59
- function appendHistory(cwd, text) {
60
- try {
61
- const p = getHistoryPath();
62
- fs.mkdirSync(path.dirname(p), { recursive: true });
63
- const entry = JSON.stringify({ cwd, text, ts: Date.now() });
64
- fs.appendFileSync(p, entry + '\n', 'utf-8');
65
- }
66
- catch { /* ignore */ }
67
- }
12
+ import { tokens } from './tokens.js';
13
+ let _id = 0;
14
+ function nextId(p) { _id++; return `${p}-${_id}`; }
68
15
  export function App(props) {
16
+ const { stdout } = useStdout();
69
17
  const [transcript, setTranscript] = useState(props.initialTranscript ?? []);
70
18
  const [input, setInput] = useState('');
71
19
  const [bridge] = useState(() => props.approvalBridge ?? new TuiApprovalBridge());
72
20
  const [awaitingApproval, setAwaitingApproval] = useState(false);
73
21
  const [plan, setPlan] = useState([]);
74
- const [planExpanded, setPlanExpanded] = useState(false);
75
22
  const [status, setStatus] = useState({
76
23
  model: props.initialModel,
77
24
  step: 0,
@@ -82,38 +29,41 @@ export function App(props) {
82
29
  status: 'idle',
83
30
  ...props.initialStatus,
84
31
  });
85
- const [history, setHistory] = useState(() => loadHistory(props.cwd));
86
- const historyIndexRef = useRef(-1);
87
- const lastCtrlCRef = useRef(0);
88
- const [gitBranch, setGitBranch] = useState(() => getGitBranch(props.cwd));
32
+ const [elapsed, setElapsed] = useState(0);
89
33
  const [queued, setQueued] = useState(null);
34
+ useEffect(() => bridge.subscribe((p) => setAwaitingApproval(p !== null)), [bridge]);
35
+ // 2.4 — send queued when idle
90
36
  useEffect(() => {
91
- const t = setInterval(() => setGitBranch(getGitBranch(props.cwd)), 5000);
92
- return () => clearInterval(t);
93
- }, [props.cwd]);
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);
47
+ }
48
+ }, [queued, status.status, awaitingApproval]);
94
49
  useEffect(() => {
95
- return bridge.subscribe((p) => setAwaitingApproval(p !== null));
96
- }, [bridge]);
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]);
97
56
  const append = useCallback((item) => {
98
57
  setTranscript((prev) => {
99
58
  const last = prev[prev.length - 1];
100
- if (last?.kind === 'text' &&
101
- item.kind === 'text' &&
102
- last.role === 'assistant' &&
103
- item.role === 'assistant' &&
104
- last.id === item.id) {
59
+ if (last?.kind === 'text' && item.kind === 'text' && last.role === 'assistant' && item.role === 'assistant' && last.id === item.id) {
105
60
  return [...prev.slice(0, -1), { ...last, text: last.text + item.text }];
106
61
  }
107
62
  return [...prev, item];
108
63
  });
109
64
  }, []);
110
- const updateStatus = useCallback((s) => {
111
- setStatus((prev) => ({ ...prev, ...s }));
112
- }, []);
113
- const updatePlan = useCallback((p) => {
114
- setPlan(p);
115
- setPlanExpanded(true);
116
- }, []);
65
+ const updateStatus = useCallback((s) => setStatus((p) => ({ ...p, ...s })), []);
66
+ const updatePlan = useCallback((p) => setPlan(p), []);
117
67
  const onMountedRef = useRef(props.onMounted);
118
68
  useEffect(() => { onMountedRef.current = props.onMounted; }, [props.onMounted]);
119
69
  useEffect(() => {
@@ -127,37 +77,7 @@ export function App(props) {
127
77
  delete globalThis.__klyroAppPlan;
128
78
  };
129
79
  }, [append, updateStatus, updatePlan]);
130
- // Handle Windows raw-mode fallback warning
131
- const [rawModeWarning, setRawModeWarning] = useState(null);
132
- useEffect(() => {
133
- try {
134
- const stdin = process.stdin;
135
- if (stdin.isTTY && typeof stdin.setRawMode !== 'function') {
136
- setRawModeWarning('Raw mode not available (mintty) — input may be limited');
137
- }
138
- }
139
- catch { /* ignore */ }
140
- }, []);
141
- // Queue next message if typed during stream (2.4)
142
- useEffect(() => {
143
- if (queued && status.status !== 'running' && !awaitingApproval) {
144
- const toSend = queued;
145
- setQueued(null);
146
- const trimmed = toSend.trim();
147
- if (!trimmed)
148
- return;
149
- append({ id: nextId('text'), kind: 'text', text: toSend, role: 'user' });
150
- const cmd = parseSlash(trimmed);
151
- if (cmd.kind === 'prompt')
152
- void props.onPrompt(cmd.text);
153
- else if (cmd.kind === 'plan') {
154
- if (plan.length > 0)
155
- setPlanExpanded((v) => !v);
156
- }
157
- else
158
- void props.onSlash(cmd);
159
- }
160
- }, [queued, status.status, awaitingApproval, plan.length, append]);
80
+ // Single useInput owner — handles queued when running (2.4)
161
81
  useInput((inputStr, key) => {
162
82
  if (awaitingApproval)
163
83
  return;
@@ -167,171 +87,47 @@ export function App(props) {
167
87
  return;
168
88
  }
169
89
  if (key.return) {
170
- const value = input.trim();
171
- if (!value)
90
+ const v = input.trim();
91
+ if (!v)
172
92
  return;
173
- setQueued(value);
93
+ setQueued(v);
174
94
  setInput('');
175
- append({ id: nextId('text'), kind: 'text', text: `queued: ${value.slice(0, 80)}`, role: 'assistant' });
95
+ setTranscript((prev) => [...prev, { id: nextId('text'), kind: 'text', text: `queued: ${v.slice(0, 80)}`, role: 'assistant' }]);
176
96
  return;
177
97
  }
178
- if (!key.ctrl && !key.meta) {
179
- // Show typing indicator but don't change input (queued mode)
180
- return;
181
- }
182
- return;
183
- }
184
- // History navigation
185
- if (key.upArrow) {
186
- if (history.length === 0)
98
+ if (key.backspace || key.delete) {
99
+ setInput((v) => v.slice(0, -1));
187
100
  return;
188
- if (historyIndexRef.current === -1)
189
- historyIndexRef.current = history.length - 1;
190
- else if (historyIndexRef.current > 0)
191
- historyIndexRef.current--;
192
- setInput(history[historyIndexRef.current] ?? '');
193
- return;
194
- }
195
- if (key.downArrow) {
196
- if (historyIndexRef.current === -1)
197
- return;
198
- historyIndexRef.current++;
199
- if (historyIndexRef.current >= history.length) {
200
- historyIndexRef.current = -1;
201
- setInput('');
202
101
  }
203
- else {
204
- setInput(history[historyIndexRef.current] ?? '');
205
- }
206
- return;
207
- }
208
- // Ctrl+R search — simple: cycle history
209
- if (key.ctrl && inputStr === 'r') {
210
- if (history.length === 0)
211
- return;
212
- const term = input.toLowerCase();
213
- for (let i = history.length - 1; i >= 0; i--) {
214
- if (history[i].toLowerCase().includes(term)) {
215
- setInput(history[i]);
216
- return;
217
- }
102
+ if (!key.ctrl && !key.meta) {
103
+ const norm = inputStr.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
104
+ setInput((v) => v + norm);
218
105
  }
219
106
  return;
220
107
  }
221
108
  if (key.return) {
222
- // Multiline: trailing \ or Shift+Enter (where supported, key.shift is true)
223
- // Ink's key object has `shift` for Shift+Enter on some terminals
224
- const isShiftEnter = key.shift === true;
225
- if (isShiftEnter || input.endsWith('\\')) {
226
- // Replace trailing \ with newline, or just add newline for Shift+Enter
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
- // Preserve newlines for bracketed paste — don't trim inner newlines, only outer
235
- const trimmedOuter = value.replace(/^\s+|\s+$/g, '');
236
- if (!trimmedOuter) {
237
- setInput('');
238
- return;
239
- }
240
- // 4.4 — handle @path and !cmd and # note without model call
241
- if (trimmedOuter.startsWith('@')) {
242
- const atPath = trimmedOuter.slice(1).trim().split(' ')[0] ?? '';
243
- setInput('');
244
- append({ id: nextId('text'), kind: 'text', text: `Attached @${atPath} (fuzzy completion stub)`, role: 'assistant' });
245
- // Still send to model as context, but mark as @ reference
246
- const atText = `Reference file: ${atPath}`;
247
- void props.onPrompt(atText);
248
- return;
249
- }
250
- if (trimmedOuter.startsWith('!')) {
251
- const cmdText = trimmedOuter.slice(1).trim();
252
- setInput('');
253
- // Run shell without model call, attach output
254
- import('../tools/shell/shell-exec.js').then(async ({ shellExecTool }) => {
255
- const { builtinRegistry } = await import('../tools/registry.js');
256
- const reg = builtinRegistry();
257
- const r = await reg.execute('shell_exec', { command: cmdText }, { cwd: props.cwd, env: process.env, nonInteractive: true });
258
- const out = r.ok ? JSON.stringify(r.value).slice(0, 500) : String(r.error.message);
259
- append({ id: nextId('text'), kind: 'text', text: `!${cmdText}\n${out}`, role: 'assistant' });
260
- });
109
+ const v = input.trim();
110
+ if (!v)
261
111
  return;
262
- }
263
- if (trimmedOuter.startsWith('# ')) {
264
- const note = trimmedOuter.slice(2).trim();
265
- // Append to .klyro/memory/session-notes.md
266
- import('node:fs/promises').then(async (fs) => {
267
- const p = (await import('node:path')).join(props.cwd, '.klyro', 'memory', 'session-notes.md');
268
- await fs.mkdir((await import('node:path')).dirname(p), { recursive: true });
269
- await fs.appendFile(p, `- ${note}\n`, 'utf-8');
270
- });
271
- setInput('');
272
- append({ id: nextId('text'), kind: 'text', text: `Note saved: ${note}`, role: 'assistant' });
273
- return;
274
- }
275
- // Check for Ctrl+C double at empty prompt handled below, but here handle submit
276
112
  setInput('');
277
- historyIndexRef.current = -1;
278
- // Save to history
279
- setHistory((prev) => {
280
- const next = [...prev, value];
281
- appendHistory(props.cwd, value);
282
- return next.slice(-200);
283
- });
284
- append({ id: nextId('text'), kind: 'text', text: value, role: 'user' });
285
- const cmd = parseSlash(trimmedOuter);
286
- if (cmd.kind === 'prompt') {
113
+ const item = { id: nextId('text'), kind: 'text', text: v, role: 'user' };
114
+ setTranscript((prev) => [...prev, item]);
115
+ const cmd = parseSlash(v);
116
+ if (cmd.kind === 'prompt')
287
117
  void props.onPrompt(cmd.text);
288
- }
289
- else if (cmd.kind === 'plan') {
290
- if (plan.length > 0)
291
- setPlanExpanded((v) => !v);
292
- }
293
- else {
118
+ else
294
119
  void props.onSlash(cmd);
295
- }
296
120
  return;
297
121
  }
298
122
  if (key.backspace || key.delete) {
299
123
  setInput((v) => v.slice(0, -1));
300
124
  return;
301
125
  }
302
- // Ctrl+C double at empty prompt exits
303
- if (key.ctrl && inputStr === 'c') {
304
- if (input === '') {
305
- const now = Date.now();
306
- if (now - lastCtrlCRef.current < 1500) {
307
- void props.onSlash({ kind: 'quit' });
308
- return;
309
- }
310
- lastCtrlCRef.current = now;
311
- append({ id: nextId('text'), kind: 'text', text: '(press Ctrl+C again to exit)', role: 'assistant' });
312
- return;
313
- }
314
- // Single Ctrl+C cancels input
315
- setInput('');
316
- return;
317
- }
318
- if (key.ctrl && inputStr === 'd') {
319
- void props.onSlash({ kind: 'quit' });
320
- return;
321
- }
322
- if (key.ctrl && inputStr === 'l') {
323
- // Clear — keep session but clear transcript marker
324
- append({ id: nextId('text'), kind: 'text', text: '(cleared)', role: 'assistant' });
325
- return;
326
- }
327
- // Handle bracketed paste: inputStr may contain \r\n or multiple lines
328
- if (!key.ctrl && !key.meta) {
329
- // Preserve all characters including newlines from paste
330
- // Normalize \r\n to \n
331
- const normalized = inputStr.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
332
- setInput((v) => v + normalized);
333
- }
126
+ if (!key.ctrl && !key.meta)
127
+ setInput((v) => v + inputStr);
334
128
  });
335
- const promptStr = `klyro › ${path.basename(props.cwd)}${gitBranch ? ` (${gitBranch})` : ''}`;
336
- return (_jsxs(Box, { flexDirection: "column", width: "100%", height: "100%", children: [_jsx(Header, { cwd: props.cwd, model: status.model, step: status.step, maxSteps: status.maxSteps }), rawModeWarning ? _jsx(Box, { children: _jsx(Text, { color: "yellow", children: rawModeWarning }) }) : null, _jsx(StatusLine, { snapshot: status }), plan.length > 0 ? (_jsx(PlanView, { steps: plan, expanded: planExpanded, onToggle: () => setPlanExpanded((v) => !v) })) : null, _jsx(Transcript, { items: transcript }), awaitingApproval ? _jsx(ApprovalModal, { bridge: bridge }) : null, _jsxs(Box, { borderStyle: "single", borderColor: awaitingApproval ? 'yellow' : 'gray', paddingX: 1, children: [_jsx(Text, { color: "gray", children: awaitingApproval ? '! ' : `${promptStr} ` }), _jsx(Text, { children: awaitingApproval ? '(awaiting approval — see above)' : input }), status.status === 'running' ? _jsx(Text, { color: "cyan", children: " \u258D" }) : _jsx(Text, { children: "\u258D" })] }), _jsx(Box, { paddingX: 1, children: _jsx(Text, { dimColor: true, children: "Tab: slash completion \u00B7 Shift+Enter: newline \u00B7 Ctrl+C twice: exit \u00B7 Ctrl+R: history" }) })] }));
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' })] })] }));
337
133
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Ink app root — status line + scrollable transcript + input box.
3
+ * 1.4: history per project, multiline, Ctrl+C double, slash registry, Windows handling
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;