klyro 0.1.7 → 0.1.9

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,72 +1,47 @@
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 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
5
9
  */
6
10
  import { useState, useCallback, useEffect, useRef } from 'react';
7
- import { Box, Text, useInput } from 'ink';
11
+ import { Box, Text, Static, useInput } from 'ink';
12
+ import { Banner } from './banner.js';
13
+ import { ActivityLine } from './activity-line.js';
8
14
  import { StatusLine } from './status.js';
9
15
  import { Transcript } from './transcript.js';
10
- import { Header } from './header.js';
11
- import { ApprovalModal, TuiApprovalBridge } from './approval.js';
12
- import { PlanView } from './plan.js';
16
+ import { TuiApprovalBridge } from './approval.js';
13
17
  import { parse as parseSlash } from '../cli/slash/parser.js';
18
+ import { tokens, glyphs } from './tokens.js';
14
19
  import * as fs from 'node:fs';
15
20
  import * as path from 'node:path';
16
21
  import * as os from 'node:os';
17
22
  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) {
23
+ let _id = 0;
24
+ function nextId(p) { _id++; return `${p}-${_id}`; }
25
+ function getBranch(cwd) {
24
26
  try {
25
27
  const r = spawnSync('git', ['branch', '--show-current'], { cwd, encoding: 'utf-8', timeout: 800, windowsHide: true });
26
28
  if (r.status === 0 && r.stdout)
27
29
  return r.stdout.trim().slice(0, 40);
28
30
  }
29
- catch { /* ignore */ }
31
+ catch { }
30
32
  return '';
31
33
  }
32
34
  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 */ }
35
+ return path.join(os.homedir() || process.cwd(), '.klyro', 'history');
67
36
  }
68
37
  export function App(props) {
69
- const [transcript, setTranscript] = useState(props.initialTranscript ?? []);
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);
70
45
  const [input, setInput] = useState('');
71
46
  const [bridge] = useState(() => props.approvalBridge ?? new TuiApprovalBridge());
72
47
  const [awaitingApproval, setAwaitingApproval] = useState(false);
@@ -82,43 +57,79 @@ export function App(props) {
82
57
  status: 'idle',
83
58
  ...props.initialStatus,
84
59
  });
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));
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);
89
79
  const [queued, setQueued] = useState(null);
80
+ const [gitBranch, setGitBranch] = useState(() => getBranch(props.cwd));
81
+ const batchRef = useRef('');
82
+ const batchTimer = useRef(null);
90
83
  useEffect(() => {
91
- const t = setInterval(() => setGitBranch(getGitBranch(props.cwd)), 5000);
84
+ const t = setInterval(() => setGitBranch(getBranch(props.cwd)), 5000);
92
85
  return () => clearInterval(t);
93
86
  }, [props.cwd]);
94
- useEffect(() => {
95
- return bridge.subscribe((p) => setAwaitingApproval(p !== null));
96
- }, [bridge]);
97
- const append = useCallback((item) => {
98
- setTranscript((prev) => {
99
- 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) {
105
- return [...prev.slice(0, -1), { ...last, text: last.text + item.text }];
106
- }
107
- return [...prev, item];
108
- });
109
- }, []);
110
- const updateStatus = useCallback((s) => {
111
- setStatus((prev) => ({ ...prev, ...s }));
112
- }, []);
113
- const updatePlan = useCallback((p) => {
114
- setPlan(p);
115
- setPlanExpanded(true);
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
+ }
116
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); }, []);
117
128
  const onMountedRef = useRef(props.onMounted);
118
129
  useEffect(() => { onMountedRef.current = props.onMounted; }, [props.onMounted]);
119
130
  useEffect(() => {
120
- onMountedRef.current?.({ append, updateStatus, updatePlan });
121
- globalThis.__klyroAppAppend = append;
131
+ onMountedRef.current?.({ append: appendStatic, updateStatus, updatePlan });
132
+ globalThis.__klyroAppAppend = appendStatic;
122
133
  globalThis.__klyroAppStatus = updateStatus;
123
134
  globalThis.__klyroAppPlan = updatePlan;
124
135
  return () => {
@@ -126,19 +137,14 @@ export function App(props) {
126
137
  delete globalThis.__klyroAppStatus;
127
138
  delete globalThis.__klyroAppPlan;
128
139
  };
129
- }, [append, updateStatus, updatePlan]);
130
- // Handle Windows raw-mode fallback warning
131
- const [rawModeWarning, setRawModeWarning] = useState(null);
140
+ }, [appendStatic, updateStatus, updatePlan]);
141
+ // Also handle batched text via global hook for streaming
132
142
  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)
