@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 CHANGED
@@ -98,7 +98,21 @@ Jarvis 会按下面顺序加载配置,后者覆盖前者:
98
98
 
99
99
  - `system.model` 对应 `models` 里的 key
100
100
  - `extra_body` 会直接合并进请求体,方便兼容不同服务商
101
- - 如果没有可用模型配置,程序会回退到 Mock Service,适合本地界面联调
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
- | `/agent` | 切换智能体 |
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
- | `Esc` | 中断当前任务或清空输入 |
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`
@@ -7,6 +7,8 @@
7
7
  export interface SlashCommand {
8
8
  /** 命令名称(不含 /) */
9
9
  name: string;
10
+ /** 可选:菜单展示名 */
11
+ displayName?: string;
10
12
  /** 简短描述 */
11
13
  description: string;
12
14
  /** 命令类别 */
@@ -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,4 @@
1
+ import React from 'react';
2
+ declare function ShortcutHelpMessage(): import("react/jsx-runtime").JSX.Element;
3
+ declare const _default: React.MemoExoticComponent<typeof ShortcutHelpMessage>;
4
+ export default _default;
@@ -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
- return (_jsxs(Box, { children: [_jsxs(Text, { color: isSelected ? 'cyan' : 'white', children: [' ', "/", cmd.name, ' '] }), _jsxs(Text, { color: isSelected ? 'cyan' : 'gray', children: ["- ", cmd.description, ' '] }), _jsxs(Text, { color: catColor, dimColor: true, children: ["[", catText, "]"] })] }, cmd.name));
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 删除的会话数量
@@ -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,
@@ -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('');
@@ -31,6 +31,7 @@ export async function executeSlashCommand(cmdName) {
31
31
  ' /bye 退出应用程序',
32
32
  ' /resume 恢复历史会话(支持二级菜单选择)',
33
33
  ' /resume <ID> 直接恢复指定会话',
34
+ ' /rewind 回退当前会话到指定提问位置',
34
35
  ' /help 显示此帮助信息',
35
36
  ' /session_clear 清理所有非当前会话的历史记录',
36
37
  ' /skills 查看当前所有 tools 和 skills',
@@ -6,6 +6,7 @@ export interface Message {
6
6
  status: MessageStatus;
7
7
  content: string;
8
8
  timestamp: number;
9
+ systemKind?: 'shortcut_help';
9
10
  /** 耗时 ms */
10
11
  duration?: number;
11
12
  /** token 统计 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code4bug/jarvis-agent",
3
- "version": "1.3.5",
3
+ "version": "1.3.7",
4
4
  "description": "基于 React + TypeScript + Ink 构建的命令行智能体交互界面",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",