minimal-agent 0.1.9 → 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/README.md +383 -122
- package/package.json +19 -12
- package/plugins/HOW-TO-WRITE-A-PLUGIN.md +186 -0
- package/plugins/ralph-wiggum/commands/ralph-loop.md +6 -16
- package/plugins/ralph-wiggum/plugin.js +205 -0
- package/plugins/ralph-wiggum/src/goalState.js +260 -0
- package/plugins/ralph-wiggum/src/sentinels.js +21 -0
- package/plugins/ralph-wiggum/src/stopHookRunner.js +104 -0
- package/plugins/ralph-wiggum/src/verificationGate.js +202 -0
- package/plugins/workflow-runner/.claude-plugin/plugin.json +5 -0
- package/plugins/workflow-runner/commands/workflow.md +15 -0
- package/plugins/workflow-runner/commands/workflows.md +8 -0
- package/plugins/workflow-runner/plugin.js +36 -0
- package/plugins/workflow-runner/src/expressions.js +369 -0
- package/plugins/workflow-runner/src/index.js +174 -0
- package/plugins/workflow-runner/src/loader.js +183 -0
- package/plugins/workflow-runner/src/runner.js +290 -0
- package/plugins/workflow-runner/src/stepExecutors/assert.js +28 -0
- package/plugins/workflow-runner/src/stepExecutors/llm.js +44 -0
- package/plugins/workflow-runner/src/stepExecutors/skill.js +103 -0
- package/plugins/workflow-runner/src/stepExecutors/tool.js +35 -0
- package/plugins/workflow-runner/src/types.js +59 -0
- package/plugins/workflow-runner/src/workflowState.js +46 -0
- package/skills/image-gen-openrouter/SKILL.md +121 -0
- package/skills/subtitle-srt/SKILL.md +134 -0
- package/skills/tts-zh/SKILL.md +137 -0
- package/skills/video-compose/SKILL.md +139 -0
- package/src/bootstrap/cwdArg.js +22 -0
- package/src/bootstrap/workingDir.js +31 -0
- package/src/cli/configWizard.js +272 -0
- package/src/cli/print.js +192 -0
- package/src/config/configFile.js +78 -0
- package/src/config.js +118 -0
- package/src/context/compact.js +357 -0
- package/src/context/microCompactLite.js +151 -0
- package/src/context/persistContext.js +109 -0
- package/src/context/reactiveCompact.js +121 -0
- package/src/context/sessionPath.js +58 -0
- package/src/context/snipCompact.js +112 -0
- package/src/context/tokenCounter.js +66 -0
- package/src/llm/client.js +182 -0
- package/src/loop.js +230 -0
- package/src/main.js +116 -0
- package/src/plugin-sdk.js +24 -0
- package/src/plugins/commandRouter.js +169 -0
- package/src/plugins/hookEngine.js +258 -0
- package/src/plugins/pluginApi.js +23 -0
- package/src/plugins/pluginLoader.js +71 -0
- package/src/plugins/pluginRunner.js +65 -0
- package/src/plugins/transcript.js +171 -0
- package/src/prompts/projectInstructions.js +48 -0
- package/src/prompts/skillList.js +126 -0
- package/src/prompts/system.js +155 -0
- package/src/session/runTurn.js +41 -0
- package/src/session/sessionState.js +19 -0
- package/src/tools/bash/bash.js +352 -0
- package/src/tools/bash/semantics.js +85 -0
- package/src/tools/bash/warnings.js +98 -0
- package/src/tools/edit/edit.js +253 -0
- package/src/tools/edit/multi-edit.js +155 -0
- package/src/tools/glob/glob.js +97 -0
- package/src/tools/grep/grep.js +185 -0
- package/src/tools/grep/rgPath.js +173 -0
- package/src/tools/index.js +94 -0
- package/src/tools/read/read.js +209 -0
- package/src/tools/shared/fileState.js +61 -0
- package/src/tools/shared/fileUtils.js +281 -0
- package/src/tools/shared/schemas.js +16 -0
- package/src/tools/types.js +21 -0
- package/src/tools/webbrowser/browser.js +55 -0
- package/src/tools/webbrowser/webbrowser.js +194 -0
- package/src/tools/webfetch/preapproved.js +267 -0
- package/src/tools/webfetch/webfetch.js +317 -0
- package/src/tools/websearch/websearch.js +161 -0
- package/src/tools/write/write.js +125 -0
- package/src/types/turndown.d.ts +23 -0
- package/src/types.js +16 -0
- package/src/ui/App.js +37 -0
- package/src/ui/InputBox.js +240 -0
- package/src/ui/MessageList.js +28 -0
- package/src/ui/Root.js +70 -0
- package/src/ui/StatusLine.js +41 -0
- package/src/ui/ToolStatus.js +11 -0
- package/src/ui/hooks/useChat.js +234 -0
- package/src/ui/hooks/usePasteHandler.js +137 -0
- package/src/ui/hooks/useTextBuffer.js +55 -0
- package/src/ui/hooks/useTokenUsage.js +30 -0
- package/src/ui/textBuffer.js +217 -0
- package/src/utils/packageRoot.js +37 -0
- package/src/utils/resourcePaths.js +49 -0
- package/src/utils/zodToJson.js +29 -0
- package/workflows/book-review-short.yaml +99 -0
- package/workflows/e2e-write-greet.yaml +27 -0
- package/workflows/schema.json +74 -0
- package/workflows/youtube-shorts.yaml +171 -0
- package/dist/main.js +0 -5936
- package/plugins/ralph-wiggum/scripts/setup-ralph-loop.sh +0 -203
package/src/ui/Root.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* ============================================================
|
|
4
|
+
* src/ui/Root.tsx —— TUI 顶层 phase 切换器
|
|
5
|
+
* ------------------------------------------------------------
|
|
6
|
+
* 作为 Ink 渲染树的根节点,根据"有没有 Provider 配置"决定渲染:
|
|
7
|
+
* - 没有 → <ConfigWizard>:用户填完 + 测试通过后切到 App
|
|
8
|
+
* - 有 → <App>:常规对话界面
|
|
9
|
+
*
|
|
10
|
+
* 为什么需要这一层而不是直接在 main.tsx 二选一渲染?
|
|
11
|
+
* 向导成功后必须直接进 App,不能让用户手动重跑命令。在 React 树里用 state
|
|
12
|
+
* 切换是最自然的方式,无需 ink unmount/remount 的额外开销。
|
|
13
|
+
*
|
|
14
|
+
* initialHistory 的构造(buildFullSystemPrompt + 历史持久化加载)在向导后才跑,
|
|
15
|
+
* 因为:
|
|
16
|
+
* - 系统提示词本身不依赖 provider,但流程上推迟到"确定要进 App"那一刻
|
|
17
|
+
* 最干净。
|
|
18
|
+
* - 第一次启动时常常没历史可加载,buildInitialHistory 行为是只返 system
|
|
19
|
+
* prompt,开销可忽略。
|
|
20
|
+
* ============================================================
|
|
21
|
+
*/
|
|
22
|
+
import { useEffect, useState } from 'react';
|
|
23
|
+
import { Box, Text } from 'ink';
|
|
24
|
+
import { getWorkingDir } from '../bootstrap/workingDir.js';
|
|
25
|
+
import { ConfigWizard } from '../cli/configWizard.js';
|
|
26
|
+
import { loadContext } from '../context/persistContext.js';
|
|
27
|
+
import { buildFullSystemPrompt } from '../prompts/system.js';
|
|
28
|
+
import { ALL_TOOLS } from '../tools/index.js';
|
|
29
|
+
import { App } from './App.js';
|
|
30
|
+
export function Root({ initialProvider }) {
|
|
31
|
+
const [phase, setPhase] = useState(initialProvider
|
|
32
|
+
? { kind: 'loading-history', provider: initialProvider }
|
|
33
|
+
: { kind: 'wizard' });
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (phase.kind !== 'loading-history')
|
|
36
|
+
return;
|
|
37
|
+
let cancelled = false;
|
|
38
|
+
void (async () => {
|
|
39
|
+
const history = await buildInitialHistory();
|
|
40
|
+
if (cancelled)
|
|
41
|
+
return;
|
|
42
|
+
setPhase({ kind: 'app', provider: phase.provider, initialHistory: history });
|
|
43
|
+
})();
|
|
44
|
+
return () => {
|
|
45
|
+
cancelled = true;
|
|
46
|
+
};
|
|
47
|
+
}, [phase]);
|
|
48
|
+
if (phase.kind === 'wizard') {
|
|
49
|
+
return (_jsx(ConfigWizard, { onSuccess: (provider) => {
|
|
50
|
+
setPhase({ kind: 'loading-history', provider });
|
|
51
|
+
} }));
|
|
52
|
+
}
|
|
53
|
+
if (phase.kind === 'loading-history') {
|
|
54
|
+
return (_jsx(Box, { paddingX: 1, children: _jsx(Text, { color: "gray", children: "\u6B63\u5728\u52A0\u8F7D\u5386\u53F2\u4E0A\u4E0B\u6587..." }) }));
|
|
55
|
+
}
|
|
56
|
+
return _jsx(App, { provider: phase.provider, initialHistory: phase.initialHistory });
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* 复用 main.tsx 的逻辑:fresh system prompt + 上次未关闭的对话。
|
|
60
|
+
* 这里独立一份是为了让 Root 内部能在向导成功后即时构造,不必返工 main.tsx。
|
|
61
|
+
*/
|
|
62
|
+
async function buildInitialHistory() {
|
|
63
|
+
const content = await buildFullSystemPrompt(getWorkingDir(), ALL_TOOLS);
|
|
64
|
+
const fresh = { role: 'system', content };
|
|
65
|
+
const persisted = await loadContext();
|
|
66
|
+
if (!persisted || persisted.length === 0)
|
|
67
|
+
return [fresh];
|
|
68
|
+
const rest = persisted[0]?.role === 'system' ? persisted.slice(1) : persisted;
|
|
69
|
+
return [fresh, ...rest];
|
|
70
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { sep } from 'node:path';
|
|
5
|
+
import { getWorkingDir } from '../bootstrap/workingDir.js';
|
|
6
|
+
import { useTokenUsage } from './hooks/useTokenUsage.js';
|
|
7
|
+
export function StatusLine({ provider, history, pluginProgress, }) {
|
|
8
|
+
const usage = useTokenUsage(history, provider);
|
|
9
|
+
const ratio = usage.tokens / usage.threshold;
|
|
10
|
+
const color = ratio >= 1 ? 'red' : ratio >= 0.7 ? 'yellow' : 'green';
|
|
11
|
+
const cwdDisplay = shortenPath(getWorkingDir());
|
|
12
|
+
return (_jsxs(Box, { children: [pluginProgress && (_jsxs(_Fragment, { children: [_jsxs(Text, { color: "magenta", children: ["\uD83D\uDD04 ", pluginProgress.pluginId, " "] }), _jsxs(Text, { color: "magenta", children: [pluginProgress.current, "/", pluginProgress.max] }), _jsx(Text, { color: "gray", children: ' · ' })] })), _jsx(Text, { color: "cyan", children: "cwd " }), _jsx(Text, { color: "gray", children: cwdDisplay }), _jsx(Text, { color: "gray", children: ' · ' }), _jsxs(Text, { color: "gray", children: [provider.name, "/", provider.model] }), _jsx(Text, { color: "gray", children: ' · ' }), _jsxs(Text, { color: color, children: ["tokens ", fmt(usage.tokens), " / ", fmt(usage.contextWindow)] }), _jsx(Text, { color: "gray", children: ' · ' }), _jsx(Text, { color: color, children: usage.toThreshold > 0 ? `距压缩 ${fmt(usage.toThreshold)}` : `已超阈值(下轮压缩)` })] }));
|
|
13
|
+
}
|
|
14
|
+
/** 把整数格式化为 1.2K / 12.3K / 1.5M */
|
|
15
|
+
function fmt(n) {
|
|
16
|
+
if (n < 1000)
|
|
17
|
+
return `${n}`;
|
|
18
|
+
if (n < 1_000_000)
|
|
19
|
+
return `${(n / 1000).toFixed(1)}K`;
|
|
20
|
+
return `${(n / 1_000_000).toFixed(2)}M`;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 把绝对路径精简成适合状态栏显示的形式:
|
|
24
|
+
* - home 下 → 以 ~ 开头
|
|
25
|
+
* - 超过 40 字符 → 保留首段 + … + 末两段
|
|
26
|
+
*/
|
|
27
|
+
export function shortenPath(abs) {
|
|
28
|
+
const home = homedir();
|
|
29
|
+
let p = abs;
|
|
30
|
+
if (home && (p === home || p.startsWith(home + sep))) {
|
|
31
|
+
p = '~' + p.slice(home.length);
|
|
32
|
+
}
|
|
33
|
+
if (p.length <= 40)
|
|
34
|
+
return p;
|
|
35
|
+
const parts = p.split(/[\\/]/).filter(Boolean);
|
|
36
|
+
if (parts.length <= 3)
|
|
37
|
+
return p;
|
|
38
|
+
const head = p.startsWith('~') ? '~' : parts[0];
|
|
39
|
+
const tail = parts.slice(-2).join(sep);
|
|
40
|
+
return `${head}${sep}…${sep}${tail}`;
|
|
41
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
export function ToolStatus({ status, compacting }) {
|
|
4
|
+
if (compacting) {
|
|
5
|
+
return (_jsx(Box, { children: _jsx(Text, { color: "magenta", bold: true, children: "\uD83D\uDDDC \u6B63\u5728\u538B\u7F29\u5BF9\u8BDD\u5386\u53F2..." }) }));
|
|
6
|
+
}
|
|
7
|
+
if (status) {
|
|
8
|
+
return (_jsx(Box, { children: _jsxs(Text, { color: "yellow", children: ['⏳ ', _jsx(Text, { bold: true, children: status.toolName }), `(${status.argsPreview})`] }) }));
|
|
9
|
+
}
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/ui/hooks/useChat.ts —— 把 loop.runQuery 桥接到 React 状态
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 这个 hook 是 UI 层的核心:
|
|
6
|
+
* - 维护 history 数组(用 ref 持久引用,version 计数器触发重渲染)
|
|
7
|
+
* - 维护"当前正在打字"的 streamingText
|
|
8
|
+
* - 维护"当前工具状态" toolStatus
|
|
9
|
+
* - 暴露 submit() 给 InputBox 调用、abort() 给 Ctrl+C 处理
|
|
10
|
+
*
|
|
11
|
+
* 为什么 history 用 ref 而不是 state?
|
|
12
|
+
* runQuery 内部会 mutate history 数组(push 新消息、压缩时清空重建),
|
|
13
|
+
* React state 的"不可变更新"规则不适合。用 ref 持有可变引用,
|
|
14
|
+
* 用 version 计数器驱动 setState 触发 re-render 是更顺手的写法。
|
|
15
|
+
* ============================================================
|
|
16
|
+
*/
|
|
17
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
18
|
+
import { getWorkingDir } from '../../bootstrap/workingDir.js';
|
|
19
|
+
import { forceCompact } from '../../context/compact.js';
|
|
20
|
+
import { clearContext } from '../../context/persistContext.js';
|
|
21
|
+
import { resetReactiveCompactState } from '../../context/reactiveCompact.js';
|
|
22
|
+
import { buildFullSystemPrompt } from '../../prompts/system.js';
|
|
23
|
+
import { ALL_TOOLS } from '../../tools/index.js';
|
|
24
|
+
import { clearFileState } from '../../tools/shared/fileState.js';
|
|
25
|
+
import { runWithPlugins } from '../../plugins/pluginRunner.js';
|
|
26
|
+
export function useChat(args) {
|
|
27
|
+
// 历史用 ref(可变),用 version 触发 re-render
|
|
28
|
+
const historyRef = useRef(args.initialHistory.slice());
|
|
29
|
+
const [version, setVersion] = useState(0);
|
|
30
|
+
const bump = useCallback(() => setVersion((v) => v + 1), []);
|
|
31
|
+
const [streamingText, setStreamingText] = useState('');
|
|
32
|
+
const [toolStatus, setToolStatus] = useState(null);
|
|
33
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
34
|
+
const [error, setError] = useState(null);
|
|
35
|
+
const [interrupted, setInterrupted] = useState(false);
|
|
36
|
+
const [compacting, setCompacting] = useState(false);
|
|
37
|
+
const [pluginProgress, setPluginProgress] = useState(null);
|
|
38
|
+
const abortRef = useRef(null);
|
|
39
|
+
// 卸载时取消任何进行中的请求
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
return () => {
|
|
42
|
+
abortRef.current?.abort();
|
|
43
|
+
};
|
|
44
|
+
}, []);
|
|
45
|
+
const submit = useCallback(async (input) => {
|
|
46
|
+
if (isLoading)
|
|
47
|
+
return;
|
|
48
|
+
const trimmed = input.trim();
|
|
49
|
+
if (trimmed.length === 0)
|
|
50
|
+
return;
|
|
51
|
+
setIsLoading(true);
|
|
52
|
+
setError(null);
|
|
53
|
+
setInterrupted(false);
|
|
54
|
+
setStreamingText('');
|
|
55
|
+
setPluginProgress(null);
|
|
56
|
+
const ac = new AbortController();
|
|
57
|
+
abortRef.current = ac;
|
|
58
|
+
try {
|
|
59
|
+
for await (const ev of runWithPlugins(trimmed, {
|
|
60
|
+
provider: args.provider,
|
|
61
|
+
history: historyRef.current,
|
|
62
|
+
signal: ac.signal,
|
|
63
|
+
})) {
|
|
64
|
+
handleEvent(ev, {
|
|
65
|
+
setStreamingText,
|
|
66
|
+
setToolStatus,
|
|
67
|
+
setCompacting,
|
|
68
|
+
setError,
|
|
69
|
+
setInterrupted,
|
|
70
|
+
setPluginProgress,
|
|
71
|
+
bump,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch (e) {
|
|
76
|
+
setError(`未捕获异常:${e.message}`);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
setIsLoading(false);
|
|
80
|
+
setStreamingText('');
|
|
81
|
+
setToolStatus(null);
|
|
82
|
+
setCompacting(false);
|
|
83
|
+
setPluginProgress(null);
|
|
84
|
+
abortRef.current = null;
|
|
85
|
+
args.onPersist?.(historyRef.current);
|
|
86
|
+
}
|
|
87
|
+
}, [args.provider, args.onPersist, bump, isLoading]);
|
|
88
|
+
const abort = useCallback(() => {
|
|
89
|
+
abortRef.current?.abort();
|
|
90
|
+
}, []);
|
|
91
|
+
/**
|
|
92
|
+
* /new —— 清空历史,重新生成 system prompt(带最新 skills + 项目指令)。
|
|
93
|
+
* 不复用启动时的初始 history 快照,避免把磁盘里的损坏数据带回来。
|
|
94
|
+
* isLoading 时拒绝执行,避免和正在跑的 runQuery 抢状态。
|
|
95
|
+
*/
|
|
96
|
+
const clearHistory = useCallback(async () => {
|
|
97
|
+
if (isLoading)
|
|
98
|
+
return;
|
|
99
|
+
// 生成全新的 system prompt(实时扫 skills + 拼项目指令),
|
|
100
|
+
// 与启动时 main.tsx buildInitialHistory 行为完全一致
|
|
101
|
+
const newSystemPrompt = await buildFullSystemPrompt(getWorkingDir(), ALL_TOOLS);
|
|
102
|
+
historyRef.current.length = 0;
|
|
103
|
+
historyRef.current.push({ role: 'system', content: newSystemPrompt });
|
|
104
|
+
// 允许新 session 再次触发反应式压缩自救
|
|
105
|
+
resetReactiveCompactState();
|
|
106
|
+
setStreamingText('');
|
|
107
|
+
setToolStatus(null);
|
|
108
|
+
setError(null);
|
|
109
|
+
setInterrupted(false);
|
|
110
|
+
setCompacting(false);
|
|
111
|
+
bump();
|
|
112
|
+
// 清空磁盘上的持久化文件(防止下次启动又加载损坏数据)
|
|
113
|
+
await clearContext();
|
|
114
|
+
// 清空 pre-read guard 状态表(防止跨 session 的 Read 记录污染新会话)
|
|
115
|
+
clearFileState();
|
|
116
|
+
// 注意:这里不调用 onPersist,因为我们刚刚清空了,没必要再保存一次"空历史"
|
|
117
|
+
}, [bump, isLoading]);
|
|
118
|
+
/**
|
|
119
|
+
* /compact —— 手动跑一次压缩(不看阈值)。
|
|
120
|
+
* isLoading 时拒绝执行;压缩自身的 setIsLoading(true) 会让 InputBox 显示 ⏳。
|
|
121
|
+
*
|
|
122
|
+
* ✅ 关键改进:压缩失败时不保存 history(可能包含损坏数据)
|
|
123
|
+
*/
|
|
124
|
+
const compactNow = useCallback(async () => {
|
|
125
|
+
if (isLoading)
|
|
126
|
+
return;
|
|
127
|
+
setIsLoading(true);
|
|
128
|
+
setCompacting(true);
|
|
129
|
+
setError(null);
|
|
130
|
+
let success = false;
|
|
131
|
+
try {
|
|
132
|
+
const r = await forceCompact(historyRef.current, args.provider);
|
|
133
|
+
historyRef.current.length = 0;
|
|
134
|
+
historyRef.current.push(...r.messages);
|
|
135
|
+
bump();
|
|
136
|
+
success = true; // ✅ 标记成功
|
|
137
|
+
}
|
|
138
|
+
catch (e) {
|
|
139
|
+
setError(`手动压缩失败:${e.message}`);
|
|
140
|
+
// ❌ 不修改 historyRef.current,保持原样(不保存可能损坏的数据)
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
setCompacting(false);
|
|
144
|
+
setIsLoading(false);
|
|
145
|
+
// ✅ 只在成功时才持久化,避免保存损坏的历史到磁盘
|
|
146
|
+
if (success) {
|
|
147
|
+
args.onPersist?.(historyRef.current);
|
|
148
|
+
args.onCompactDone?.();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}, [args.provider, args.onPersist, args.onCompactDone, bump, isLoading]);
|
|
152
|
+
// history 用 useMemo 托管:version 变化时重新计算,返回当前 historyRef.current
|
|
153
|
+
// 这样任何导致 version++ 的操作(submit/clearHistory/compactNow/工具回调 bump)都会
|
|
154
|
+
// 让所有消费 history 的组件(MessageList/StatusLine/ToolStatus)自动收到新引用、触发 re-render。
|
|
155
|
+
const history = useMemo(() => historyRef.current, [version]);
|
|
156
|
+
return {
|
|
157
|
+
history,
|
|
158
|
+
streamingText,
|
|
159
|
+
toolStatus,
|
|
160
|
+
isLoading,
|
|
161
|
+
error,
|
|
162
|
+
interrupted,
|
|
163
|
+
compacting,
|
|
164
|
+
pluginProgress,
|
|
165
|
+
submit,
|
|
166
|
+
abort,
|
|
167
|
+
clearHistory,
|
|
168
|
+
compactNow,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* 把一个事件派发到对应的 setState。
|
|
173
|
+
*
|
|
174
|
+
* 接受 LoopEvent(框架已识别)或 PluginEvent(插件私有,type 可为任意字符串)。
|
|
175
|
+
* switch 只匹配 LoopEvent 字面量 type;未识别走 default no-op,等同静默忽略。
|
|
176
|
+
*/
|
|
177
|
+
function handleEvent(ev, setters) {
|
|
178
|
+
// 收口到 LoopEvent:未识别的 PluginEvent type 走 default no-op
|
|
179
|
+
const e = ev;
|
|
180
|
+
switch (e.type) {
|
|
181
|
+
case 'text':
|
|
182
|
+
setters.setStreamingText((prev) => prev + e.delta);
|
|
183
|
+
break;
|
|
184
|
+
case 'assistant_message':
|
|
185
|
+
// history 已被 runQuery 直接 push,这里只刷一下视图 + 清流式 buffer
|
|
186
|
+
setters.setStreamingText(() => '');
|
|
187
|
+
setters.bump();
|
|
188
|
+
break;
|
|
189
|
+
case 'tool_start':
|
|
190
|
+
setters.setToolStatus({
|
|
191
|
+
toolName: e.toolName,
|
|
192
|
+
argsPreview: e.argsPreview,
|
|
193
|
+
status: 'running',
|
|
194
|
+
});
|
|
195
|
+
setters.bump();
|
|
196
|
+
break;
|
|
197
|
+
case 'tool_end':
|
|
198
|
+
setters.setToolStatus(null);
|
|
199
|
+
setters.bump();
|
|
200
|
+
break;
|
|
201
|
+
case 'compact_start':
|
|
202
|
+
// reactive 自救场景:LLM 失败前可能已经 yield 过部分 text delta,
|
|
203
|
+
// 压缩前清掉残留 streamingText,避免重发后新文本追加在旧 buffer 后面。
|
|
204
|
+
// autoCompact 场景下 streamingText 本来就是空,这里 no-op。
|
|
205
|
+
setters.setStreamingText(() => '');
|
|
206
|
+
setters.setCompacting(true);
|
|
207
|
+
setters.bump();
|
|
208
|
+
break;
|
|
209
|
+
case 'compact_done':
|
|
210
|
+
setters.setCompacting(false);
|
|
211
|
+
setters.bump();
|
|
212
|
+
break;
|
|
213
|
+
case 'turn_done':
|
|
214
|
+
setters.bump();
|
|
215
|
+
break;
|
|
216
|
+
case 'interrupted':
|
|
217
|
+
setters.setInterrupted(true);
|
|
218
|
+
setters.bump();
|
|
219
|
+
break;
|
|
220
|
+
case 'error':
|
|
221
|
+
setters.setError(e.error);
|
|
222
|
+
setters.bump();
|
|
223
|
+
break;
|
|
224
|
+
case 'plugin_progress':
|
|
225
|
+
setters.setPluginProgress({
|
|
226
|
+
pluginId: e.pluginId,
|
|
227
|
+
current: e.current,
|
|
228
|
+
max: e.max ?? 0,
|
|
229
|
+
message: e.message,
|
|
230
|
+
});
|
|
231
|
+
setters.bump();
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/ui/hooks/usePasteHandler.ts —— 粘贴检测 Hook(v0.3 最终版)
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 功能:
|
|
6
|
+
* 1. 检测粘贴输入(通过内容长度阈值)
|
|
7
|
+
* 2. 收集多 Chunk 粘贴内容(处理 stdin 分块到达)
|
|
8
|
+
* 3. ✨ 粘贴期间拦截所有 Enter(防止多行内容被误发送)
|
|
9
|
+
* 4. 跨平台兼容:Win / Mac / Linux
|
|
10
|
+
*
|
|
11
|
+
* v0.3 核心设计原则(符合用户直觉):
|
|
12
|
+
* - Enter(单独按):始终返回 'continue' → 调用方处理为**发送消息**
|
|
13
|
+
* - Alt+Enter(Mac: Option+Enter):返回 'enter-newline' → 调用方处理为**插入换行**
|
|
14
|
+
* - Ctrl+Enter:返回 'continue' → 调用方处理为**强制发送**
|
|
15
|
+
* - 粘贴期间 + 任何 Enter:返回 'enter-in-paste' → **忽略**(防止误触发发送)
|
|
16
|
+
*
|
|
17
|
+
* 工作原理:
|
|
18
|
+
* 当用户粘贴多行内容时,stdin 可能分多个 chunk 到达。
|
|
19
|
+
* 如果不处理,粘贴内容中的 \n 会被解析为 Enter 键,
|
|
20
|
+
* 导致只有最后一个换行之前的内容被保留,或者直接触发了发送。
|
|
21
|
+
* ============================================================
|
|
22
|
+
*/
|
|
23
|
+
import { useCallback, useRef, useState } from 'react';
|
|
24
|
+
// 粘贴检测阈值:超过此长度视为粘贴输入
|
|
25
|
+
const PASTE_THRESHOLD = 20;
|
|
26
|
+
// 多 Chunk 等待超时:100ms 内收到的内容视为同一批粘贴
|
|
27
|
+
const PASTE_TIMEOUT_MS = 100;
|
|
28
|
+
/**
|
|
29
|
+
* 粘贴检测 Hook(v0.3 最终版)
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```tsx
|
|
33
|
+
* const { handleInput } = usePasteHandler({
|
|
34
|
+
* onPasteComplete: (text) => {
|
|
35
|
+
* buf.insert(text);
|
|
36
|
+
* forceRefresh();
|
|
37
|
+
* },
|
|
38
|
+
* multiline: true,
|
|
39
|
+
* });
|
|
40
|
+
*
|
|
41
|
+
* useInput((input, key) => {
|
|
42
|
+
* const result = handleInput(input, key);
|
|
43
|
+
* if (result === 'paste') return; // 粘贴已处理
|
|
44
|
+
* if (result === 'enter-in-paste') return; // 粘贴中的 Enter 忽略
|
|
45
|
+
* if (result === 'enter-newline') { // Alt+Enter → 换行
|
|
46
|
+
* buf.insert('\n');
|
|
47
|
+
* forceRefresh();
|
|
48
|
+
* return;
|
|
49
|
+
* }
|
|
50
|
+
* // result === 'continue': 正常处理
|
|
51
|
+
* // 包括:Enter 发送、Ctrl+Enter 发送、普通字符输入等
|
|
52
|
+
* });
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export function usePasteHandler({ onPasteComplete, multiline = false, }) {
|
|
56
|
+
const [isPasting, setIsPasting] = useState(false);
|
|
57
|
+
const pasteStateRef = useRef({
|
|
58
|
+
chunks: [],
|
|
59
|
+
timeoutId: null,
|
|
60
|
+
});
|
|
61
|
+
/**
|
|
62
|
+
* 刷新 UI 的引用(避免闭包问题)
|
|
63
|
+
*/
|
|
64
|
+
const onPasteCompleteRef = useRef(onPasteComplete);
|
|
65
|
+
onPasteCompleteRef.current = onPasteComplete;
|
|
66
|
+
/**
|
|
67
|
+
* 刷新 isPasting 状态的引用
|
|
68
|
+
*/
|
|
69
|
+
const setIsPastingRef = useRef(setIsPasting);
|
|
70
|
+
setIsPastingRef.current = setIsPasting;
|
|
71
|
+
/**
|
|
72
|
+
* 将收集的 chunks 合并并触发回调
|
|
73
|
+
*/
|
|
74
|
+
const flushPaste = useCallback(() => {
|
|
75
|
+
const { chunks } = pasteStateRef.current;
|
|
76
|
+
if (chunks.length === 0)
|
|
77
|
+
return;
|
|
78
|
+
// 合并所有 chunks
|
|
79
|
+
const rawText = chunks.join('');
|
|
80
|
+
// 清理文本:
|
|
81
|
+
// 1. \r\n → \n(Windows 行尾)
|
|
82
|
+
// 2. 连续的 \n 压缩为最多两个(保持段落分隔)
|
|
83
|
+
const cleanedText = rawText
|
|
84
|
+
.replace(/\r\n/g, '\n')
|
|
85
|
+
.replace(/\r/g, '\n')
|
|
86
|
+
.replace(/\n{3,}/g, '\n\n');
|
|
87
|
+
// 清除状态
|
|
88
|
+
pasteStateRef.current = { chunks: [], timeoutId: null };
|
|
89
|
+
setIsPastingRef.current(false);
|
|
90
|
+
// 触发回调
|
|
91
|
+
onPasteCompleteRef.current(cleanedText);
|
|
92
|
+
}, []);
|
|
93
|
+
/**
|
|
94
|
+
* 处理输入(v0.3:Enter 始终发送,Alt+Enter 换行,粘贴期间拦截)
|
|
95
|
+
*/
|
|
96
|
+
const handleInput = useCallback((input, key) => {
|
|
97
|
+
// ---- 1. 粘贴期间的任何 Enter 键:全部拦截 ----
|
|
98
|
+
// 这是最关键的防御!防止多行粘贴内容中的 \n 触发发送
|
|
99
|
+
if (isPasting && key.return) {
|
|
100
|
+
return 'enter-in-paste';
|
|
101
|
+
}
|
|
102
|
+
// ---- 2. Alt+Enter(Mac: Option+Enter):插入换行符(用于编辑时手动换行)----
|
|
103
|
+
// multiline=false 时不识别 Alt+Enter,让它走到下面的普通 Enter 分支(提交)
|
|
104
|
+
if (multiline && key.return && key.meta && !key.ctrl) {
|
|
105
|
+
return 'enter-newline';
|
|
106
|
+
}
|
|
107
|
+
// ---- 3. Enter(包括 Ctrl+Enter):始终返回 continue → 让调用方处理为发送 ----
|
|
108
|
+
// 不管是单行还是多行内容,Enter 的默认行为都是发送
|
|
109
|
+
// 只有 Alt+Enter 才是换行
|
|
110
|
+
if (key.return) {
|
|
111
|
+
return 'continue'; // 调用方会执行 onSubmit()
|
|
112
|
+
}
|
|
113
|
+
// ---- 4. 检测是否为粘贴输入 ----
|
|
114
|
+
const isLongInput = input.length > PASTE_THRESHOLD;
|
|
115
|
+
if (isLongInput || isPasting) {
|
|
116
|
+
// 进入或继续粘贴状态
|
|
117
|
+
setIsPasting(true);
|
|
118
|
+
// 清除之前的超时
|
|
119
|
+
if (pasteStateRef.current.timeoutId) {
|
|
120
|
+
clearTimeout(pasteStateRef.current.timeoutId);
|
|
121
|
+
}
|
|
122
|
+
// 收集这个 chunk
|
|
123
|
+
pasteStateRef.current.chunks.push(input);
|
|
124
|
+
// 设置新的超时,超时后 flush
|
|
125
|
+
pasteStateRef.current.timeoutId = setTimeout(() => {
|
|
126
|
+
flushPaste();
|
|
127
|
+
}, PASTE_TIMEOUT_MS);
|
|
128
|
+
return 'paste';
|
|
129
|
+
}
|
|
130
|
+
// ---- 5. 普通输入,继续处理 ----
|
|
131
|
+
return 'continue';
|
|
132
|
+
}, [isPasting, multiline, flushPaste]);
|
|
133
|
+
return {
|
|
134
|
+
handleInput,
|
|
135
|
+
isPasting,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/ui/hooks/useTextBuffer.ts —— 输入框文本缓冲(React 包装)
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 把 textBuffer.ts 里的纯函数包成 React Hook:useState 持有 BufferState,
|
|
6
|
+
* 暴露一组 callback 给 InputBox 调用。callback 用 setState(prev => ...)
|
|
7
|
+
* 形式,避免 useInput handler 闭包看到旧 state 的问题。
|
|
8
|
+
* ============================================================
|
|
9
|
+
*/
|
|
10
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
11
|
+
import { bufClear, bufDeleteAt, bufDeleteBefore, bufDeleteWordBefore, bufInsert, bufKillToLineEnd, bufKillToLineStart, bufMoveBufferEnd, bufMoveBufferStart, bufMoveDown, bufMoveLeft, bufMoveLineEnd, bufMoveLineStart, bufMoveRight, bufMoveUp, bufMoveWordLeft, bufMoveWordRight, bufSetText, EMPTY_BUFFER, layoutBuffer, } from '../textBuffer.js';
|
|
12
|
+
export function useTextBuffer() {
|
|
13
|
+
const [state, setState] = useState(EMPTY_BUFFER);
|
|
14
|
+
const insert = useCallback((s) => setState((p) => bufInsert(p, s)), []);
|
|
15
|
+
const setText = useCallback((s) => setState(bufSetText(s)), []);
|
|
16
|
+
const clear = useCallback(() => setState(bufClear()), []);
|
|
17
|
+
const deleteBefore = useCallback(() => setState(bufDeleteBefore), []);
|
|
18
|
+
const deleteAt = useCallback(() => setState(bufDeleteAt), []);
|
|
19
|
+
const deleteWordBefore = useCallback(() => setState(bufDeleteWordBefore), []);
|
|
20
|
+
const killToLineStart = useCallback(() => setState(bufKillToLineStart), []);
|
|
21
|
+
const killToLineEnd = useCallback(() => setState(bufKillToLineEnd), []);
|
|
22
|
+
const moveLeft = useCallback(() => setState(bufMoveLeft), []);
|
|
23
|
+
const moveRight = useCallback(() => setState(bufMoveRight), []);
|
|
24
|
+
const moveWordLeft = useCallback(() => setState(bufMoveWordLeft), []);
|
|
25
|
+
const moveWordRight = useCallback(() => setState(bufMoveWordRight), []);
|
|
26
|
+
const moveLineStart = useCallback(() => setState(bufMoveLineStart), []);
|
|
27
|
+
const moveLineEnd = useCallback(() => setState(bufMoveLineEnd), []);
|
|
28
|
+
const moveBufferStart = useCallback(() => setState(bufMoveBufferStart), []);
|
|
29
|
+
const moveBufferEnd = useCallback(() => setState(bufMoveBufferEnd), []);
|
|
30
|
+
const moveUp = useCallback(() => setState(bufMoveUp), []);
|
|
31
|
+
const moveDown = useCallback(() => setState(bufMoveDown), []);
|
|
32
|
+
const layout = useMemo(() => layoutBuffer(state), [state]);
|
|
33
|
+
return {
|
|
34
|
+
state,
|
|
35
|
+
layout,
|
|
36
|
+
insert,
|
|
37
|
+
setText,
|
|
38
|
+
clear,
|
|
39
|
+
deleteBefore,
|
|
40
|
+
deleteAt,
|
|
41
|
+
deleteWordBefore,
|
|
42
|
+
killToLineStart,
|
|
43
|
+
killToLineEnd,
|
|
44
|
+
moveLeft,
|
|
45
|
+
moveRight,
|
|
46
|
+
moveWordLeft,
|
|
47
|
+
moveWordRight,
|
|
48
|
+
moveLineStart,
|
|
49
|
+
moveLineEnd,
|
|
50
|
+
moveBufferStart,
|
|
51
|
+
moveBufferEnd,
|
|
52
|
+
moveUp,
|
|
53
|
+
moveDown,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================
|
|
3
|
+
* src/ui/hooks/useTokenUsage.ts —— token 用量计算
|
|
4
|
+
* ------------------------------------------------------------
|
|
5
|
+
* 纯函数 hook:根据当前 history 算 token 数 + 距离压缩阈值的百分比,
|
|
6
|
+
* StatusLine 用它显示底部状态。
|
|
7
|
+
*
|
|
8
|
+
* 历史坑:曾经用 useMemo([messages, provider]) 缓存——但 useChat
|
|
9
|
+
* 里的 historyRef.current 跨渲染始终是同一个数组引用(runQuery
|
|
10
|
+
* 内部用 push() / length=0 直接 mutate),useMemo 缓存键永不失效,
|
|
11
|
+
* 导致 StatusLine 显示的 token 数从启动后就冻结。
|
|
12
|
+
*
|
|
13
|
+
* 修复:直接每渲染重算。countMessagesTokens 在百 KB 历史上 < 1ms,
|
|
14
|
+
* 仅状态栏一处调用,零负担。如果未来历史规模变大需要 memo,
|
|
15
|
+
* 再额外传入版本号 prop 配合依赖即可。
|
|
16
|
+
* ============================================================
|
|
17
|
+
*/
|
|
18
|
+
import { AUTOCOMPACT_BUFFER_TOKENS } from '../../context/compact.js';
|
|
19
|
+
import { countMessagesTokens } from '../../context/tokenCounter.js';
|
|
20
|
+
export function useTokenUsage(messages, provider) {
|
|
21
|
+
const tokens = countMessagesTokens(messages);
|
|
22
|
+
const threshold = Math.max(1000, provider.contextWindow - AUTOCOMPACT_BUFFER_TOKENS);
|
|
23
|
+
return {
|
|
24
|
+
tokens,
|
|
25
|
+
threshold,
|
|
26
|
+
contextWindow: provider.contextWindow,
|
|
27
|
+
percentageOfWindow: tokens / provider.contextWindow,
|
|
28
|
+
toThreshold: threshold - tokens,
|
|
29
|
+
};
|
|
30
|
+
}
|