143
+ const origAppend = appendStatic;
144
+ globalThis.__klyroAppendDelta = appendDeltaBatched;
145
+ return () => { delete globalThis.__klyroAppendDelta; };
146
+ }, [appendDeltaBatched, appendStatic]);
147
+ // Queue handling (2.4)
142
148
  useEffect(() => {
143
149
  if (queued && status.status !== 'running' && !awaitingApproval) {
144
150
  const toSend = queued;
@@ -146,84 +152,78 @@ export function App(props) {
146
152
  const trimmed = toSend.trim();
147
153
  if (!trimmed)
148
154
  return;
149
- append({ id: nextId('text'), kind: 'text', text: toSend, role: 'user' });
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 { }
150
162
  const cmd = parseSlash(trimmed);
151
163
  if (cmd.kind === 'prompt')
152
164
  void props.onPrompt(cmd.text);
153
- else if (cmd.kind === 'plan') {
154
- if (plan.length > 0)
155
- setPlanExpanded((v) => !v);
156
- }
157
165
  else
158
166
  void props.onSlash(cmd);
159
167
  }
160
- }, [queued, status.status, awaitingApproval, plan.length, append]);
168
+ }, [queued, status.status, awaitingApproval]);
169
+ // Single useInput owner (discipline 4)
161
170
  useInput((inputStr, key) => {
162
171
  if (awaitingApproval)
163
- return;
172
+ return; // approval modal owns input
164
173
  if (status.status === 'running') {
165
174
  if (key.ctrl && inputStr === 'c') {
166
175
  void props.onSlash({ kind: 'quit' });
167
176
  return;
168
177
  }
169
178
  if (key.return) {
170
- const value = input.trim();
171
- if (!value)
179
+ const v = input.trim();
180
+ if (!v)
172
181
  return;
173
- setQueued(value);
182
+ setQueued(v);
174
183
  setInput('');
175
- append({ id: nextId('text'), kind: 'text', text: `queued: ${value.slice(0, 80)}`, role: 'assistant' });
184
+ // Show queued indicator in live region
176
185
  return;
177
186
  }
178
- if (!key.ctrl && !key.meta) {
179
- // Show typing indicator but don't change input (queued mode)
187
+ if (key.ctrl && inputStr === 't') {
188
+ setThinkingExpanded((v) => !v);
180
189
  return;
181
190
  }
182
191
  return;
183
192
  }
184
- // History navigation
185
193
  if (key.upArrow) {
186
194
  if (history.length === 0)
187
195
  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] ?? '');
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] ?? '');
193
201
  return;
194
202
  }
195
203
  if (key.downArrow) {
196
- if (historyIndexRef.current === -1)
204
+ if (histIdx.current === -1)
197
205
  return;
198
- historyIndexRef.current++;
199
- if (historyIndexRef.current >= history.length) {
200
- historyIndexRef.current = -1;
206
+ histIdx.current++;
207
+ if (histIdx.current >= history.length) {
208
+ histIdx.current = -1;
201
209
  setInput('');
202
210
  }
203
- else {
204
- setInput(history[historyIndexRef.current] ?? '');
205
- }
211
+ else
212
+ setInput(history[histIdx.current] ?? '');
206
213
  return;
207
214
  }
208
- // Ctrl+R search — simple: cycle history
209
215
  if (key.ctrl && inputStr === 'r') {
210
- if (history.length === 0)
211
- return;
212
216
  const term = input.toLowerCase();
213
- for (let i = history.length - 1; i >= 0; i--) {
217
+ for (let i = history.length - 1; i >= 0; i--)
214
218
  if (history[i].toLowerCase().includes(term)) {
215
219
  setInput(history[i]);
216
220
  return;
217
221
  }
218
- }
219
222
  return;
220
223
  }
221
224
  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
225
+ const isShift = key.shift === true;
226
+ if (isShift || input.endsWith('\\')) {
227
227
  if (input.endsWith('\\'))
228
228
  setInput((v) => v.slice(0, -1) + '\n');
229
229
  else
@@ -231,87 +231,78 @@ export function App(props) {
231
231
  return;
232
232
  }
233
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) {
234
+ const trimmed = value.replace(/^\s+|\s+$/g, '');
235
+ if (!trimmed) {
237
236
  setInput('');
238
237
  return;
239
238
  }
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] ?? '';
239
+ // @ and ! handling
240
+ if (trimmed.startsWith('@')) {
241
+ const atPath = trimmed.slice(1).trim().split(' ')[0] ?? '';
243
242
  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);
243
+ setStaticItems((prev) => [...prev, { id: nextId('text'), kind: 'text', text: value, role: 'user' }]);
244
+ void props.onPrompt(`Reference file: ${atPath}`);
248
245
  return;
249
246
  }
