xitto-kernel 0.2.0 → 0.3.2

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/src/app/tui.js ADDED
@@ -0,0 +1,398 @@
1
+ // Ink(終端版 React)渲染層 — 對標 Claude Code:
2
+ // 1) 已完成訊息進 <Static>:打一次、不重繪、隨終端捲動。
3
+ // 2) 串流中的回覆:已完成的「段落」即時 render markdown 提交進 <Static>(見 appendLive),
4
+ // 動態區只保留「正在輸入的最後一段」並每幀重解析重繪——動態區恆小,避免回覆變長後
5
+ // 動態區高度 ≥ 終端列數而退回 Ink 全螢幕重繪慢路徑(閃爍根因)。
6
+ // 3) 自訂 Input:命令歷史(↑↓)、斜線/@補全(Tab)、多行(反斜線續行/貼上)、游標編輯。
7
+ // 4) 輸入框永遠在最底,上方依序為輸出 / spinner / 頁腳 / 狀態列。
8
+ import React, { useState, useEffect } from 'react';
9
+ import { render, Box, Text, Static, useInput } from 'ink';
10
+ import { md, lexBlocks, codeChunk } from './md-render.js';
11
+
12
+ const h = React.createElement;
13
+ const DOT = '\x1b[32m⏺\x1b[39m'; // 綠色實心圓,標記回覆/工具(對標 Claude Code)
14
+ // 左側溝槽:第一行加 head 前綴(為 '' 時與其餘行一樣縮排 2 空格,用於「延續區塊」),
15
+ // 讓整段成為一個視覺區塊。延續區塊不再重複 ● 標記。
16
+ export function gutter(text, head = DOT) {
17
+ const pad = head ? head + ' ' : ' ';
18
+ return text.split('\n').map((l, i) => (i === 0 ? pad + l : ' ' + l)).join('\n');
19
+ }
20
+
21
+ // 串流增量提交(對標 Claude Code):用 markdown 的 block lexer 把 live 切成頂層區塊,
22
+ // 提交「除最後一塊外」的完整區塊進 <Static>,最後一塊(可能還沒打完)留在動態區。
23
+ // 例外:最後一塊是「未閉合且超長的程式碼框」時,逐行提交已完成的程式碼行(見 store.appendLive)。
24
+
25
+ // live 是否為一個「未閉合的 fenced code block」;是則回 {lang, body}(body 為開頭 fence 之後的程式碼)。
26
+ // 容忍開頭的區塊間空行(前一塊提交後 raw 常殘留前導 \n)。
27
+ function openCodeBlock(text) {
28
+ const t = text.replace(/^\n+/, '');
29
+ const m = t.match(/^```([^\n]*)\n/);
30
+ if (!m) return null;
31
+ if ((t.match(/```/g) || []).length !== 1) return null; // 只有開頭那組 ``` = 未閉合
32
+ return { lang: m[1].trim(), body: t.slice(m[0].length) };
33
+ }
34
+ // 超長程式碼框逐行提交的行數門檻:接近一屏才啟動(小/中型 code 仍正常即時渲染、含標頭)。
35
+ function codeFlushLines() {
36
+ return Math.max(12, (process.stdout?.rows || 24) - 8);
37
+ }
38
+ const INV = '\x1b[7m';
39
+ const INVOFF = '\x1b[27m';
40
+ const GRAY = (s) => `\x1b[90m${s}\x1b[39m`;
41
+ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
42
+ function waitingVerb(sec) {
43
+ if (sec < 3) return '思考中';
44
+ if (sec < 10) return '處理中';
45
+ if (sec < 30) return '仍在處理';
46
+ return '長任務處理中';
47
+ }
48
+
49
+ // 外部 store:plain object + listeners。main() 推狀態,元件 subscribe 後 re-render。
50
+ export function createStore(initial = {}) {
51
+ let state = {
52
+ transcript: [], live: '', liveStarted: false, liveCodeLang: null, thinking: '', tool: null, status: '',
53
+ tasks: '', // 任務面板:活動區就地更新(不進 Static,避免每步更新堆重複列表)
54
+ mode: 'idle', planMode: false, permission: null, placeholder: '',
55
+ busyAt: null, // 本輪開始時間戳(ms):思考動畫 + 耗時
56
+ modelLabel: '', cwdLabel: '', gitLabel: '', permLabel: '', sandboxLabel: '', // 狀態列:模型/目錄/git/權限模式/沙箱
57
+ ctx: null, // {used,total} 上下文用量
58
+ atStart: true, // 起始乾淨畫面:把輸入框推到終端底部,首次送出後關閉
59
+ suggestions: [], // 後續追問建議(ghost text + Tab 接受)
60
+ selection: null, // {title, options} 方向鍵選單(權限確認 / ask_user)
61
+ ask: null, // {prompt} 純文字問答輸入(ask_user 的「其他(自行輸入)」)
62
+ ...initial,
63
+ };
64
+ const listeners = new Set();
65
+ let id = 1;
66
+ let liveStarted = false; // 本段文字是否已輸出過 ● 開頭(首塊帶 ●,段內後續塊為延續、不重複 ●)
67
+ let codeLang = null; // 非 null=正在「逐行提交」一個超長未閉合程式碼框;此時 state.live 只存程式碼本體(無 fence)
68
+ let scheduled = null;
69
+ const emitNow = () => { scheduled = null; for (const l of listeners) l(); };
70
+ const emitThrottled = () => { if (!scheduled) scheduled = setTimeout(emitNow, 40); };
71
+
72
+ // 提交一個已完成區塊進 <Static>:rendered 為已渲染好的 ANSI 字串。首塊帶 ●,之後為延續。
73
+ const commitBlock = (rendered) => {
74
+ if (!rendered || !rendered.trim()) return;
75
+ const block = '\n' + gutter(rendered, liveStarted ? '' : DOT);
76
+ liveStarted = true;
77
+ state = { ...state, liveStarted: true, transcript: [...state.transcript, { id: id++, text: block }] };
78
+ };
79
+
80
+ return {
81
+ get: () => state,
82
+ subscribe(l) { listeners.add(l); return () => listeners.delete(l); },
83
+ set(p) { state = { ...state, ...p }; emitNow(); },
84
+ pushBlock(text) {
85
+ if (text == null || text === '') return;
86
+ state = { ...state, transcript: [...state.transcript, { id: id++, text }] };
87
+ emitNow();
88
+ },
89
+ appendLive(s) {
90
+ // --- 模式 A:正在逐行提交超長未閉合程式碼框(state.live 只存程式碼本體、無 fence)---
91
+ if (codeLang != null) {
92
+ let body = state.live + s;
93
+ const close = body.indexOf('```'); // 收尾 fence 到了?
94
+ if (close !== -1) {
95
+ const done = body.slice(0, close).replace(/\n$/, '');
96
+ if (done) commitBlock(codeChunk(done, codeLang, false));
97
+ codeLang = null;
98
+ // 收尾 fence 之後的剩餘文字(去掉 fence 本身與其後換行)回到一般模式,留待下次 delta / finalize 處理
99
+ const rest = body.slice(close + 3).replace(/^[^\n]*\n?/, '');
100
+ state = { ...state, live: rest, liveCodeLang: null };
101
+ emitThrottled();
102
+ return;
103
+ }
104
+ const lines = body.split('\n');
105
+ if (lines.length > codeFlushLines()) {
106
+ commitBlock(codeChunk(lines.slice(0, -1).join('\n'), codeLang, false)); // 提交已完成行、留最後一行(可能未打完)
107
+ body = lines[lines.length - 1];
108
+ }
109
+ state = { ...state, live: body, liveCodeLang: codeLang };
110
+ emitThrottled();
111
+ return;
112
+ }
113
+
114
+ // --- 模式 B:一般 markdown。用 block lexer 切塊,提交除最後一塊外的所有完整區塊 ---
115
+ let raw = state.live + s;
116
+ const tokens = lexBlocks(raw);
117
+ if (tokens.length >= 2) {
118
+ let committedRaw = '';
119
+ for (let i = 0; i < tokens.length - 1; i++) committedRaw += tokens[i].raw;
120
+ if (committedRaw.trim() && raw.startsWith(committedRaw)) {
121
+ commitBlock(md(committedRaw));
122
+ raw = raw.slice(committedRaw.length);
123
+ }
124
+ }
125
+ // 最後一塊若是「未閉合且超長」的程式碼框 → 進入模式 A,立即提交首批已完成行(含標頭)
126
+ const open = openCodeBlock(raw);
127
+ if (open && open.body.split('\n').length > codeFlushLines()) {
128
+ codeLang = open.lang;
129
+ const lines = open.body.split('\n');
130
+ commitBlock(codeChunk(lines.slice(0, -1).join('\n'), codeLang, true)); // 首塊含 ┌─ lang 標頭
131
+ state = { ...state, live: lines[lines.length - 1], liveStarted, liveCodeLang: codeLang };
132
+ emitThrottled();
133
+ return;
134
+ }
135
+ state = { ...state, live: raw, liveStarted };
136
+ emitThrottled();
137
+ },
138
+ appendThinking(s) { state = { ...state, thinking: state.thinking + s }; emitThrottled(); },
139
+ finalizeLive() {
140
+ const live = state.live;
141
+ const started = liveStarted;
142
+ const cl = codeLang;
143
+ liveStarted = false; codeLang = null;
144
+ state = { ...state, live: '', liveStarted: false, liveCodeLang: null, thinking: '' };
145
+ if (live.trim()) {
146
+ const rendered = cl != null ? codeChunk(live, cl, false) : md(live);
147
+ state.transcript = [...state.transcript, { id: id++, text: '\n' + gutter(rendered, started ? '' : DOT) }];
148
+ }
149
+ emitNow();
150
+ },
151
+ setTool(tool) { state = { ...state, tool }; emitNow(); },
152
+ setStatus(status) { state = { ...state, status }; emitNow(); },
153
+ setMode(mode) { state = { ...state, mode }; emitNow(); },
154
+ setPlan(planMode) { state = { ...state, planMode }; emitNow(); },
155
+ setPlaceholder(placeholder) { state = { ...state, placeholder }; emitNow(); },
156
+ askSelect(title, options) { state = { ...state, mode: 'select', selection: { title, options } }; emitNow(); },
157
+ clearSelect() { state = { ...state, mode: 'busy', selection: null }; emitNow(); },
158
+ askInput(prompt) { state = { ...state, mode: 'ask', ask: { prompt } }; emitNow(); },
159
+ clearAsk() { state = { ...state, mode: 'busy', ask: null }; emitNow(); },
160
+ };
161
+ }
162
+
163
+ // 通用方向鍵選單(↑↓ / Enter 選、Esc 取消當前提示、Ctrl+C 中斷整輪、數字鍵快選)。
164
+ // onCancel:取消這一次選擇(如權限確認=拒絕該工具);onAbort:中斷整個回合(Ctrl+C)。
165
+ const optLabel = (o) => (typeof o === 'string' ? o : (o?.label ?? String(o)));
166
+ export function Select({ title, options, onSelect, onCancel, onAbort }) {
167
+ const [index, setIndex] = useState(0);
168
+ useInput((input, key) => {
169
+ if (key.ctrl && input === 'c') { (onAbort || onCancel)(); return; } // Ctrl+C → 中斷整輪
170
+ if (key.escape) { onCancel(); return; } // Esc → 僅取消這次(拒絕該工具)
171
+ if (key.upArrow) { setIndex((i) => (i - 1 + options.length) % options.length); return; }
172
+ if (key.downArrow) { setIndex((i) => (i + 1) % options.length); return; }
173
+ if (key.return) { onSelect(index); return; }
174
+ const n = parseInt(input, 10);
175
+ if (!Number.isNaN(n) && n >= 1 && n <= options.length) { onSelect(n - 1); return; }
176
+ });
177
+ return h(
178
+ Box,
179
+ { flexDirection: 'column', borderStyle: 'round', borderColor: 'yellow', paddingX: 1, marginTop: 1 },
180
+ title ? h(Text, null, title) : null,
181
+ ...options.map((o, i) => h(Text, { key: i, color: i === index ? 'cyan' : 'gray' }, `${i === index ? '❯' : ' '} ${i + 1}. ${optLabel(o)}`)),
182
+ h(Text, { color: 'gray' }, ' ↑↓ 選擇 · Enter 確認 · Esc 取消此項 · Ctrl+C 中斷整輪'),
183
+ );
184
+ }
185
+
186
+ // 多行 value + 游標(游標處反白)渲染成字串;空值顯示 ghost/placeholder
187
+ function renderValue(value, cursor, hint) {
188
+ if (!value) return INV + ' ' + INVOFF + GRAY(hint || '');
189
+ const at = value.slice(cursor, cursor + 1) || ' ';
190
+ return value.slice(0, cursor) + INV + at + INVOFF + value.slice(cursor + 1);
191
+ }
192
+
193
+ // 貼上折疊占位符的「結尾」樣式(退格時用來判斷游標是否落在占位符末端)
194
+ const PASTE_TOKEN_END = /⟦貼上#\d+·\d+行⟧$/;
195
+
196
+ // 退格邏輯:若游標正好在貼上占位符的結尾,原子刪除整個占位符(連同它代表的內容會在送出時
197
+ // 一併消失);否則刪一個字元。避免退格只刪掉 ⟧ 破壞占位符→送出殘缺字面、貼上內容靜默遺失。
198
+ // 純函數,便於單測。回傳 { value, cursor }。
199
+ export function backspaceAt(value, cursor) {
200
+ if (cursor <= 0) return { value, cursor };
201
+ const m = value.slice(0, cursor).match(PASTE_TOKEN_END);
202
+ if (m) {
203
+ const start = cursor - m[0].length;
204
+ return { value: value.slice(0, start) + value.slice(cursor), cursor: start };
205
+ }
206
+ return { value: value.slice(0, cursor - 1) + value.slice(cursor), cursor: cursor - 1 };
207
+ }
208
+
209
+ // 自訂輸入編輯器(取代 ink-text-input,以支援歷史/補全/多行)
210
+ function Input({ onSubmit, onCtrlC, onEscape, getHistory, complete, placeholder, promptPrefix, promptColor, suggestions }) {
211
+ const [value, setValue] = useState('');
212
+ const [cursor, setCursor] = useState(0);
213
+ const [menu, setMenu] = useState(null); // {items, index, start}
214
+ const [histIdx, setHistIdx] = useState(-1);
215
+ const [draft, setDraft] = useState('');
216
+ const [pastes, setPastes] = useState([]); // 多行貼上折疊:value 內放占位符,送出時展開
217
+ // ghost 建議:輸入框為空且有建議時,淡色顯示第一條,Tab 接受
218
+ const ghost = (!value && suggestions && suggestions.length) ? suggestions[0] : '';
219
+ const expandPastes = (v) => v.replace(/⟦貼上#(\d+)·\d+行⟧/g, (m, n) => pastes[Number(n) - 1] ?? m);
220
+ // 依目前輸入自動更新補全選單(邊打邊彈,對標 Claude Code)。
221
+ // 已是完整唯一匹配(如打完 /help)則不彈,避免接受後又跳出來。
222
+ const refresh = (v, cur) => {
223
+ const res = complete ? complete(v.slice(0, cur)) : null;
224
+ if (!res || !res.items.length) { setMenu(null); return; }
225
+ const token = v.slice(res.start, cur);
226
+ if (res.items.length === 1 && res.items[0] === token) { setMenu(null); return; }
227
+ setMenu({ items: res.items, index: 0, start: res.start });
228
+ };
229
+ const put = (v, c) => { const cc = c == null ? v.length : c; setValue(v); setCursor(cc); };
230
+ const edit = (v, c) => { put(v, c); refresh(v, c == null ? v.length : c); }; // 編輯內容並刷新補全
231
+
232
+ useInput((input, key) => {
233
+ if (key.ctrl && input === 'c') { onCtrlC(); return; }
234
+ if (key.escape) { if (menu) { setMenu(null); return; } onEscape(); return; }
235
+
236
+ if (menu) { // 補全選單導航(開啟時 ↑↓ 走選單、Tab/Enter 接受)
237
+ if (key.upArrow) { setMenu({ ...menu, index: (menu.index - 1 + menu.items.length) % menu.items.length }); return; }
238
+ if (key.downArrow) { setMenu({ ...menu, index: (menu.index + 1) % menu.items.length }); return; }
239
+ if (key.tab || key.return) { const pick = menu.items[menu.index]; edit(value.slice(0, menu.start) + (typeof pick === 'string' ? pick : pick.value)); return; }
240
+ // 其他鍵:先關選單,落到下面照常處理(並會在編輯後重新刷新)
241
+ setMenu(null);
242
+ }
243
+
244
+ if (key.tab && ghost) { edit(ghost, ghost.length); return; } // Tab 接受 ghost 建議
245
+ if (key.tab) { refresh(value, cursor); return; } // 選單未開時 Tab 也可手動觸發
246
+ if (key.return) {
247
+ // Shift+Enter / Option(Alt)+Enter → 在游標處換行(多行輸入),不送出
248
+ if (key.shift || key.meta) { edit(value.slice(0, cursor) + '\n' + value.slice(cursor), cursor + 1); return; }
249
+ if (value.endsWith('\\')) { edit(value.slice(0, -1) + '\n'); return; } // 反斜線續行
250
+ const v = expandPastes(value); // 送出前把貼上占位符還原成原文
251
+ setValue(''); setCursor(0); setMenu(null); setHistIdx(-1); setDraft(''); setPastes([]);
252
+ onSubmit(v); return;
253
+ }
254
+ if (key.upArrow) {
255
+ const hist = getHistory(); if (!hist.length) return;
256
+ let i = histIdx; if (i === -1) { setDraft(value); i = 0; } else if (i < hist.length - 1) i++;
257
+ setHistIdx(i); put(hist[hist.length - 1 - i]); return;
258
+ }
259
+ if (key.downArrow) {
260
+ if (histIdx === -1) return;
261
+ const hist = getHistory(); const i = histIdx - 1;
262
+ if (i < 0) { setHistIdx(-1); put(draft); return; }
263
+ setHistIdx(i); put(hist[hist.length - 1 - i]); return;
264
+ }
265
+ // Alt/Option + ←/→:以「詞」為單位移動游標
266
+ if (key.meta && key.leftArrow) { let i = cursor; while (i > 0 && /\s/.test(value[i - 1])) i--; while (i > 0 && !/\s/.test(value[i - 1])) i--; setCursor(i); return; }
267
+ if (key.meta && key.rightArrow) { let i = cursor; while (i < value.length && /\s/.test(value[i])) i++; while (i < value.length && !/\s/.test(value[i])) i++; setCursor(i); return; }
268
+ if (key.leftArrow) { setCursor(Math.max(0, cursor - 1)); return; }
269
+ if (key.rightArrow) { setCursor(Math.min(value.length, cursor + 1)); return; }
270
+ if (key.ctrl && input === 'a') { setCursor(0); return; }
271
+ if (key.ctrl && input === 'e') { setCursor(value.length); return; }
272
+ if (key.ctrl && input === 'u') { edit(value.slice(cursor), 0); return; } // 刪到行首
273
+ if (key.ctrl && input === 'k') { edit(value.slice(0, cursor), cursor); return; } // 刪到行尾
274
+ if (key.backspace || key.delete) {
275
+ const r = backspaceAt(value, cursor); // 游標在貼上占位符末端→原子刪整個占位符
276
+ if (r.value !== value || r.cursor !== cursor) edit(r.value, r.cursor);
277
+ return;
278
+ }
279
+ if (input) {
280
+ // 多行貼上:折疊成占位符 ⟦貼上#n·N行⟧,不撐爆輸入框;送出時展開(對標 Claude Code)
281
+ if (input.includes('\n') && input.length > 1) {
282
+ const id = pastes.length + 1;
283
+ const token = `⟦貼上#${id}·${input.split('\n').length}行⟧`;
284
+ setPastes((p) => [...p, input]);
285
+ edit(value.slice(0, cursor) + token + value.slice(cursor), cursor + token.length);
286
+ } else {
287
+ edit(value.slice(0, cursor) + input + value.slice(cursor), cursor + input.length);
288
+ }
289
+ }
290
+ });
291
+
292
+ // 空輸入時,ghost 建議優先顯示(附 ⇥ 提示);否則顯示 placeholder
293
+ const hint = ghost ? `${ghost} \x1b[2m⇥ Tab\x1b[22m` : placeholder;
294
+ const lines = renderValue(value, cursor, hint).split('\n');
295
+ return h(
296
+ Box,
297
+ { flexDirection: 'column' },
298
+ h(Box, null, h(Text, { color: promptColor }, promptPrefix), h(Text, null, lines[0])),
299
+ ...lines.slice(1).map((ln, i) => h(Text, { key: 'l' + i }, ' ' + ln)),
300
+ menu
301
+ ? h(Box, { flexDirection: 'column' },
302
+ ...menu.items.slice(0, 8).map((it, i) => {
303
+ const val = typeof it === 'string' ? it : it.value;
304
+ const desc = typeof it === 'string' ? '' : (it.desc ? ' \x1b[90m' + it.desc + '\x1b[39m' : '');
305
+ return h(Text, { key: 'm' + i, color: i === menu.index ? 'cyan' : 'gray' }, (i === menu.index ? '❯ ' : ' ') + val + desc);
306
+ }))
307
+ : null,
308
+ );
309
+ }
310
+
311
+ export function App({ store, handlers }) {
312
+ const [, force] = useState(0);
313
+ const [tick, setTick] = useState(0);
314
+ useEffect(() => store.subscribe(() => force((n) => n + 1)), [store]);
315
+ const s = store.get();
316
+
317
+ // spinner / 耗時計時:執行中每 120ms 重繪
318
+ useEffect(() => {
319
+ if (s.mode !== 'busy') return undefined;
320
+ const t = setInterval(() => setTick((n) => n + 1), 120);
321
+ return () => clearInterval(t);
322
+ }, [s.mode]);
323
+
324
+ // 註:終端 resize 由 index.js 處理(全清螢幕 + 重新掛載),因為 Ink 的增量清行
325
+ // 會用「換行數」而非「實際終端列數」計算,視窗變窄時會誤算並殘留亂碼。
326
+
327
+ const elapsed = s.busyAt ? Math.floor((Date.now() - s.busyAt) / 1000) : 0;
328
+ const verb = s.live ? '輸出中' : (s.tool ? '執行中' : waitingVerb(elapsed)); // 已開始輸出/工具時改措辭
329
+ const waiting = s.mode === 'busy'
330
+ ? `\x1b[35m${SPINNER[tick % SPINNER.length]} ${verb}… ${elapsed}s\x1b[39m ` +
331
+ GRAY('· 直接打字=引導,Esc/Ctrl+C 中斷')
332
+ : null;
333
+
334
+ const borderColor = s.mode === 'ask' ? 'cyan' : s.mode === 'busy' ? 'magenta' : s.planMode ? 'cyan' : 'gray';
335
+ const promptColor = s.mode === 'ask' ? 'cyan' : s.planMode ? 'cyan' : 'green';
336
+
337
+ // 狀態列:模型 · 目錄 · [計劃] · 上下文 N%
338
+ let ctxPart = '';
339
+ if (s.ctx && s.ctx.total) {
340
+ const pct = Math.min(100, Math.round((s.ctx.used / s.ctx.total) * 100));
341
+ const col = pct >= 90 ? '31' : pct >= 70 ? '33' : '90';
342
+ ctxPart = ` \x1b[90m·\x1b[39m \x1b[${col}m上下文 ${pct}%\x1b[39m`;
343
+ }
344
+ const planPart = s.planMode ? ' \x1b[90m·\x1b[39m \x1b[36m[計劃]\x1b[39m' : '';
345
+ const permPart = s.permLabel ? ' \x1b[90m·\x1b[39m \x1b[32m' + s.permLabel + '\x1b[39m' : '';
346
+ const sandboxPart = s.sandboxLabel ? ' \x1b[90m·\x1b[39m \x1b[33m' + s.sandboxLabel + '\x1b[39m' : '';
347
+ const gitPart = s.gitLabel ? ' \x1b[90m·\x1b[39m ' + GRAY(s.gitLabel) : '';
348
+ const statusBar = GRAY(`${s.modelLabel} ${s.cwdLabel}`) + gitPart + planPart + permPart + sandboxPart + ctxPart;
349
+
350
+ // 不再用 spacer 把輸入框頂到屏底。原本算高度撐滿整屏,會讓「活動區」逼近終端列數;
351
+ // 一旦 outputHeight >= rows,Ink 改走 clearTerminal 全屏重繪慢路徑(Windows conhost 上劇烈閃爍/跳動)。
352
+ // 改為自頂向下自然捲動:歷史進 <Static>(不計入活動區高度),活動區恆只剩 串流/狀態/輸入 幾行,
353
+ // Ink 永遠走 eraseLines 增量快路徑。輸入框在內容填滿一屏後自然落到底部(對標 Claude Code)。
354
+ return h(
355
+ Box,
356
+ { flexDirection: 'column' },
357
+ h(Static, { items: s.transcript }, (item) => h(Box, { key: item.id, flexDirection: 'column' }, h(Text, null, item.text))),
358
+ s.thinking ? h(Text, { color: 'gray', wrap: 'wrap' }, s.thinking) : null,
359
+ s.live ? h(Box, { flexDirection: 'column' }, h(Text, null, '\n' + gutter(
360
+ s.liveCodeLang != null ? codeChunk(s.live, s.liveCodeLang, false) : md(s.live),
361
+ s.liveStarted ? '' : DOT))) : null,
362
+ s.tool ? h(Text, { color: 'yellow' }, `⏺ ${s.tool.name}`, s.tool.summary ? h(Text, { color: 'gray' }, `(${s.tool.summary})`) : null) : null,
363
+ waiting ? h(Text, null, waiting) : null,
364
+ s.ask ? h(Text, null, s.ask.prompt) : null,
365
+ s.status ? h(Text, null, ' ' + s.status) : null,
366
+ // 任務面板:就地更新的活動區元素(非 Static),每次 task_update 在原位重畫、不堆歷史
367
+ s.tasks ? h(Box, { flexDirection: 'column' }, h(Text, null, GRAY(' 任務')), h(Text, null, s.tasks)) : null,
368
+ statusBar.trim() ? h(Text, null, ' ' + statusBar) : null,
369
+ (s.mode === 'idle' && !s.selection) ? h(Text, null, GRAY(' ↵ 送出 · ⇧↵ 換行 · / 指令 · @ 檔案 · ↑ 歷史')) : null,
370
+ // 最底:選單模式渲染 <Select>(取代輸入框),否則渲染輸入框
371
+ s.selection
372
+ ? h(Select, {
373
+ title: s.selection.title,
374
+ options: s.selection.options,
375
+ onSelect: handlers.onSelectChoice,
376
+ onCancel: handlers.onSelectCancel,
377
+ onAbort: handlers.onSelectAbort,
378
+ })
379
+ : h(Box, { borderStyle: 'round', borderColor, paddingX: 1, marginTop: 1 },
380
+ h(Input, {
381
+ onSubmit: handlers.onSubmit,
382
+ onCtrlC: handlers.onCtrlC,
383
+ onEscape: handlers.onEscape,
384
+ getHistory: handlers.getHistory,
385
+ // ask 模式(自行輸入答案)為純文字:關閉斜線/@補全與 ghost 建議,避免 `/`、`@` 誤彈選單
386
+ complete: s.mode === 'ask' ? undefined : handlers.complete,
387
+ placeholder: s.mode === 'ask' ? '輸入你的答案,Enter 送出 · Esc 取消' : s.placeholder,
388
+ promptPrefix: s.mode === 'ask' ? '› ' : (s.planMode ? '[計劃] › ' : '› '),
389
+ promptColor,
390
+ suggestions: s.mode === 'ask' ? [] : s.suggestions,
391
+ })),
392
+ );
393
+ }
394
+
395
+ // 掛載 TUI;回傳 Ink instance(含 unmount / waitUntilExit)
396
+ export function mountTui({ store, handlers }) {
397
+ return render(h(App, { store, handlers }), { exitOnCtrlC: false });
398
+ }
@@ -4,40 +4,12 @@
4
4
  // 對應 docs/05-example-packs.md「A. coding pack」。
5
5
  import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
6
6
  import { isAbsolute, join, relative } from 'node:path';
7
- import { execSync } from 'node:child_process';
7
+ import { execSync, spawnSync } from 'node:child_process';
8
8
  import { createBackgroundTools } from '../../kernel/bg.js';
9
+ import { createGrepTool, createGlobTool } from '../shared/code-nav.js';
9
10
 
10
11
  const txt = (s) => ({ content: [{ type: 'text', text: typeof s === 'string' ? s : JSON.stringify(s) }] });
11
12
 
12
- const IGNORE = new Set(['node_modules', '.git', '.xitto-kernel', '.xitto-code', 'dist', 'build', '.next', 'coverage']);
13
-
14
- // 遞迴收集檔案路徑(跳過 IGNORE 目錄),上限保護
15
- function walkFiles(dir, out, limit) {
16
- if (out.length >= limit) return;
17
- let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
18
- for (const e of entries) {
19
- if (out.length >= limit) return;
20
- if (e.name.startsWith('.') && e.name !== '.env') { if (IGNORE.has(e.name)) continue; }
21
- if (IGNORE.has(e.name)) continue;
22
- const full = join(dir, e.name);
23
- if (e.isDirectory()) walkFiles(full, out, limit);
24
- else out.push(full);
25
- }
26
- }
27
-
28
- // glob 樣式 → 正則(支援 ** 遞迴、* 與 ?)
29
- function globToRegex(pattern) {
30
- let re = '';
31
- for (let i = 0; i < pattern.length; i++) {
32
- const ch = pattern[i];
33
- if (ch === '*') { if (pattern[i + 1] === '*') { re += '.*'; i++; if (pattern[i + 1] === '/') i++; } else re += '[^/]*'; }
34
- else if (ch === '?') re += '[^/]';
35
- else if ('.+^${}()|[]\\'.includes(ch)) re += '\\' + ch;
36
- else re += ch;
37
- }
38
- return new RegExp('^' + re + '$');
39
- }
40
-
41
13
  const SYSTEM_PROMPT = [
42
14
  '你是嚴謹的編碼 agent。準則:',
43
15
  '- 探索 codebase:用 glob 找檔、grep 搜內容、read 讀檔(附行號)。',
@@ -121,50 +93,18 @@ export function createCodingPack({ cwd = process.cwd() } = {}) {
121
93
  parameters: { type: 'object', properties: { command: { type: 'string' }, timeout: { type: 'number' } }, required: ['command'] },
122
94
  execute: async (_id, { command, timeout }) => {
123
95
  const ms = Math.min(600, Math.max(1, timeout || 120)) * 1000;
124
- try { return txt(execSync(command, { cwd, encoding: 'utf8', timeout: ms, maxBuffer: 16 * 1024 * 1024 }) || '(no output)'); }
125
- catch (e) { return txt({ error: e.message, stdout: e.stdout, stderr: e.stderr }); }
96
+ // spawnSync 同時捕捉 stdout+stderr(不漏到終端,agent 也看得到錯誤輸出)
97
+ const r = spawnSync(command, { shell: true, cwd, encoding: 'utf8', timeout: ms, maxBuffer: 16 * 1024 * 1024 });
98
+ const output = ((r.stdout || '') + (r.stderr || '')).trim();
99
+ if (r.error) return txt({ error: r.error.message, output });
100
+ if (r.status !== 0) return txt({ error: `命令結束碼 ${r.status}`, output: output || '(no output)' });
101
+ return txt(output || '(no output)');
126
102
  },
127
103
  };
128
104
 
129
- // ── codebase 導航:grep(搜內容)+ glob(找檔)──
130
- const grepTool = {
131
- name: 'grep', label: '搜尋內容', readOnly: true,
132
- description: '在檔案內容用正則搜尋,回 path:line:文字。可選 path(起點目錄)、glob(檔名過濾如 *.js)。自動跳過 node_modules/.git。',
133
- parameters: { type: 'object', properties: { pattern: { type: 'string' }, path: { type: 'string' }, glob: { type: 'string' }, ignoreCase: { type: 'boolean' } }, required: ['pattern'] },
134
- execute: async (_id, { pattern, path, glob, ignoreCase }) => {
135
- let re; try { re = new RegExp(pattern, ignoreCase ? 'i' : ''); } catch (e) { return txt({ error: '正則無效:' + e.message }); }
136
- const base = path ? abs(path) : cwd;
137
- if (!existsSync(base)) return txt({ error: '目錄不存在', path });
138
- const files = []; walkFiles(base, files, 8000);
139
- const gre = glob ? globToRegex(glob) : null;
140
- const hits = [];
141
- for (const f of files) {
142
- if (gre && !gre.test(f.split('/').pop())) continue;
143
- let content; try { content = readFileSync(f, 'utf8'); } catch { continue; }
144
- if (content.includes('')) continue; // 跳過二進位
145
- const lines = content.split('\n');
146
- for (let i = 0; i < lines.length; i++) {
147
- if (re.test(lines[i])) { hits.push(`${relative(cwd, f)}:${i + 1}: ${lines[i].trim().slice(0, 200)}`); if (hits.length >= 200) break; }
148
- }
149
- if (hits.length >= 200) break;
150
- }
151
- return txt(hits.length ? hits.join('\n') + (hits.length >= 200 ? '\n…(結果已截斷至 200)' : '') : '(無匹配)');
152
- },
153
- };
154
-
155
- const globTool = {
156
- name: 'glob', label: '找檔', readOnly: true,
157
- description: '用萬用字元樣式找檔(** 遞迴、* ?),相對路徑比對。如 "src/**/*.js"、"**/*.test.js"。',
158
- parameters: { type: 'object', properties: { pattern: { type: 'string' }, path: { type: 'string' } }, required: ['pattern'] },
159
- execute: async (_id, { pattern, path }) => {
160
- const base = path ? abs(path) : cwd;
161
- if (!existsSync(base)) return txt({ error: '目錄不存在', path });
162
- const files = []; walkFiles(base, files, 8000);
163
- const re = globToRegex(pattern);
164
- const matched = files.map((f) => relative(cwd, f)).filter((rel) => re.test(rel)).slice(0, 200);
165
- return txt({ pattern, count: matched.length, files: matched });
166
- },
167
- };
105
+ // ── codebase 導航:grep / glob(共用模組)──
106
+ const grepTool = createGrepTool(cwd);
107
+ const globTool = createGlobTool(cwd);
168
108
 
169
109
  const webFetch = {
170
110
  name: 'web_fetch', label: '抓網頁', readOnly: true,
@@ -1,66 +1,72 @@
1
- // data-query pack — 第二個範例領域,用來證明「同介面、kernel 零改動」。
2
- // 工具為示意 stub(回傳假資料),重點是六個插槽用法與 coding 完全一樣,只是內容換了。
3
- // 對照:schema-before-query 之於資料查詢 == read-before-edit 之於編碼(同 preToolPolicy 插槽)。
1
+ // data-query pack — 真實資料查詢 agent(用 sqlite3 CLI,零依賴)。
2
+ // 工具對一個 SQLite .db 跑真實 SQL;schema-before-query 守衛(對照 read-before-edit)。
4
3
  // 對應 docs/05-example-packs.md「B. data-query pack」。
4
+ import { spawnSync } from 'node:child_process';
5
+ import { isAbsolute, join } from 'node:path';
5
6
 
6
7
  const txt = (s) => ({ content: [{ type: 'text', text: typeof s === 'string' ? s : JSON.stringify(s) }] });
7
8
 
9
+ // 用 sqlite3 CLI 跑 SQL(sql 以 argv 傳入,非 shell 內插 → 無 shell 注入)
10
+ function sqlite(dbPath, sql, opts = []) {
11
+ const r = spawnSync('sqlite3', [...opts, dbPath, sql], { encoding: 'utf8', timeout: 30000, maxBuffer: 16 * 1024 * 1024 });
12
+ if (r.error) return { error: r.error.code === 'ENOENT' ? '找不到 sqlite3 命令(請先安裝)' : r.error.message };
13
+ if (r.status !== 0) return { error: (r.stderr || '').trim() || `exit ${r.status}` };
14
+ return { out: (r.stdout || '').trim() };
15
+ }
16
+
8
17
  const SYSTEM_PROMPT = [
9
- '你是資料分析 agent。準則:',
18
+ '你是資料分析 agent,對一個 SQLite 資料庫工作。準則:',
10
19
  '- 下查詢前先用 list_tables / describe_table 了解結構。',
11
- '- 破壞性 SQL(DROP/TRUNCATE/DELETE 無 WHERE)一律先確認。',
20
+ '- 唯讀查詢用 sql_query;寫入(INSERT/UPDATE/DELETE/建表)用 sql_exec。',
21
+ '- 破壞性 SQL(DROP/TRUNCATE/無 WHERE 的 DELETE)先確認。',
22
+ '- 回答問題時根據真實查詢結果,不要臆測數字。',
12
23
  ].join('\n');
13
24
 
14
- /**
15
- * @param {{ schema?: Record<string,string[]> }} [opts]
16
- * @returns {import('../../types.js').DomainPack}
17
- */
18
- export function createDataQueryPack({ schema = { orders: ['id', 'amount', 'user_id'], users: ['id', 'name'] } } = {}) {
19
- let schemaLoaded = false; // describe/list 後設為 true,schema-before-query 守衛據此放行
25
+ export function createDataQueryPack({ cwd = process.cwd(), db } = {}) {
26
+ const dbPath = db ? (isAbsolute(db) ? db : join(cwd, db)) : join(cwd, 'data.db');
27
+ let schemaSeen = false;
20
28
 
21
29
  const listTables = {
22
- name: 'list_tables', label: '列表', description: '列出所有資料表', readOnly: true,
30
+ name: 'list_tables', label: '列表', readOnly: true, description: '列出資料庫所有資料表',
23
31
  parameters: { type: 'object', properties: {} },
24
- execute: async () => { schemaLoaded = true; return txt(Object.keys(schema)); },
32
+ execute: async () => { schemaSeen = true; const r = sqlite(dbPath, '.tables'); return txt(r.error ? { error: r.error } : { tables: (r.out || '').split(/\s+/).filter(Boolean) }); },
25
33
  };
26
34
  const describeTable = {
27
- name: 'describe_table', label: '表結構', description: '看某表欄位', readOnly: true,
35
+ name: 'describe_table', label: '表結構', readOnly: true, description: '看某表的欄位定義(CREATE 語句)',
28
36
  parameters: { type: 'object', properties: { table: { type: 'string' } }, required: ['table'] },
29
- execute: async (_id, { table }) => { schemaLoaded = true; return txt(schema[table] || { error: '無此表' }); },
37
+ execute: async (_id, { table }) => { schemaSeen = true; const r = sqlite(dbPath, `.schema ${table}`); return txt(r.error ? { error: r.error } : (r.out || '(無此表)')); },
30
38
  };
31
39
  const sqlQuery = {
32
- name: 'sql_query', label: '查詢', description: '執行唯讀 SQL 查詢', readOnly: true,
40
+ name: 'sql_query', label: '查詢', readOnly: true, description: '執行唯讀 SQL 查詢(SELECT/WITH/PRAGMA),回 CSV(含表頭)。',
33
41
  parameters: { type: 'object', properties: { sql: { type: 'string' } }, required: ['sql'] },
34
- execute: async (_id, { sql }) => txt({ note: '(示意)查詢結果', sql, rows: [] }),
42
+ execute: async (_id, { sql }) => {
43
+ if (/\b(insert|update|delete|drop|create|alter|replace)\b/i.test(sql)) return txt({ error: '這是寫入型 SQL,請改用 sql_exec' });
44
+ const r = sqlite(dbPath, sql, ['-header', '-csv']);
45
+ return txt(r.error ? { error: r.error } : (r.out || '(空結果)'));
46
+ },
35
47
  };
36
48
  const sqlExec = {
37
- name: 'sql_exec', label: '寫入', description: '執行寫入型 SQL(INSERT/UPDATE/DELETE', mutating: true,
49
+ name: 'sql_exec', label: '寫入', mutating: true, description: '執行寫入型 SQL(INSERT/UPDATE/DELETE/CREATE/ALTER)。',
38
50
  parameters: { type: 'object', properties: { sql: { type: 'string' } }, required: ['sql'] },
39
- execute: async (_id, { sql }) => txt({ note: '(示意)已執行', sql }),
40
- };
41
- const chartRender = {
42
- name: 'chart_render', label: '畫圖', description: '把查詢結果渲染成圖表', readOnly: true,
43
- parameters: { type: 'object', properties: { spec: { type: 'object' } }, required: ['spec'] },
44
- execute: async (_id, { spec }) => txt({ note: '(示意)已渲染', spec }),
51
+ execute: async (_id, { sql }) => { const r = sqlite(dbPath, sql); return txt(r.error ? { error: r.error } : { ok: true, out: r.out || '' }); },
45
52
  };
46
53
 
47
54
  return {
48
55
  name: 'data-query',
49
- tools: () => [listTables, describeTable, sqlQuery, sqlExec, chartRender],
56
+ tools: () => [listTables, describeTable, sqlQuery, sqlExec],
50
57
  systemPrompt: SYSTEM_PROMPT,
51
58
  contextFiles: ['SCHEMA.md', 'METRICS.md'],
52
- // sql_query 唯讀、sql_exec mutating → 從 metadata 自動推導 mutatingTools=['sql_exec']
59
+ // 只有 sql_exec mutating → kernel 從 metadata 推導
53
60
  preToolPolicy: {
54
- // schema-before-query:沒先看過 schema 就下查詢
61
+ // schema-before-query:沒先看過結構就下 SQL擋(對照 read-before-edit)
55
62
  check: (ctx) => {
56
- if ((ctx.name === 'sql_query' || ctx.name === 'sql_exec') && !schemaLoaded) {
63
+ if ((ctx.name === 'sql_query' || ctx.name === 'sql_exec') && !schemaSeen) {
57
64
  return { block: true, reason: '請先用 list_tables / describe_table 了解結構,再下 SQL。' };
58
65
  }
59
66
  return undefined;
60
67
  },
61
68
  },
62
69
  permissionPolicy: { deny: ['bash:DROP', 'bash:TRUNCATE'], defaultMode: 'default' },
63
- // verify 省略 → 查詢無「自我驗收」概念
64
70
  };
65
71
  }
66
72
 
@@ -0,0 +1,41 @@
1
+ // deep-research pack — 深度研究 agent:拆問題 → 多來源搜尋 → 讀全文查證 → 有引用的結論。
2
+ // 工具:web_search/web_fetch(共用)+ write/read(存/讀報告)。搭配 kernel 的 spawn_agent 可並行子研究。
3
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
4
+ import { isAbsolute, join } from 'node:path';
5
+ import { createWebSearchTool, createWebFetchTool } from '../shared/web-tools.js';
6
+
7
+ const txt = (s) => ({ content: [{ type: 'text', text: typeof s === 'string' ? s : JSON.stringify(s) }] });
8
+
9
+ const SYSTEM_PROMPT = [
10
+ '你是深度研究 agent。給你一個問題,做法:',
11
+ '- 把問題拆成幾個子查詢,用 web_search 找多個來源(不要只查一次)。',
12
+ '- 用 web_fetch 讀來源全文查證,不只看搜尋摘要。',
13
+ '- 交叉比對多個來源;關鍵事實要標注來源 URL。',
14
+ '- 最後給有引用的結論(重要論點附 [來源: URL]);可用 write 存成報告檔。',
15
+ '- 來源衝突或查不到時明說,不杜撰。',
16
+ ].join('\n');
17
+
18
+ export function createDeepResearchPack({ cwd = process.cwd() } = {}) {
19
+ const abs = (p) => (isAbsolute(p) ? p : join(cwd, p));
20
+
21
+ const writeReport = {
22
+ name: 'write', label: '存報告', mutating: true, description: '把研究報告/筆記寫入檔案(建立或覆寫)。',
23
+ parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] },
24
+ execute: async (_id, { path, content }) => { writeFileSync(abs(path), content ?? '', 'utf8'); return txt({ written: path, bytes: Buffer.byteLength(content ?? '') }); },
25
+ };
26
+ const readTool = {
27
+ name: 'read', label: '讀檔', readOnly: true, description: '讀回已存的報告/筆記',
28
+ parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
29
+ execute: async (_id, { path }) => { const p = abs(path); return existsSync(p) ? txt(readFileSync(p, 'utf8')) : txt({ error: '檔案不存在', path }); },
30
+ };
31
+
32
+ return {
33
+ name: 'deep-research',
34
+ tools: () => [createWebSearchTool(), createWebFetchTool(), writeReport, readTool],
35
+ systemPrompt: SYSTEM_PROMPT,
36
+ contextFiles: ['RESEARCH.md'],
37
+ permissionPolicy: { defaultMode: 'default' },
38
+ };
39
+ }
40
+
41
+ export const deepResearchPack = createDeepResearchPack();