@code4bug/jarvis-agent 1.3.5 → 1.3.7
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 +50 -4
- package/dist/commands/index.d.ts +2 -0
- package/dist/commands/index.js +1 -0
- package/dist/components/MessageItem.js +2 -1
- package/dist/components/ShortcutHelpMessage.d.ts +4 -0
- package/dist/components/ShortcutHelpMessage.js +10 -0
- package/dist/components/SlashCommandMenu.js +2 -1
- package/dist/components/WelcomeHeader.js +1 -1
- package/dist/config/shortcuts.d.ts +13 -0
- package/dist/config/shortcuts.js +67 -0
- package/dist/core/QueryEngine.d.ts +16 -0
- package/dist/core/QueryEngine.js +85 -25
- package/dist/hooks/useSlashMenu.d.ts +2 -1
- package/dist/hooks/useSlashMenu.js +65 -6
- package/dist/screens/repl.js +60 -0
- package/dist/screens/slashCommands.js +1 -0
- package/dist/types/index.d.ts +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -98,7 +98,21 @@ Jarvis 会按下面顺序加载配置,后者覆盖前者:
|
|
|
98
98
|
|
|
99
99
|
- `system.model` 对应 `models` 里的 key
|
|
100
100
|
- `extra_body` 会直接合并进请求体,方便兼容不同服务商
|
|
101
|
-
|
|
101
|
+
|
|
102
|
+
### 首次启动配置
|
|
103
|
+
|
|
104
|
+
如果未检测到可用的全局配置,Jarvis 启动时会先进入首次配置向导,而不是直接进入主界面。
|
|
105
|
+
|
|
106
|
+
当前配置向导支持两种接入方式:
|
|
107
|
+
|
|
108
|
+
- OpenAI 兼容接口
|
|
109
|
+
- Ollama / 本地模型
|
|
110
|
+
|
|
111
|
+
配置写入位置:
|
|
112
|
+
|
|
113
|
+
- `~/.jarvis/config.json`
|
|
114
|
+
|
|
115
|
+
项目目录下的 `./.jarvis/config.json` 仍可作为本地覆盖配置使用。
|
|
102
116
|
|
|
103
117
|
## 常用命令
|
|
104
118
|
|
|
@@ -116,21 +130,47 @@ jarvis --version
|
|
|
116
130
|
| `/init` | 扫描当前项目并生成 `JARVIS.md` |
|
|
117
131
|
| `/new` | 开启新会话 |
|
|
118
132
|
| `/resume` | 恢复历史会话 |
|
|
119
|
-
| `/
|
|
133
|
+
| `/resume <ID>` | 直接恢复指定历史会话 |
|
|
134
|
+
| `/rewind` | 打开当前会话提问列表并回退到指定位置 |
|
|
135
|
+
| `/agent` | 打开智能体切换列表 |
|
|
136
|
+
| `/agent <名称>` | 切换智能体,重启后生效 |
|
|
120
137
|
| `/permissions` | 查看持久化授权 |
|
|
121
138
|
| `/skills` | 查看当前工具与外部 Skill |
|
|
122
139
|
| `/session_clear` | 清理非当前会话历史 |
|
|
123
140
|
| `/version` | 显示版本 |
|
|
124
141
|
| `/help` | 显示帮助 |
|
|
142
|
+
| `/create_skill <描述>` | 根据描述创建新的 Skill |
|
|
143
|
+
| `/exit` | 退出应用 |
|
|
144
|
+
| `/quit` | 退出应用 |
|
|
145
|
+
| `/bye` | 退出应用 |
|
|
146
|
+
| `/read <路径>` | 以工具方式读取文件 |
|
|
147
|
+
| `/write <路径>` | 以工具方式写入文件 |
|
|
148
|
+
| `/bash <命令>` | 以工具方式执行命令 |
|
|
149
|
+
| `/ls <路径>` | 以工具方式列出目录 |
|
|
150
|
+
| `/search <关键词>` | 以工具方式搜索文件内容 |
|
|
125
151
|
|
|
126
152
|
### 快捷键
|
|
127
153
|
|
|
128
154
|
| 快捷键 | 说明 |
|
|
129
155
|
| --- | --- |
|
|
130
156
|
| `Ctrl + L` | 清屏并开始新会话 |
|
|
131
|
-
| `Ctrl + C` |
|
|
132
|
-
| `
|
|
157
|
+
| `Ctrl + C` | 3 秒内连续按两次退出 |
|
|
158
|
+
| `Ctrl + O` | 切换详情视图 |
|
|
159
|
+
| `Esc` | 中断当前任务;空闲时双击清空输入 |
|
|
133
160
|
| `Alt/Option + Enter` | 输入换行 |
|
|
161
|
+
| `Tab` | 输入为空时填入提示词;在斜杠菜单中补全当前项 |
|
|
162
|
+
| `? + Enter` | 输出快捷键帮助信息 |
|
|
163
|
+
|
|
164
|
+
### 输入与交互
|
|
165
|
+
|
|
166
|
+
- 输入 `/` 可打开斜杠菜单
|
|
167
|
+
- 斜杠菜单中可使用 `↑ / ↓` 切换命令
|
|
168
|
+
- 在斜杠菜单中按 `Tab` 可补全当前命令
|
|
169
|
+
- 在斜杠菜单中按 `Enter` 可提交当前命令
|
|
170
|
+
- 输入 `/rewind` 后可列出当前会话的提问列表,按时间从上到下升序排列
|
|
171
|
+
- 在 `/rewind` 列表中选择某条提问后,会把该提问回填到输入框,并丢弃该位置之后的上下文
|
|
172
|
+
- 对于多行输入,`↑ / ↓` 用于在输入框内移动光标
|
|
173
|
+
- 对于单行输入,`↑ / ↓` 用于切换历史输入
|
|
134
174
|
|
|
135
175
|
## 内置工具
|
|
136
176
|
|
|
@@ -209,6 +249,12 @@ Agent 通过 Markdown 文件定义元信息与系统提示词,切换结果会
|
|
|
209
249
|
|
|
210
250
|
每次会话会保存消息、摘要、更新时间和累计 Token。
|
|
211
251
|
|
|
252
|
+
支持能力:
|
|
253
|
+
|
|
254
|
+
- `/resume` 从列表恢复历史会话
|
|
255
|
+
- `/resume <ID>` 直接恢复指定会话
|
|
256
|
+
- `/session_clear` 清理除当前会话外的历史记录
|
|
257
|
+
|
|
212
258
|
### 长期记忆
|
|
213
259
|
|
|
214
260
|
- 记忆文件:`~/.jarvis/MEMORY.md`
|
package/dist/commands/index.d.ts
CHANGED
package/dist/commands/index.js
CHANGED
|
@@ -12,6 +12,7 @@ const builtinCommands = [
|
|
|
12
12
|
{ name: 'quit', description: '退出应用程序', category: 'builtin', submitMode: 'action' },
|
|
13
13
|
{ name: 'bye', description: '退出应用程序', category: 'builtin', submitMode: 'action' },
|
|
14
14
|
{ name: 'resume', description: '恢复历史会话上下文', category: 'builtin', submitMode: 'list' },
|
|
15
|
+
{ name: 'rewind', description: '回退当前会话到指定提问位置', category: 'builtin', submitMode: 'list' },
|
|
15
16
|
{ name: 'help', description: '显示帮助信息', category: 'builtin', submitMode: 'action' },
|
|
16
17
|
{ name: 'agent', description: '切换智能体', category: 'builtin', submitMode: 'list' },
|
|
17
18
|
{ name: 'permissions', description: '查看所有持久化授权列表', category: 'builtin', submitMode: 'action' },
|
|
@@ -3,6 +3,7 @@ import React from 'react';
|
|
|
3
3
|
import { Box, Text } from 'ink';
|
|
4
4
|
import Spinner from 'ink-spinner';
|
|
5
5
|
import MarkdownText from './MarkdownText.js';
|
|
6
|
+
import ShortcutHelpMessage from './ShortcutHelpMessage.js';
|
|
6
7
|
// 状态圆点 icon,根据消息类型 + 状态决定颜色
|
|
7
8
|
// reasoning 完成 → 白色 / tool_exec 成功 → 绿色 / error → 红色 / aborted → 黄色 / pending → 黄色
|
|
8
9
|
function statusDot(status, type) {
|
|
@@ -80,7 +81,7 @@ function MessageItem({ msg, showDetails = false }) {
|
|
|
80
81
|
_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsxs(Text, { color: dotColor, children: [dot, " "] }), _jsxs(Text, { color: "blue", dimColor: true, children: [msg.subAgentId, " \u203A "] })] }), _jsx(Box, { marginLeft: 2, flexDirection: "column", children: _jsx(MarkdownText, { text: msg.content }) })] })) : (_jsxs(Box, { flexDirection: "row", alignItems: "flex-start", children: [_jsxs(Text, { color: dotColor, children: [dot, " "] }), _jsx(Box, { flexDirection: "column", flexShrink: 1, children: _jsx(MarkdownText, { text: msg.content }) })] })), _jsx(MessageStats, { msg: msg, show: showDetails })] }));
|
|
81
82
|
}
|
|
82
83
|
if (msg.type === 'system' && msg.content) {
|
|
83
|
-
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { flexDirection: "row", alignItems: "flex-start", children: [_jsxs(Text, { color: dotColor, children: [dot, " "] }), _jsx(Box, { flexDirection: "column", flexShrink: 1, children: _jsx(MarkdownText, { text: msg.content, color: "gray" }) })] }), _jsx(MessageStats, { msg: msg, show: showDetails })] }));
|
|
84
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { flexDirection: "row", alignItems: "flex-start", children: [_jsxs(Text, { color: dotColor, children: [dot, " "] }), _jsx(Box, { flexDirection: "column", flexShrink: 1, children: msg.systemKind === 'shortcut_help' ? (_jsx(ShortcutHelpMessage, {})) : (_jsx(MarkdownText, { text: msg.content, color: "gray" })) })] }), _jsx(MessageStats, { msg: msg, show: showDetails })] }));
|
|
84
85
|
}
|
|
85
86
|
if (msg.status !== 'pending' && msg.content) {
|
|
86
87
|
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { flexDirection: "row", alignItems: "flex-start", children: [_jsxs(Text, { color: dotColor, children: [dot, " "] }), _jsx(Box, { flexDirection: "column", flexShrink: 1, children: _jsx(MarkdownText, { text: msg.content, color: "gray" }) })] }), _jsx(MessageStats, { msg: msg, show: showDetails })] }));
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
|
+
import { getShortcutPlatformNote, getShortcutSections } from '../config/shortcuts.js';
|
|
5
|
+
function ShortcutHelpMessage() {
|
|
6
|
+
const sections = getShortcutSections();
|
|
7
|
+
const note = getShortcutPlatformNote();
|
|
8
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "cyan", bold: true, children: "\u5FEB\u6377\u952E\u5E2E\u52A9" }), _jsx(Text, { color: "gray", children: note }), sections.map((section) => (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", bold: true, children: section.title }), section.items.map((item, index) => (_jsxs(Box, { children: [_jsx(Box, { width: 24, flexShrink: 0, children: _jsx(Text, { color: "green", bold: true, children: item.key }) }), _jsx(Text, { color: "white", children: item.description })] }, `${section.title}-${item.key}-${index}`)))] }, section.title)))] }));
|
|
9
|
+
}
|
|
10
|
+
export default React.memo(ShortcutHelpMessage);
|
|
@@ -37,7 +37,8 @@ function SlashCommandMenu({ commands, selectedIndex, maxVisible = 6, }) {
|
|
|
37
37
|
const isSelected = realIndex === selectedIndex;
|
|
38
38
|
const catColor = categoryColor[cmd.category] ?? 'gray';
|
|
39
39
|
const catText = categoryLabel[cmd.category] ?? cmd.category;
|
|
40
|
-
|
|
40
|
+
const displayName = cmd.displayName ?? cmd.name;
|
|
41
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { color: isSelected ? 'cyan' : 'white', children: [' ', "/", displayName, ' '] }), _jsxs(Text, { color: isSelected ? 'cyan' : 'gray', children: ["- ", cmd.description, ' '] }), _jsxs(Text, { color: catColor, dimColor: true, children: ["[", catText, "]"] })] }, `${cmd.name}-${realIndex}`));
|
|
41
42
|
}), start + visible < total && (_jsxs(Text, { color: "gray", dimColor: true, children: [" \u2193 \u8FD8\u6709 ", total - start - visible, " \u9879"] }))] }));
|
|
42
43
|
}
|
|
43
44
|
export default React.memo(SlashCommandMenu);
|
|
@@ -22,6 +22,6 @@ function WelcomeHeader({ width }) {
|
|
|
22
22
|
const showLogo = width >= 52;
|
|
23
23
|
const appName = getAppName();
|
|
24
24
|
const modelName = getModelName();
|
|
25
|
-
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, width: width, children: [showLogo && (_jsx(Box, { flexDirection: "column", children: LOGO_LINES.map((line, i) => (_jsx(Text, { color: LOGO_COLORS[i], children: line }, i))) })), _jsxs(Box, { marginTop: 0, children: [_jsx(Text, { color: "gray" }), _jsx(Text, { color: "white", bold: true, children: "Your AI-Powered Dev Companion" }), _jsx(Text, { color: "gray" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: '─'.repeat(Math.min(width - 4, 48)) }) }), _jsxs(Box, { marginTop: 0, children: [_jsx(Text, { color: "gray", children: "model " }), _jsx(Text, { color: "cyan", children: modelName }), _jsxs(Text, { color: "gray", children: [" ", appName, " "] }), _jsx(Text, { color: "magenta", children: APP_VERSION })] }), _jsx(Box, { children: _jsx(Text, { color: "gray", children: truncatePath(process.cwd(), maxPath) }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: '─'.repeat(Math.min(width - 4, 48)) }) }), _jsxs(Box, { children: [_jsx(Text, { color: "gray", children: "/" }), _jsx(Text, { color: "cyan", children: "init" }), _jsx(Text, { color: "gray", children: " \u521D\u59CB\u5316 " }), _jsx(Text, { color: "gray", children: "/" }), _jsx(Text, { color: "cyan", children: "help" }), _jsx(Text, { color: "gray", children: " \u5E2E\u52A9 " }), _jsx(Text, { color: "gray", children: "/" }), _jsx(Text, { color: "cyan", children: "new" }), _jsx(Text, { color: "gray", children: " \u65B0\u4F1A\u8BDD " }), _jsx(Text, { color: "gray", children: "/" }), _jsx(Text, { color: "cyan", children: "agent" }), _jsx(Text, { color: "gray", children: " \u5207\u6362" })] }), _jsx(Text, { children: ' ' })] }));
|
|
25
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, width: width, children: [showLogo && (_jsx(Box, { flexDirection: "column", children: LOGO_LINES.map((line, i) => (_jsx(Text, { color: LOGO_COLORS[i], children: line }, i))) })), _jsxs(Box, { marginTop: 0, children: [_jsx(Text, { color: "gray" }), _jsx(Text, { color: "white", bold: true, children: "Your AI-Powered Dev Companion" }), _jsx(Text, { color: "gray" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: '─'.repeat(Math.min(width - 4, 48)) }) }), _jsxs(Box, { marginTop: 0, children: [_jsx(Text, { color: "gray", children: "model " }), _jsx(Text, { color: "cyan", children: modelName }), _jsxs(Text, { color: "gray", children: [" ", appName, " "] }), _jsx(Text, { color: "magenta", children: APP_VERSION })] }), _jsx(Box, { children: _jsx(Text, { color: "gray", children: truncatePath(process.cwd(), maxPath) }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: '─'.repeat(Math.min(width - 4, 48)) }) }), _jsxs(Box, { children: [_jsx(Text, { color: "gray", children: "/" }), _jsx(Text, { color: "cyan", children: "init" }), _jsx(Text, { color: "gray", children: " \u521D\u59CB\u5316 " }), _jsx(Text, { color: "gray", children: "/" }), _jsx(Text, { color: "cyan", children: "help" }), _jsx(Text, { color: "gray", children: " \u5E2E\u52A9 " }), _jsx(Text, { color: "gray", children: "/" }), _jsx(Text, { color: "cyan", children: "new" }), _jsx(Text, { color: "gray", children: " \u65B0\u4F1A\u8BDD " }), _jsx(Text, { color: "gray", children: "/" }), _jsx(Text, { color: "cyan", children: "agent" }), _jsx(Text, { color: "gray", children: " \u5207\u6362 " }), _jsx(Text, { color: "yellow", bold: true, children: "?" }), _jsx(Text, { color: "gray", children: " \u5FEB\u6377\u952E\u63D0\u793A" })] }), _jsx(Text, { children: ' ' })] }));
|
|
26
26
|
}
|
|
27
27
|
export default React.memo(WelcomeHeader);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface ShortcutItem {
|
|
2
|
+
key: string;
|
|
3
|
+
description: string;
|
|
4
|
+
}
|
|
5
|
+
export interface ShortcutSection {
|
|
6
|
+
title: string;
|
|
7
|
+
items: ShortcutItem[];
|
|
8
|
+
}
|
|
9
|
+
export declare function getShortcutPlatformLabel(): string;
|
|
10
|
+
export declare function getShortcutAltKeyLabel(): string;
|
|
11
|
+
export declare function getShortcutPlatformNote(): string;
|
|
12
|
+
export declare function getShortcutSections(): ShortcutSection[];
|
|
13
|
+
export declare function buildShortcutHelpText(): string;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const isMac = process.platform === 'darwin';
|
|
2
|
+
const isWindows = process.platform === 'win32';
|
|
3
|
+
export function getShortcutPlatformLabel() {
|
|
4
|
+
if (isMac)
|
|
5
|
+
return 'macOS';
|
|
6
|
+
if (isWindows)
|
|
7
|
+
return 'Windows';
|
|
8
|
+
return 'Linux';
|
|
9
|
+
}
|
|
10
|
+
export function getShortcutAltKeyLabel() {
|
|
11
|
+
return isMac ? 'Option' : 'Alt';
|
|
12
|
+
}
|
|
13
|
+
export function getShortcutPlatformNote() {
|
|
14
|
+
if (isMac)
|
|
15
|
+
return '当前平台:macOS(终端内快捷键使用 Ctrl / Option,通常不是 Cmd)';
|
|
16
|
+
if (isWindows)
|
|
17
|
+
return '当前平台:Windows(快捷键使用 Ctrl / Alt)';
|
|
18
|
+
return '当前平台:Linux(快捷键使用 Ctrl / Alt)';
|
|
19
|
+
}
|
|
20
|
+
export function getShortcutSections() {
|
|
21
|
+
const altKey = getShortcutAltKeyLabel();
|
|
22
|
+
const platform = getShortcutPlatformLabel();
|
|
23
|
+
return [
|
|
24
|
+
{
|
|
25
|
+
title: '通用快捷键',
|
|
26
|
+
items: [
|
|
27
|
+
{ key: '? + Enter', description: '查看全部快捷键说明' },
|
|
28
|
+
{ key: 'Ctrl + C', description: '退出 Jarvis(需连续按两次)' },
|
|
29
|
+
{ key: 'Ctrl + L', description: '清屏并开始新会话' },
|
|
30
|
+
{ key: 'Ctrl + O', description: '切换详情视图' },
|
|
31
|
+
{ key: 'Esc', description: '中断当前任务;空闲时双击清空输入' },
|
|
32
|
+
{ key: `${altKey} + Enter`, description: '插入换行' },
|
|
33
|
+
{ key: 'Tab', description: '输入为空时填入提示词;斜杠菜单中补全选中项' },
|
|
34
|
+
{ key: 'Enter', description: '发送消息;斜杠菜单中提交当前选中命令' },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
title: `输入编辑(${platform})`,
|
|
39
|
+
items: [
|
|
40
|
+
{ key: '← / →', description: '左右移动光标' },
|
|
41
|
+
{ key: '↑ / ↓', description: '多行输入时上下移动光标' },
|
|
42
|
+
{ key: '↑ / ↓', description: '单行输入时切换历史记录' },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
title: '斜杠菜单',
|
|
47
|
+
items: [
|
|
48
|
+
{ key: '/', description: '打开命令菜单' },
|
|
49
|
+
{ key: '↑ / ↓', description: '切换命令' },
|
|
50
|
+
{ key: 'Tab / Enter', description: '补全或提交当前命令' },
|
|
51
|
+
{ key: 'Esc', description: '关闭命令菜单' },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
export function buildShortcutHelpText() {
|
|
57
|
+
return [
|
|
58
|
+
'快捷键帮助',
|
|
59
|
+
getShortcutPlatformNote(),
|
|
60
|
+
'',
|
|
61
|
+
...getShortcutSections().flatMap((section) => [
|
|
62
|
+
`${section.title}:`,
|
|
63
|
+
...section.items.map((item) => ` ${item.key} ${item.description}`),
|
|
64
|
+
'',
|
|
65
|
+
]),
|
|
66
|
+
].join('\n').trim();
|
|
67
|
+
}
|
|
@@ -30,6 +30,9 @@ export declare class QueryEngine {
|
|
|
30
30
|
/** 注册持久 UI 回调,供后台 spawn_agent 子 Agent 推送消息 */
|
|
31
31
|
registerUIBus(onMessage: (msg: Message) => void, onUpdateMessage: (id: string, updates: Partial<Message>) => void): void;
|
|
32
32
|
private createSession;
|
|
33
|
+
private rebuildTranscriptFromMessages;
|
|
34
|
+
private recomputeSessionSummary;
|
|
35
|
+
private recomputeSessionTokens;
|
|
33
36
|
private ensureSessionDir;
|
|
34
37
|
private createService;
|
|
35
38
|
/** 处理用户输入(在独立 Worker 线程中执行) */
|
|
@@ -62,6 +65,19 @@ export declare class QueryEngine {
|
|
|
62
65
|
messages: Message[];
|
|
63
66
|
} | null;
|
|
64
67
|
getSession(): Session;
|
|
68
|
+
getCurrentSessionUserTurns(): Array<{
|
|
69
|
+
messageId: string;
|
|
70
|
+
turnIndex: number;
|
|
71
|
+
input: string;
|
|
72
|
+
answerPreview: string;
|
|
73
|
+
timestamp: number;
|
|
74
|
+
}>;
|
|
75
|
+
rewindToUserMessage(messageId: string): {
|
|
76
|
+
session: Session;
|
|
77
|
+
messages: Message[];
|
|
78
|
+
input: string;
|
|
79
|
+
turnIndex: number;
|
|
80
|
+
} | null;
|
|
65
81
|
/**
|
|
66
82
|
* 清理非当前会话的所有历史会话文件
|
|
67
83
|
* @returns 删除的会话数量
|
package/dist/core/QueryEngine.js
CHANGED
|
@@ -51,6 +51,39 @@ export class QueryEngine {
|
|
|
51
51
|
totalCost: 0,
|
|
52
52
|
};
|
|
53
53
|
}
|
|
54
|
+
rebuildTranscriptFromMessages(messages) {
|
|
55
|
+
const transcript = [];
|
|
56
|
+
for (const msg of messages) {
|
|
57
|
+
if (msg.type === 'user') {
|
|
58
|
+
transcript.push({ role: 'user', content: msg.content });
|
|
59
|
+
}
|
|
60
|
+
else if (msg.type === 'reasoning' && msg.status === 'success' && msg.content) {
|
|
61
|
+
transcript.push({
|
|
62
|
+
role: 'assistant',
|
|
63
|
+
content: [{ type: 'text', text: msg.content }],
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
else if (msg.type === 'tool_exec' && msg.toolName && msg.toolResult !== undefined) {
|
|
67
|
+
transcript.push({
|
|
68
|
+
role: 'assistant',
|
|
69
|
+
content: [{ type: 'tool_use', id: msg.id, name: msg.toolName, input: msg.toolArgs ?? {} }],
|
|
70
|
+
});
|
|
71
|
+
transcript.push({
|
|
72
|
+
role: 'tool_result',
|
|
73
|
+
content: msg.toolResult,
|
|
74
|
+
toolUseId: msg.id,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return transcript;
|
|
79
|
+
}
|
|
80
|
+
recomputeSessionSummary(messages) {
|
|
81
|
+
const firstUser = messages.find((m) => m.type === 'user');
|
|
82
|
+
return firstUser ? firstUser.content.slice(0, 80).replace(/\n/g, ' ') : undefined;
|
|
83
|
+
}
|
|
84
|
+
recomputeSessionTokens(messages) {
|
|
85
|
+
return messages.reduce((sum, msg) => sum + (msg.tokenCount ?? 0), 0);
|
|
86
|
+
}
|
|
54
87
|
ensureSessionDir() {
|
|
55
88
|
if (!fs.existsSync(SESSIONS_DIR)) {
|
|
56
89
|
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
|
@@ -321,31 +354,7 @@ export class QueryEngine {
|
|
|
321
354
|
const cleanedMessages = loaded.messages.filter((m) => !(m.type === 'thinking' && m.status === 'pending'));
|
|
322
355
|
this.session.messages = cleanedMessages;
|
|
323
356
|
// 从历史消息重建 transcript
|
|
324
|
-
this.transcript =
|
|
325
|
-
for (const msg of loaded.messages) {
|
|
326
|
-
if (msg.type === 'user') {
|
|
327
|
-
this.transcript.push({ role: 'user', content: msg.content });
|
|
328
|
-
}
|
|
329
|
-
else if (msg.type === 'reasoning' && msg.status === 'success' && msg.content) {
|
|
330
|
-
// assistant 消息的 content 必须是 ContentBlock[],与 query.ts 中的构建方式一致
|
|
331
|
-
this.transcript.push({
|
|
332
|
-
role: 'assistant',
|
|
333
|
-
content: [{ type: 'text', text: msg.content }],
|
|
334
|
-
});
|
|
335
|
-
}
|
|
336
|
-
else if (msg.type === 'tool_exec' && msg.toolName && msg.toolResult !== undefined) {
|
|
337
|
-
// 工具调用:assistant tool_use + tool_result
|
|
338
|
-
this.transcript.push({
|
|
339
|
-
role: 'assistant',
|
|
340
|
-
content: [{ type: 'tool_use', id: msg.id, name: msg.toolName, input: msg.toolArgs ?? {} }],
|
|
341
|
-
});
|
|
342
|
-
this.transcript.push({
|
|
343
|
-
role: 'tool_result',
|
|
344
|
-
content: msg.toolResult,
|
|
345
|
-
toolUseId: msg.id,
|
|
346
|
-
});
|
|
347
|
-
}
|
|
348
|
-
}
|
|
357
|
+
this.transcript = this.rebuildTranscriptFromMessages(cleanedMessages);
|
|
349
358
|
logInfo('session.loaded', {
|
|
350
359
|
sessionId,
|
|
351
360
|
messageCount: cleanedMessages.length,
|
|
@@ -362,6 +371,57 @@ export class QueryEngine {
|
|
|
362
371
|
getSession() {
|
|
363
372
|
return this.session;
|
|
364
373
|
}
|
|
374
|
+
getCurrentSessionUserTurns() {
|
|
375
|
+
const cleanedMessages = this.session.messages.filter((m) => !(m.type === 'thinking' && m.status === 'pending'));
|
|
376
|
+
const userMessages = cleanedMessages.filter((m) => m.type === 'user');
|
|
377
|
+
return userMessages.map((msg, index) => {
|
|
378
|
+
const currentIndex = cleanedMessages.findIndex((item) => item.id === msg.id);
|
|
379
|
+
const nextUserIndex = cleanedMessages.findIndex((item, i) => i > currentIndex && item.type === 'user');
|
|
380
|
+
const endIndex = nextUserIndex >= 0 ? nextUserIndex : cleanedMessages.length;
|
|
381
|
+
const answerPreview = cleanedMessages
|
|
382
|
+
.slice(currentIndex + 1, endIndex)
|
|
383
|
+
.filter((item) => item.type === 'reasoning' && item.content)
|
|
384
|
+
.map((item) => item.content.trim())
|
|
385
|
+
.find(Boolean) ?? '';
|
|
386
|
+
return {
|
|
387
|
+
messageId: msg.id,
|
|
388
|
+
turnIndex: index + 1,
|
|
389
|
+
input: msg.content,
|
|
390
|
+
answerPreview,
|
|
391
|
+
timestamp: msg.timestamp,
|
|
392
|
+
};
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
rewindToUserMessage(messageId) {
|
|
396
|
+
const cleanedMessages = this.session.messages.filter((m) => !(m.type === 'thinking' && m.status === 'pending'));
|
|
397
|
+
const targetIndex = cleanedMessages.findIndex((m) => m.id === messageId && m.type === 'user');
|
|
398
|
+
if (targetIndex < 0)
|
|
399
|
+
return null;
|
|
400
|
+
const userTurns = cleanedMessages.filter((m) => m.type === 'user');
|
|
401
|
+
const turnIndex = userTurns.findIndex((m) => m.id === messageId) + 1;
|
|
402
|
+
const targetMessage = cleanedMessages[targetIndex];
|
|
403
|
+
const keptMessages = cleanedMessages.slice(0, targetIndex);
|
|
404
|
+
this.session.messages = keptMessages;
|
|
405
|
+
this.session.summary = this.recomputeSessionSummary(keptMessages);
|
|
406
|
+
this.session.totalTokens = this.recomputeSessionTokens(keptMessages);
|
|
407
|
+
this.session.updatedAt = Date.now();
|
|
408
|
+
this.transcript = this.rebuildTranscriptFromMessages(keptMessages);
|
|
409
|
+
this.touchActivity();
|
|
410
|
+
this.saveSession();
|
|
411
|
+
logInfo('session.rewind', {
|
|
412
|
+
sessionId: this.session.id,
|
|
413
|
+
targetMessageId: messageId,
|
|
414
|
+
turnIndex,
|
|
415
|
+
keptMessageCount: keptMessages.length,
|
|
416
|
+
transcriptLength: this.transcript.length,
|
|
417
|
+
});
|
|
418
|
+
return {
|
|
419
|
+
session: this.session,
|
|
420
|
+
messages: keptMessages,
|
|
421
|
+
input: targetMessage.content,
|
|
422
|
+
turnIndex,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
365
425
|
/**
|
|
366
426
|
* 清理非当前会话的所有历史会话文件
|
|
367
427
|
* @returns 删除的会话数量
|
|
@@ -27,12 +27,13 @@ export declare function useSlashMenu(opts: UseSlashMenuOptions): {
|
|
|
27
27
|
slashMenuIndex: number;
|
|
28
28
|
agentMenuMode: boolean;
|
|
29
29
|
resumeMenuMode: boolean;
|
|
30
|
+
rewindMenuMode: boolean;
|
|
30
31
|
handleSlashMenuUp: () => void;
|
|
31
32
|
handleSlashMenuDown: () => void;
|
|
32
33
|
handleSlashMenuClose: () => void;
|
|
33
34
|
getSelectedCommand: () => SlashCommand | null;
|
|
34
35
|
autocompleteSlashMenuSelection: (currentInput: string) => string;
|
|
35
|
-
openListCommand: (commandName: "agent" | "resume") => "/agent " | "/resume ";
|
|
36
|
+
openListCommand: (commandName: "agent" | "resume" | "rewind") => "/agent " | "/rewind " | "/resume ";
|
|
36
37
|
updateSlashMenu: (val: string) => void;
|
|
37
38
|
setSlashMenuVisible: import("react").Dispatch<import("react").SetStateAction<boolean>>;
|
|
38
39
|
resumeSession: (sessionId: string) => void;
|
|
@@ -13,6 +13,7 @@ export function useSlashMenu(opts) {
|
|
|
13
13
|
const [slashMenuIndex, setSlashMenuIndex] = useState(0);
|
|
14
14
|
const [agentMenuMode, setAgentMenuMode] = useState(false);
|
|
15
15
|
const [resumeMenuMode, setResumeMenuMode] = useState(false);
|
|
16
|
+
const [rewindMenuMode, setRewindMenuMode] = useState(false);
|
|
16
17
|
// 上移
|
|
17
18
|
const handleSlashMenuUp = useCallback(() => {
|
|
18
19
|
setSlashMenuIndex((prev) => (prev > 0 ? prev - 1 : slashMenuItems.length - 1));
|
|
@@ -23,9 +24,10 @@ export function useSlashMenu(opts) {
|
|
|
23
24
|
}, [slashMenuItems.length]);
|
|
24
25
|
// 关闭
|
|
25
26
|
const handleSlashMenuClose = useCallback(() => {
|
|
26
|
-
if (agentMenuMode || resumeMenuMode) {
|
|
27
|
+
if (agentMenuMode || resumeMenuMode || rewindMenuMode) {
|
|
27
28
|
setAgentMenuMode(false);
|
|
28
29
|
setResumeMenuMode(false);
|
|
30
|
+
setRewindMenuMode(false);
|
|
29
31
|
setInput('/');
|
|
30
32
|
const matched = filterCommands('');
|
|
31
33
|
setSlashMenuItems(matched);
|
|
@@ -35,7 +37,7 @@ export function useSlashMenu(opts) {
|
|
|
35
37
|
else {
|
|
36
38
|
setSlashMenuVisible(false);
|
|
37
39
|
}
|
|
38
|
-
}, [agentMenuMode, resumeMenuMode, setInput]);
|
|
40
|
+
}, [agentMenuMode, resumeMenuMode, rewindMenuMode, setInput]);
|
|
39
41
|
// 恢复会话的通用逻辑
|
|
40
42
|
const resumeSession = useCallback((sessionId) => {
|
|
41
43
|
if (!engineRef.current)
|
|
@@ -76,6 +78,25 @@ export function useSlashMenu(opts) {
|
|
|
76
78
|
return null;
|
|
77
79
|
return slashMenuItems[slashMenuIndex] ?? null;
|
|
78
80
|
}, [slashMenuItems, slashMenuIndex]);
|
|
81
|
+
const buildRewindItems = useCallback(() => {
|
|
82
|
+
if (!engineRef.current)
|
|
83
|
+
return [];
|
|
84
|
+
return engineRef.current.getCurrentSessionUserTurns()
|
|
85
|
+
.slice()
|
|
86
|
+
.map((turn) => {
|
|
87
|
+
const date = new Date(turn.timestamp);
|
|
88
|
+
const dateStr = `${date.getMonth() + 1}/${date.getDate()} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
|
|
89
|
+
const question = turn.input.replace(/\s+/g, ' ').slice(0, 32);
|
|
90
|
+
const answer = turn.answerPreview.replace(/\s+/g, ' ').slice(0, 24);
|
|
91
|
+
return {
|
|
92
|
+
name: turn.messageId,
|
|
93
|
+
displayName: `rewind-${turn.turnIndex}`,
|
|
94
|
+
description: `[Q${turn.turnIndex} ${dateStr}] ${question}${answer ? ` | A: ${answer}` : ''}`,
|
|
95
|
+
category: 'builtin',
|
|
96
|
+
submitMode: 'action',
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
}, [engineRef]);
|
|
79
100
|
const openListCommand = useCallback((commandName) => {
|
|
80
101
|
if (commandName === 'agent') {
|
|
81
102
|
setInput('/agent ');
|
|
@@ -84,9 +105,21 @@ export function useSlashMenu(opts) {
|
|
|
84
105
|
setSlashMenuIndex(0);
|
|
85
106
|
setAgentMenuMode(true);
|
|
86
107
|
setResumeMenuMode(false);
|
|
108
|
+
setRewindMenuMode(false);
|
|
87
109
|
setSlashMenuVisible(matched.length > 0);
|
|
88
110
|
return '/agent ';
|
|
89
111
|
}
|
|
112
|
+
if (commandName === 'rewind') {
|
|
113
|
+
const items = buildRewindItems();
|
|
114
|
+
setInput('/rewind ');
|
|
115
|
+
setSlashMenuItems(items);
|
|
116
|
+
setSlashMenuIndex(0);
|
|
117
|
+
setSlashMenuVisible(items.length > 0);
|
|
118
|
+
setRewindMenuMode(true);
|
|
119
|
+
setResumeMenuMode(false);
|
|
120
|
+
setAgentMenuMode(false);
|
|
121
|
+
return '/rewind ';
|
|
122
|
+
}
|
|
90
123
|
const sessions = QueryEngine.listSessions().slice(0, 20);
|
|
91
124
|
const items = sessions.map((s) => {
|
|
92
125
|
const date = new Date(s.updatedAt);
|
|
@@ -104,8 +137,9 @@ export function useSlashMenu(opts) {
|
|
|
104
137
|
setSlashMenuVisible(items.length > 0);
|
|
105
138
|
setResumeMenuMode(true);
|
|
106
139
|
setAgentMenuMode(false);
|
|
140
|
+
setRewindMenuMode(false);
|
|
107
141
|
return '/resume ';
|
|
108
|
-
}, [setInput]);
|
|
142
|
+
}, [setInput, buildRewindItems]);
|
|
109
143
|
const autocompleteSlashMenuSelection = useCallback((currentInput) => {
|
|
110
144
|
const cmd = getSelectedCommand();
|
|
111
145
|
if (!cmd)
|
|
@@ -124,17 +158,23 @@ export function useSlashMenu(opts) {
|
|
|
124
158
|
setSlashMenuVisible(false);
|
|
125
159
|
return nextInput;
|
|
126
160
|
}
|
|
161
|
+
if (rewindMenuMode) {
|
|
162
|
+
const nextInput = `/rewind ${cmd.name}`;
|
|
163
|
+
setInput(nextInput);
|
|
164
|
+
setSlashMenuVisible(false);
|
|
165
|
+
return nextInput;
|
|
166
|
+
}
|
|
127
167
|
const match = currentInput.match(/^\/\S*/);
|
|
128
168
|
const suffix = match ? currentInput.slice(match[0].length) : '';
|
|
129
169
|
const needsTrailingSpace = !suffix && (cmd.submitMode === 'context' || cmd.submitMode === 'list');
|
|
130
170
|
const nextInput = `/${cmd.name}${suffix}${needsTrailingSpace ? ' ' : ''}`;
|
|
131
171
|
setInput(nextInput);
|
|
132
|
-
if (cmd.submitMode === 'list' && (cmd.name === 'agent' || cmd.name === 'resume') && !suffix.trim()) {
|
|
172
|
+
if (cmd.submitMode === 'list' && (cmd.name === 'agent' || cmd.name === 'resume' || cmd.name === 'rewind') && !suffix.trim()) {
|
|
133
173
|
return openListCommand(cmd.name);
|
|
134
174
|
}
|
|
135
175
|
updateSlashMenu(nextInput);
|
|
136
176
|
return nextInput;
|
|
137
|
-
}, [agentMenuMode, resumeMenuMode, getSelectedCommand, setInput, openListCommand]);
|
|
177
|
+
}, [agentMenuMode, resumeMenuMode, rewindMenuMode, getSelectedCommand, setInput, openListCommand]);
|
|
138
178
|
// 输入变化时更新菜单
|
|
139
179
|
const updateSlashMenu = useCallback((val) => {
|
|
140
180
|
if (val.startsWith('/') && !val.includes('\n')) {
|
|
@@ -172,6 +212,22 @@ export function useSlashMenu(opts) {
|
|
|
172
212
|
setSlashMenuVisible(matched.length > 0);
|
|
173
213
|
setResumeMenuMode(true);
|
|
174
214
|
setAgentMenuMode(false);
|
|
215
|
+
setRewindMenuMode(false);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
// /rewind 二级菜单
|
|
219
|
+
if (/^rewind\s/i.test(query)) {
|
|
220
|
+
const subQuery = query.replace(/^rewind\s*/i, '').toLowerCase();
|
|
221
|
+
const items = buildRewindItems();
|
|
222
|
+
const matched = subQuery
|
|
223
|
+
? items.filter((i) => i.name.includes(subQuery) || (i.displayName ?? '').toLowerCase().includes(subQuery) || i.description.toLowerCase().includes(subQuery))
|
|
224
|
+
: items;
|
|
225
|
+
setSlashMenuItems(matched);
|
|
226
|
+
setSlashMenuIndex(0);
|
|
227
|
+
setSlashMenuVisible(matched.length > 0);
|
|
228
|
+
setRewindMenuMode(true);
|
|
229
|
+
setResumeMenuMode(false);
|
|
230
|
+
setAgentMenuMode(false);
|
|
175
231
|
return;
|
|
176
232
|
}
|
|
177
233
|
// 一级菜单
|
|
@@ -181,19 +237,22 @@ export function useSlashMenu(opts) {
|
|
|
181
237
|
setSlashMenuVisible(matched.length > 0);
|
|
182
238
|
setAgentMenuMode(false);
|
|
183
239
|
setResumeMenuMode(false);
|
|
240
|
+
setRewindMenuMode(false);
|
|
184
241
|
}
|
|
185
242
|
else {
|
|
186
243
|
setSlashMenuVisible(false);
|
|
187
244
|
setAgentMenuMode(false);
|
|
188
245
|
setResumeMenuMode(false);
|
|
246
|
+
setRewindMenuMode(false);
|
|
189
247
|
}
|
|
190
|
-
}, []);
|
|
248
|
+
}, [buildRewindItems]);
|
|
191
249
|
return {
|
|
192
250
|
slashMenuVisible,
|
|
193
251
|
slashMenuItems,
|
|
194
252
|
slashMenuIndex,
|
|
195
253
|
agentMenuMode,
|
|
196
254
|
resumeMenuMode,
|
|
255
|
+
rewindMenuMode,
|
|
197
256
|
handleSlashMenuUp,
|
|
198
257
|
handleSlashMenuDown,
|
|
199
258
|
handleSlashMenuClose,
|
package/dist/screens/repl.js
CHANGED
|
@@ -19,6 +19,7 @@ import { subscribeAgentCount, getActiveAgentCount } from '../core/spawnRegistry.
|
|
|
19
19
|
import { logError, logInfo, logWarn } from '../core/logger.js';
|
|
20
20
|
import { getAgentSubCommands } from '../commands/index.js';
|
|
21
21
|
import { setActiveAgent } from '../config/agentState.js';
|
|
22
|
+
import { buildShortcutHelpText } from '../config/shortcuts.js';
|
|
22
23
|
import { hideTerminalCursor, showTerminalCursor } from '../terminal/cursor.js';
|
|
23
24
|
export default function REPL() {
|
|
24
25
|
const { exit } = useApp();
|
|
@@ -200,6 +201,22 @@ export default function REPL() {
|
|
|
200
201
|
const trimmed = value.trim();
|
|
201
202
|
if (!trimmed || isProcessing || !engineRef.current)
|
|
202
203
|
return;
|
|
204
|
+
if (trimmed === '?') {
|
|
205
|
+
setInput('');
|
|
206
|
+
slashMenu.setSlashMenuVisible(false);
|
|
207
|
+
if (HIDE_WELCOME_AFTER_INPUT)
|
|
208
|
+
setShowWelcome(false);
|
|
209
|
+
setMessages((prev) => [...prev, {
|
|
210
|
+
id: `shortcut-help-${Date.now()}`,
|
|
211
|
+
type: 'system',
|
|
212
|
+
status: 'success',
|
|
213
|
+
systemKind: 'shortcut_help',
|
|
214
|
+
content: buildShortcutHelpText(),
|
|
215
|
+
timestamp: Date.now(),
|
|
216
|
+
}]);
|
|
217
|
+
logInfo('ui.shortcut_help.open');
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
203
220
|
logInfo('ui.submit', {
|
|
204
221
|
inputLength: trimmed.length,
|
|
205
222
|
isSlashCommand: trimmed.startsWith('/'),
|
|
@@ -243,6 +260,49 @@ export default function REPL() {
|
|
|
243
260
|
}
|
|
244
261
|
return;
|
|
245
262
|
}
|
|
263
|
+
// /rewind
|
|
264
|
+
if (cmdName === 'rewind') {
|
|
265
|
+
if (hasArgs && engineRef.current) {
|
|
266
|
+
setInput('');
|
|
267
|
+
slashMenu.setSlashMenuVisible(false);
|
|
268
|
+
const messageId = parts.slice(1).join(' ').trim();
|
|
269
|
+
const result = engineRef.current.rewindToUserMessage(messageId);
|
|
270
|
+
if (!result) {
|
|
271
|
+
const errMsg = {
|
|
272
|
+
id: `rewind-err-${Date.now()}`,
|
|
273
|
+
type: 'error',
|
|
274
|
+
status: 'error',
|
|
275
|
+
content: '未找到要回退的会话位置',
|
|
276
|
+
timestamp: Date.now(),
|
|
277
|
+
};
|
|
278
|
+
setMessages((prev) => [...prev, errMsg]);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
stopAll();
|
|
282
|
+
setMessages([
|
|
283
|
+
...result.messages,
|
|
284
|
+
{
|
|
285
|
+
id: `rewind-${Date.now()}`,
|
|
286
|
+
type: 'system',
|
|
287
|
+
status: 'success',
|
|
288
|
+
content: `已回退到当前会话的第 ${result.turnIndex} 条提问,请确认后重新发送。`,
|
|
289
|
+
timestamp: Date.now(),
|
|
290
|
+
},
|
|
291
|
+
]);
|
|
292
|
+
sessionRef.current = result.session;
|
|
293
|
+
tokenCountRef.current = result.session.totalTokens;
|
|
294
|
+
syncTokenDisplay(result.session.totalTokens);
|
|
295
|
+
setLoopState(null);
|
|
296
|
+
setIsProcessing(false);
|
|
297
|
+
setShowWelcome(false);
|
|
298
|
+
resetNavigation();
|
|
299
|
+
setInput(result.input);
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
slashMenu.openListCommand('rewind');
|
|
303
|
+
}
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
246
306
|
// /create_skill
|
|
247
307
|
if (cmdName === 'create_skill') {
|
|
248
308
|
setInput('');
|
package/dist/types/index.d.ts
CHANGED