250
- if (trimmedOuter.startsWith('!')) {
251
- const cmdText = trimmedOuter.slice(1).trim();
247
+ if (trimmed.startsWith('!')) {
248
+ const cmdText = trimmed.slice(1).trim();
252
249
  setInput('');
253
- // Run shell without model call, attach output
250
+ setStaticItems((prev) => [...prev, { id: nextId('text'), kind: 'text', text: value, role: 'user' }]);
254
251
  import('../tools/shell/shell-exec.js').then(async ({ shellExecTool }) => {
255
252
  const { builtinRegistry } = await import('../tools/registry.js');
256
253
  const reg = builtinRegistry();
257
254
  const r = await reg.execute('shell_exec', { command: cmdText }, { cwd: props.cwd, env: process.env, nonInteractive: true });
258
255
  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' });
256
+ setStaticItems((prev) => [...prev, { id: nextId('text'), kind: 'text', text: `!${cmdText}\n${out}`, role: 'assistant' }]);
260
257
  });
261
258
  return;
262
259
  }
263
- if (trimmedOuter.startsWith('# ')) {
264
- const note = trimmedOuter.slice(2).trim();
265
- // Append to .klyro/memory/session-notes.md
260
+ if (trimmed.startsWith('# ')) {
261
+ const note = trimmed.slice(2).trim();
266
262
  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 });
263
+ const p = path.join(props.cwd, '.klyro', 'memory', 'session-notes.md');
264
+ await fs.mkdir(path.dirname(p), { recursive: true });
269
265
  await fs.appendFile(p, `- ${note}\n`, 'utf-8');
270
266
  });
271
267
  setInput('');
272
- append({ id: nextId('text'), kind: 'text', text: `Note saved: ${note}`, role: 'assistant' });
268
+ setStaticItems((prev) => [...prev, { id: nextId('text'), kind: 'text', text: `Note saved: ${note}`, role: 'assistant' }]);
273
269
  return;
274
270
  }
275
- // Check for Ctrl+C double at empty prompt handled below, but here handle submit
276
271
  setInput('');
277
- historyIndexRef.current = -1;
278
- // Save to history
272
+ histIdx.current = -1;
279
273
  setHistory((prev) => {
280
274
  const next = [...prev, value];
281
- appendHistory(props.cwd, value);
275
+ try {
276
+ fs.mkdirSync(path.dirname(getHistoryPath()), { recursive: true });
277
+ fs.appendFileSync(getHistoryPath(), JSON.stringify({ cwd: props.cwd, text: value, ts: Date.now() }) + '\n');
278
+ }
279
+ catch { }
282
280
  return next.slice(-200);
283
281
  });
284
- append({ id: nextId('text'), kind: 'text', text: value, role: 'user' });
285
- const cmd = parseSlash(trimmedOuter);
286
- if (cmd.kind === 'prompt') {
282
+ const userItem = { id: nextId('text'), kind: 'text', text: value, role: 'user' };
283
+ setStaticItems((prev) => [...prev, userItem]);
284
+ const cmd = parseSlash(trimmed);
285
+ if (cmd.kind === 'prompt')
287
286
  void props.onPrompt(cmd.text);
288
- }
289
- else if (cmd.kind === 'plan') {
290
- if (plan.length > 0)
291
- setPlanExpanded((v) => !v);
292
- }
293
- else {
287
+ else
294
288
  void props.onSlash(cmd);
295
- }
296
289
  return;
297
290
  }
298
291
  if (key.backspace || key.delete) {
299
292
  setInput((v) => v.slice(0, -1));
300
293
  return;
301
294
  }
