xitto-kernel 0.2.0 → 0.3.0
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/CHANGELOG.md +19 -0
- package/README.md +36 -1
- package/package.json +16 -3
- package/src/app/cli.js +46 -8
- package/src/app/index.js +1 -0
- package/src/app/main.js +16 -1
- package/src/app/md-render.js +95 -0
- package/src/app/server.js +102 -0
- package/src/app/tui-run.js +198 -0
- package/src/app/tui.js +398 -0
- package/src/packs/coding/index.js +11 -71
- package/src/packs/data-query/index.js +35 -29
- package/src/packs/deep-research/index.js +41 -0
- package/src/packs/devops/index.js +30 -0
- package/src/packs/general/index.js +38 -38
- package/src/packs/shared/code-nav.js +0 -0
- package/src/packs/shared/fs-tools.js +82 -0
- package/src/packs/shared/web-tools.js +57 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Ink TUI driver — 把 kernel 接上 tui.js 的 store/handlers/App(對標 Claude Code 的全 TUI 體驗)。
|
|
2
|
+
// 常駐狀態列、串流即時重繪、Esc 中斷、權限 Select、@檔案/!bash/#記憶/斜線指令。
|
|
3
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
4
|
+
import { isAbsolute, join } from 'node:path';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { execSync } from 'node:child_process';
|
|
7
|
+
import { createKernel } from '../kernel/index.js';
|
|
8
|
+
import { createStore, mountTui, gutter } from './tui.js';
|
|
9
|
+
import { md } from './md-render.js';
|
|
10
|
+
|
|
11
|
+
const summarize = (args) => { const s = JSON.stringify(args ?? {}); return s.length > 60 ? s.slice(0, 57) + '…' : s; };
|
|
12
|
+
const Y = (s) => `\x1b[33m${s}\x1b[39m`; const G = (s) => `\x1b[90m${s}\x1b[39m`; const R = (s) => `\x1b[31m${s}\x1b[39m`; const C = (s) => `\x1b[36m${s}\x1b[39m`;
|
|
13
|
+
|
|
14
|
+
const SLASH = { '/help': '說明', '/goal': '目標循環', '/sandbox': '沙箱', '/auto': '自動核准', '/plan': '計劃模式', '/undo': '撤銷', '/tools': '工具', '/memory': '記憶', '/sessions': '對話', '/resume': '續接', '/cost': '成本', '/clear': '清除', '/exit': '離開' };
|
|
15
|
+
|
|
16
|
+
export function runTui({ pack, model, getApiKey, sandbox = false, resume = null }) {
|
|
17
|
+
const cwd = process.cwd();
|
|
18
|
+
const store = createStore();
|
|
19
|
+
let history = [];
|
|
20
|
+
let sessionId;
|
|
21
|
+
let currentAgent = null;
|
|
22
|
+
let planMode = false;
|
|
23
|
+
let sandboxOn = !!sandbox;
|
|
24
|
+
let autoApprove = false;
|
|
25
|
+
let pendingSelect = null;
|
|
26
|
+
const sessionTok = { in: 0, out: 0 };
|
|
27
|
+
|
|
28
|
+
const askConfirm = (name, args, danger) => {
|
|
29
|
+
if (autoApprove && !danger) return Promise.resolve('yes');
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
const opts = danger ? ['允許一次', '拒絕'] : ['允許', '此工具全部允許', '拒絕'];
|
|
32
|
+
const map = danger ? ['yes', 'no'] : ['yes', 'always', 'no'];
|
|
33
|
+
pendingSelect = { resolve, map };
|
|
34
|
+
store.askSelect((danger ? R(`⛔ 危險:${danger}\n`) : '') + Y(`允許 ${name}`) + G(`(${summarize(args)})`), opts);
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const kernel = createKernel(pack, {
|
|
39
|
+
model, getApiKey,
|
|
40
|
+
sandbox: { enabled: sandboxOn }, getSandbox: () => sandboxOn,
|
|
41
|
+
getPlanMode: () => planMode, confirm: askConfirm,
|
|
42
|
+
});
|
|
43
|
+
sessionId = kernel.session.newId();
|
|
44
|
+
if (resume) {
|
|
45
|
+
const data = resume === true ? (kernel.session.latest() && kernel.session.load(kernel.session.latest().id)) : kernel.session.load(resume);
|
|
46
|
+
if (data?.messages?.length) { history = data.messages; sessionId = data.id; }
|
|
47
|
+
}
|
|
48
|
+
const persist = () => { try { kernel.session.save(sessionId, history); } catch { /* 略 */ } };
|
|
49
|
+
|
|
50
|
+
// ── kernel 事件 → store ──
|
|
51
|
+
const toolBlock = (name, result, isError) => {
|
|
52
|
+
const text = (result?.content || []).map((c) => c.text || '').join(' ').replace(/\s+/g, ' ').trim();
|
|
53
|
+
return Y(`⏺ ${name}`) + '\n' + (isError ? R(' ⎿ ✗ ' + text.slice(0, 200)) : G(' ⎿ ✓ ' + text.slice(0, 120)));
|
|
54
|
+
};
|
|
55
|
+
const onEvent = (ev) => {
|
|
56
|
+
switch (ev.type) {
|
|
57
|
+
case 'message_update': {
|
|
58
|
+
const a = ev.assistantMessageEvent;
|
|
59
|
+
if (a?.type === 'text_delta' && a.delta) store.appendLive(a.delta);
|
|
60
|
+
else if (a?.type === 'thinking_delta' && a.delta) store.appendThinking(a.delta);
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
case 'message_end': {
|
|
64
|
+
const u = ev.message?.usage;
|
|
65
|
+
if (u) { sessionTok.in += u.input || 0; sessionTok.out += u.output || 0; const used = (u.input || 0) + (u.cacheRead || 0) + (u.cacheWrite || 0); if (used) store.set({ ctx: { used, total: model.contextWindow || 0 } }); }
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
case 'tool_execution_start':
|
|
69
|
+
store.finalizeLive();
|
|
70
|
+
if (ev.toolName === 'todo_write' && Array.isArray(ev.args?.todos)) {
|
|
71
|
+
store.pushBlock(C('☑ 待辦') + '\n' + ev.args.todos.map((t) => ' ' + (t.status === 'completed' ? '\x1b[32m☑\x1b[39m ' + G(t.content) : t.status === 'in_progress' ? Y('◐ ') + t.content : G('☐ ' + t.content))).join('\n'));
|
|
72
|
+
} else {
|
|
73
|
+
store.setTool({ name: ev.toolName, summary: summarize(ev.args) });
|
|
74
|
+
}
|
|
75
|
+
break;
|
|
76
|
+
case 'tool_execution_end':
|
|
77
|
+
store.setTool(null);
|
|
78
|
+
if (ev.toolName !== 'todo_write') store.pushBlock(toolBlock(ev.toolName, ev.result, ev.isError));
|
|
79
|
+
break;
|
|
80
|
+
case 'verify_start': store.finalizeLive(); store.pushBlock(G(' 🔎 自動驗收…')); break;
|
|
81
|
+
case 'verify_end': store.pushBlock(ev.ok ? G(' ✓ 驗收通過') : Y(' ✗ 驗收失敗,修正中…')); break;
|
|
82
|
+
case 'compact': store.pushBlock(G(` ⊙ 已壓縮上下文:${ev.tokensBefore}→${ev.tokensAfter} tokens`)); break;
|
|
83
|
+
case 'hook_fail': store.pushBlock(Y(` ✗ hook 失敗 ${ev.command}`)); break;
|
|
84
|
+
case 'agent_end': store.finalizeLive(); break;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// ── @檔案展開 ──
|
|
89
|
+
const expandMentions = (text) => text.replace(/(^|\s)@(\S+)/g, (m, sp, p) => {
|
|
90
|
+
const fp = isAbsolute(p) ? p : join(cwd, p);
|
|
91
|
+
if (existsSync(fp)) { try { return `${sp}${p}\n\n<file path="${p}">\n${readFileSync(fp, 'utf8').slice(0, 8000)}\n</file>`; } catch { return m; } }
|
|
92
|
+
return m;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const refreshGit = () => {
|
|
96
|
+
try { const b = execSync('git rev-parse --abbrev-ref HEAD', { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); const dirty = execSync('git status --short', { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); store.set({ gitLabel: b ? `⎇ ${b}${dirty ? ' ✱' : ''}` : '' }); } catch { store.set({ gitLabel: '' }); }
|
|
97
|
+
};
|
|
98
|
+
const setStatus = () => { store.set({ modelLabel: model.id, cwdLabel: cwd.replace(homedir(), '~'), sandboxLabel: sandboxOn ? '🔒 sandbox' : '', permLabel: autoApprove ? '⚡ auto' : '' }); store.setPlan(planMode); refreshGit(); };
|
|
99
|
+
|
|
100
|
+
const cmdHistory = [];
|
|
101
|
+
let ink;
|
|
102
|
+
const doExit = () => { persist(); try { ink?.unmount(); } catch { /* 略 */ } process.exit(0); };
|
|
103
|
+
|
|
104
|
+
// ── 斜線指令 ──
|
|
105
|
+
const slash = (input) => {
|
|
106
|
+
const [cmd, arg] = input.split(/\s+/);
|
|
107
|
+
switch (cmd) {
|
|
108
|
+
case '/exit': case '/quit': doExit(); return true;
|
|
109
|
+
case '/help': store.pushBlock(G(Object.entries(SLASH).map(([k, v]) => ` ${k} ${v}`).join('\n') + '\n @檔案 引用 · !命令 直接跑 · #文字 存記憶')); return true;
|
|
110
|
+
case '/sandbox': sandboxOn = arg ? arg === 'on' : !sandboxOn; setStatus(); store.pushBlock(sandboxOn ? Y('🔒 沙箱開') : G('沙箱關')); return true;
|
|
111
|
+
case '/auto': autoApprove = arg ? arg === 'on' : !autoApprove; setStatus(); store.pushBlock(autoApprove ? Y('⚡ 自動核准開') : G('自動核准關')); return true;
|
|
112
|
+
case '/plan': planMode = arg ? arg === 'on' : !planMode; setStatus(); store.pushBlock(planMode ? C('📋 計劃模式開') : G('計劃模式關')); return true;
|
|
113
|
+
case '/undo': { const r = kernel.undo(); store.pushBlock(r.undone ? G(`↩ 已撤銷 ${r.path}`) : Y(r.reason)); return true; }
|
|
114
|
+
case '/tools': store.pushBlock(G(kernel.registry.names().join(' '))); return true;
|
|
115
|
+
case '/memory': { const m = kernel.memory.list(); store.pushBlock(m.length ? G(m.map((x) => ' • ' + x).join('\n')) : G('(尚無記憶)')); return true; }
|
|
116
|
+
case '/cost': store.pushBlock(G(`本 session 累計:${sessionTok.in + sessionTok.out} tokens(in ${sessionTok.in} / out ${sessionTok.out})`)); return true;
|
|
117
|
+
case '/sessions': { const ss = kernel.session.list(); store.pushBlock(ss.length ? G(ss.map((s) => ` ${s.id} [${s.count} 則]`).join('\n')) : G('(尚無對話)')); return true; }
|
|
118
|
+
case '/resume': { const t = arg || kernel.session.latest()?.id; const d = t ? kernel.session.load(t) : null; if (d?.messages?.length) { history = d.messages; sessionId = d.id; store.pushBlock(G(`(已續接 ${d.id},${d.messages.length} 則)`)); } else store.pushBlock(Y('找不到可續接的 session')); return true; }
|
|
119
|
+
case '/clear': history = []; sessionId = kernel.session.newId(); store.pushBlock(G('(已清除歷史,開新 session)')); return true;
|
|
120
|
+
default: store.pushBlock(R(`未知指令 ${cmd}(/help)`)); return true;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// ── 送出 ──
|
|
125
|
+
async function onSubmit(raw) {
|
|
126
|
+
const input = (raw || '').trim();
|
|
127
|
+
if (!input) return;
|
|
128
|
+
if (store.get().mode === 'busy') { try { currentAgent?.steer({ role: 'user', content: input }); store.pushBlock(G(' ↪ 已插入引導')); } catch { /* 略 */ } return; }
|
|
129
|
+
cmdHistory.push(input);
|
|
130
|
+
store.pushBlock('\n' + input.split('\n').map((l) => `\x1b[34m▌ \x1b[1m${l}\x1b[22m\x1b[39m`).join('\n'));
|
|
131
|
+
|
|
132
|
+
if (input.startsWith('/goal ') || input === '/goal') { return runGoal(input.slice(5).trim()); }
|
|
133
|
+
if (input.startsWith('/')) { slash(input); return; }
|
|
134
|
+
if (input.startsWith('!')) { const r = (() => { try { return execSync(input.slice(1), { cwd, encoding: 'utf8', timeout: 60000, stdio: ['ignore', 'pipe', 'pipe'] }); } catch (e) { return (e.stdout || '') + (e.stderr || '') || e.message; } })(); store.pushBlock(G('$ ' + input.slice(1)) + '\n' + (r.trim() || '(no output)')); return; }
|
|
135
|
+
if (input.startsWith('#')) { const r = kernel.memory.save(input.slice(1).trim()); store.pushBlock(r.saved ? G('✎ 已記住:' + r.saved) : G('(記憶已存在或空)')); return; }
|
|
136
|
+
|
|
137
|
+
store.setMode('busy'); store.set({ busyAt: Date.now() });
|
|
138
|
+
try {
|
|
139
|
+
const text = expandMentions(planMode ? `[計劃模式:只規劃、列步驟與會改動的檔案,不要實際寫檔或執行命令]\n\n${input}` : input);
|
|
140
|
+
const r = await kernel.runTurn(text, { history, onEvent, onAgent: (a) => { currentAgent = a; } });
|
|
141
|
+
store.finalizeLive(); history = r.messages; persist();
|
|
142
|
+
} catch (e) { store.finalizeLive(); store.pushBlock(R('錯誤:' + e.message)); }
|
|
143
|
+
finally { currentAgent = null; store.setTool(null); store.set({ busyAt: null }); store.setMode('idle'); refreshGit(); }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function runGoal(goal) {
|
|
147
|
+
if (!goal) { store.pushBlock(G('用法 /goal <目標>')); return; }
|
|
148
|
+
store.pushBlock(C('🎯 目標:') + goal);
|
|
149
|
+
store.setMode('busy'); store.set({ busyAt: Date.now() });
|
|
150
|
+
try {
|
|
151
|
+
const r = await kernel.runGoal(goal, {
|
|
152
|
+
history,
|
|
153
|
+
onRound: ({ round, maxRounds }) => { store.finalizeLive(); store.pushBlock(Y(`🔁 第 ${round}/${maxRounds} 輪`)); },
|
|
154
|
+
onCheck: ({ done, remaining }) => { store.finalizeLive(); store.pushBlock(done ? G(' ✓ 驗收:已達成') : G(' ↻ ' + remaining)); },
|
|
155
|
+
onEvent, onAgent: (a) => { currentAgent = a; },
|
|
156
|
+
});
|
|
157
|
+
store.finalizeLive(); history = r.history; persist();
|
|
158
|
+
store.pushBlock(r.done ? G(`✅ 目標達成(${r.rounds} 輪)`) : Y(`⚠ 未達成(${r.rounds} 輪)`));
|
|
159
|
+
} catch (e) { store.finalizeLive(); store.pushBlock(R('錯誤:' + e.message)); }
|
|
160
|
+
finally { currentAgent = null; store.setTool(null); store.set({ busyAt: null }); store.setMode('idle'); refreshGit(); }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── 補全(斜線 + @檔案)──
|
|
164
|
+
const complete = (text) => {
|
|
165
|
+
const sm = text.match(/^\/(\S*)$/);
|
|
166
|
+
if (sm) { const items = Object.keys(SLASH).filter((c) => c.startsWith('/' + sm[1])).map((c) => ({ value: c, desc: SLASH[c] })); return items.length ? { start: 0, items } : null; }
|
|
167
|
+
const am = text.match(/(?:^|\s)@(\S*)$/);
|
|
168
|
+
if (am) {
|
|
169
|
+
const frag = am[1]; const at = text.length - frag.length - 1; const slashI = frag.lastIndexOf('/');
|
|
170
|
+
const dir = slashI >= 0 ? frag.slice(0, slashI + 1) : ''; const base = slashI >= 0 ? frag.slice(slashI + 1) : frag;
|
|
171
|
+
let entries; try { entries = readdirSync(join(cwd, dir), { withFileTypes: true }); } catch { return null; }
|
|
172
|
+
const items = entries.filter((e) => e.name.startsWith(base) && !e.name.startsWith('.')).slice(0, 8).map((e) => '@' + dir + e.name + (e.isDirectory() ? '/' : ''));
|
|
173
|
+
return items.length ? { start: at, items } : null;
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const abort = () => { try { currentAgent?.abort(); } catch { /* 略 */ } if (pendingSelect) { const p = pendingSelect; pendingSelect = null; store.clearSelect(); p.resolve('no'); } store.finalizeLive(); store.setTool(null); store.set({ busyAt: null }); store.setMode('idle'); };
|
|
179
|
+
let lastCtrlC = 0;
|
|
180
|
+
const handlers = {
|
|
181
|
+
onSubmit,
|
|
182
|
+
onCtrlC: () => { if (store.get().mode === 'busy') { abort(); store.pushBlock(Y('⏹ 已中斷')); return; } const now = Date.now(); if (now - lastCtrlC < 2000) doExit(); else { lastCtrlC = now; store.pushBlock(G('再按一次 Ctrl+C 離開')); } },
|
|
183
|
+
onEscape: () => { if (store.get().mode === 'busy') { abort(); store.pushBlock(Y('⏹ 已中斷')); } },
|
|
184
|
+
getHistory: () => cmdHistory,
|
|
185
|
+
complete,
|
|
186
|
+
onSelectChoice: (idx) => { const p = pendingSelect; pendingSelect = null; store.clearSelect(); if (p) p.resolve(p.map[idx] ?? 'no'); },
|
|
187
|
+
onSelectCancel: () => { const p = pendingSelect; pendingSelect = null; store.clearSelect(); if (p) p.resolve('no'); },
|
|
188
|
+
onSelectAbort: () => { abort(); },
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// 橫幅 + 狀態列
|
|
192
|
+
store.pushBlock('\n' + C('✻ ') + '\x1b[1mxitto-kernel\x1b[22m' + G(` · ${pack.name} pack · ${model.id}`) + '\n' + G(' Esc 中斷 · /help · @檔案 · !命令 · #記憶 · Tab 補全'));
|
|
193
|
+
setStatus();
|
|
194
|
+
store.setMode('idle'); store.setPlaceholder('輸入訊息…');
|
|
195
|
+
process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
|
|
196
|
+
ink = mountTui({ store, handlers });
|
|
197
|
+
return ink;
|
|
198
|
+
}
|
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
|
+
}
|