302
- // Ctrl+C double at empty prompt exits
303
295
  if (key.ctrl && inputStr === 'c') {
304
296
  if (input === '') {
305
297
  const now = Date.now();
306
- if (now - lastCtrlCRef.current < 1500) {
298
+ if (now - lastCtrlC.current < 1500) {
307
299
  void props.onSlash({ kind: 'quit' });
308
300
  return;
309
301
  }
310
- lastCtrlCRef.current = now;
311
- append({ id: nextId('text'), kind: 'text', text: '(press Ctrl+C again to exit)', role: 'assistant' });
302
+ lastCtrlC.current = now;
303
+ setStaticItems((prev) => [...prev, { id: nextId('text'), kind: 'text', text: '(press Ctrl+C again to exit)', role: 'assistant' }]);
312
304
  return;
313
305
  }
314
- // Single Ctrl+C cancels input
315
306
  setInput('');
316
307
  return;
317
308
  }
@@ -320,18 +311,18 @@ export function App(props) {
320
311
  return;
321
312
  }
322
313
  if (key.ctrl && inputStr === 'l') {
323
- // Clear — keep session but clear transcript marker
324
- append({ id: nextId('text'), kind: 'text', text: '(cleared)', role: 'assistant' });
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);
325
319
  return;
326
320
  }
327
- // Handle bracketed paste: inputStr may contain \r\n or multiple lines
328
321
  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);
322
+ const norm = inputStr.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
323
+ setInput((v) => v + norm);
333
324
  }
334
325
  });
335
326
  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" }) })] }));
327
+ return (_jsxs(Box, { flexDirection: "column", width: "100%", children: [_jsx(Banner, { version: "0.1.8", cwd: props.cwd, branch: gitBranch, model: status.model, klyroMdLoaded: false, packageManager: "pnpm", testRunner: "vitest" }), _jsxs(Box, { flexDirection: "column", width: "100%", children: [_jsxs(Box, { borderStyle: "single", borderColor: tokens.ansi.accent, paddingX: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Box, { children: [_jsx(Text, { color: tokens.ansi.accent, bold: true, children: "KLYRO" }), _jsxs(Text, { color: tokens.ansi.muted, children: [" ", path.basename(props.cwd)] })] }), _jsx(Box, { children: _jsxs(Text, { color: tokens.ansi.muted, children: [status.model, " step ", status.step, "/", status.maxSteps] }) })] }), _jsx(StatusLine, { snapshot: status })] }), _jsx(Static, { items: staticItems, children: (item) => (_jsx(Box, { flexDirection: "column", width: "100%", children: item.kind === 'text' && item.role === 'user' ? (_jsxs(Box, { children: [_jsxs(Text, { color: tokens.ansi.accent, children: [glyphs.prompt, " "] }), _jsx(Text, { children: item.text })] })) : item.kind === 'text' ? (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { children: item.text }) })) : (_jsx(Transcript, { items: [item] })) }, item.id)) }), _jsxs(Box, { flexDirection: "column", width: "100%", children: [liveThinking ? (_jsxs(Box, { paddingX: 1, children: [_jsx(Text, { color: tokens.ansi.muted, dimColor: true, children: "\u2234 Thinking\u2026" }), isThinkingExpanded ? _jsxs(Text, { color: tokens.ansi.muted, dimColor: true, children: [" ", liveThinking] }) : _jsx(Text, { color: tokens.ansi.muted, dimColor: true, children: " ctrl+t to show" })] })) : null, liveText ? (_jsx(Box, { paddingX: 1, flexDirection: "column", children: _jsxs(Text, { children: [liveText, "\u258D"] }) })) : null, activity ? (_jsx(ActivityLine, { verb: activity.verb, elapsedMs: Date.now() - activity.start, hint: "esc to interrupt" })) : null, queued ? (_jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: tokens.ansi.muted, dimColor: true, children: ["\u23F3 queued: \"", queued.slice(0, 60), "\""] }) })) : null, _jsxs(Box, { borderStyle: "round", borderColor: awaitingApproval ? tokens.ansi.warning : tokens.ansi.accent, paddingX: 1, children: [_jsxs(Text, { color: tokens.ansi.muted, dimColor: true, children: [promptStr, " "] }), _jsxs(Text, { children: [input, "\u258F"] })] }), _jsxs(Box, { paddingX: 1, justifyContent: "space-between", children: [_jsx(Text, { color: tokens.ansi.muted, dimColor: true, children: "Tab: slash completion \u00B7 Shift+Enter: newline \u00B7 Ctrl+C twice: exit" }), _jsxs(Text, { color: tokens.ansi.muted, dimColor: true, children: [status.model, " \u00B7 ctx ", Math.round(((status.usageInput + status.usageOutput) / 128000) * 100), "% \u00B7 $", ((status.usageInput / 1000) * 0.003 + (status.usageOutput / 1000) * 0.015).toFixed(2)] })] })] })] }));
337
328
  }
@@ -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;