min-agent 0.3.0 → 0.4.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 +111 -28
- package/dist/agent.js +1119 -256
- package/dist/cli/commands/chat.js +10 -0
- package/dist/cli/commands/exec.js +32 -0
- package/dist/cli/commands/history.js +58 -0
- package/dist/cli/commands/index.js +224 -0
- package/dist/cli/commands/init.js +18 -0
- package/dist/cli/commands/mcp.js +173 -0
- package/dist/cli/commands/memory.js +69 -0
- package/dist/cli/commands/models.js +21 -0
- package/dist/cli/commands/permission.js +12 -0
- package/dist/cli/commands/rules.js +33 -0
- package/dist/cli/commands/sandbox.js +13 -0
- package/dist/cli/commands/serve.js +9 -0
- package/dist/cli/commands/setup.js +4 -0
- package/dist/cli/commands/shared.js +16 -0
- package/dist/cli/commands/skills.js +119 -0
- package/dist/cli/commands/update.js +7 -0
- package/dist/cli/commands/write-config.js +30 -0
- package/dist/cli/errors.js +36 -0
- package/dist/cli/exec-prompt.js +26 -0
- package/dist/cli/option-helpers.js +53 -0
- package/dist/cli/program.js +180 -0
- package/dist/cli.js +5 -888
- package/dist/code-mode.js +32 -14
- package/dist/compaction.js +347 -160
- package/dist/config.js +119 -10
- package/dist/confirm.js +56 -9
- package/dist/context-window.js +107 -39
- package/dist/doom-loop.js +264 -29
- package/dist/fetch-timeout.js +152 -0
- package/dist/http-approvals.js +60 -0
- package/dist/instructions.js +21 -0
- package/dist/logger.js +33 -4
- package/dist/markdown.js +37 -11
- package/dist/mcp.js +328 -30
- package/dist/memory.js +97 -56
- package/dist/output.js +7 -5
- package/dist/permission-cli.js +43 -0
- package/dist/plugins.js +46 -8
- package/dist/pricing.js +4 -4
- package/dist/provider.js +23 -6
- package/dist/question-format.js +60 -0
- package/dist/sandbox-cli.js +82 -0
- package/dist/sandbox.js +403 -0
- package/dist/save-throttle.js +45 -0
- package/dist/serve/common.js +404 -0
- package/dist/serve/routes-chat.js +347 -0
- package/dist/serve/routes-mcp.js +212 -0
- package/dist/serve/routes-memory.js +66 -0
- package/dist/serve/routes-meta.js +205 -0
- package/dist/serve/routes-sessions.js +61 -0
- package/dist/serve/routes-skills.js +70 -0
- package/dist/serve.js +33 -883
- package/dist/sessions.js +53 -9
- package/dist/skills.js +82 -18
- package/dist/title-gen.js +8 -2
- package/dist/token-display.js +36 -0
- package/dist/tool-display.js +5 -0
- package/dist/tool-output.js +1 -3
- package/dist/tools/apply_patch.js +85 -11
- package/dist/tools/atomic-file.js +35 -0
- package/dist/tools/backend.js +2 -2
- package/dist/tools/bash.js +57 -19
- package/dist/tools/code_search.js +7 -1
- package/dist/tools/edit.js +11 -10
- package/dist/tools/explore.js +74 -14
- package/dist/tools/glob.js +4 -0
- package/dist/tools/grep.js +17 -10
- package/dist/tools/index.js +6 -21
- package/dist/tools/question.js +28 -9
- package/dist/tools/read.js +6 -4
- package/dist/tools/search-searxng.js +223 -0
- package/dist/tools/search-serper.js +189 -0
- package/dist/tools/task.js +84 -30
- package/dist/tools/todo.js +120 -19
- package/dist/tools/web_fetch.js +11 -3
- package/dist/tools/web_search.js +66 -556
- package/dist/tools/write.js +23 -6
- package/dist/tui/App.js +63 -14
- package/dist/tui/ConfirmBar.js +45 -13
- package/dist/tui/InputBar.js +150 -35
- package/dist/tui/MessageList.js +266 -125
- package/dist/tui/ModelPicker.js +8 -3
- package/dist/tui/QuestionBar.js +51 -19
- package/dist/tui/SessionPicker.js +79 -0
- package/dist/tui/StatusBar.js +8 -14
- package/dist/tui/agent-runner.js +142 -22
- package/dist/tui/caret-pos.js +48 -5
- package/dist/tui/caret.js +1 -1
- package/dist/tui/click-count.js +13 -0
- package/dist/tui/drag-state.js +8 -3
- package/dist/tui/hydrate.js +129 -0
- package/dist/tui/index.js +42 -13
- package/dist/tui/input-history.js +92 -11
- package/dist/tui/layout.js +75 -4
- package/dist/tui/prompt-queue.js +24 -0
- package/dist/tui/selection.js +113 -21
- package/dist/tui/session-switch.js +28 -0
- package/dist/tui/slash-commands.js +22 -6
- package/dist/tui/slash-handler.js +233 -58
- package/dist/tui/text-width.js +38 -16
- package/dist/tui/token-info.js +7 -0
- package/dist/tui/tool-children.js +19 -0
- package/dist/tui/undo-stack.js +1 -1
- package/dist/tui/use-sgr-mouse.js +3 -1
- package/dist/tui-chat.js +276 -40
- package/dist/updater.js +88 -29
- package/dist/xml-search.js +194 -0
- package/docs/API.md +257 -25
- package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
- package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
- package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
- package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
- package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
- package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
- package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
- package/package.json +6 -1
- package/skills/self-config/SKILL.md +90 -0
- package/skills/self-config/reference.md +149 -0
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
# TUI 补齐(第 1 批)— 实现计划
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** 会话内切换并画出历史;`/reload`;输入历史落盘;Home/End 与折行移动;点击斜杠菜单;双击/三击与复制去前缀;Ctrl+B 消息区选择;TUI 中 `task` 可展开子工具。
|
|
6
|
+
|
|
7
|
+
**Architecture:** 先落地可单测的纯函数(hydrate、历史文件、caret 垂直移动、选区去前缀、点击计数、会话切换守卫),再接到现有 Ink 组件。新 UI 只加 `SessionPicker`。选择模式复用 `selectionRanges`。
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript, React 18 + Ink 5, bun:test
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-08-20-tui-completeness-design.md`
|
|
12
|
+
|
|
13
|
+
测试命令(`packages/min-agent` 下):`bun test tests/<file>`;类型:`npx tsc --noEmit`;仓库根目录:`bun run typecheck`。
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 文件结构
|
|
18
|
+
|
|
19
|
+
| 文件 | 职责 |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `src/tui/hydrate.ts`(新) | `ModelMessage[]` → `TuiMessage[]` |
|
|
22
|
+
| `tests/hydrate.test.ts`(新) | hydrate 映射 |
|
|
23
|
+
| `src/tui/session-switch.ts`(新) | 切换守卫、`lastUserText` |
|
|
24
|
+
| `tests/session-switch.test.ts`(新) | running/current/missing |
|
|
25
|
+
| `src/tui/input-history.ts`(改) | 上限 1000、parse/serialize、load/save |
|
|
26
|
+
| `tests/input-history.test.ts`(改) | 文件格式与坏文件 |
|
|
27
|
+
| `src/tui/caret-pos.ts`(改) | `moveCaretVertical`、Home/End 扫描 |
|
|
28
|
+
| `tests/caret-pos.test.ts`(改) | 折行上下 |
|
|
29
|
+
| `src/tui/selection.ts`(改) | 去前缀、选词范围 |
|
|
30
|
+
| `tests/selection.test.ts`(改) | 去前缀与词界 |
|
|
31
|
+
| `src/tui/click-count.ts`(新) | 双击/三击计数 |
|
|
32
|
+
| `tests/click-count.test.ts`(新) | 400ms / 列差 |
|
|
33
|
+
| `src/tui/SessionPicker.tsx`(新) | 会话选择器 |
|
|
34
|
+
| `src/tui/types.ts` `index.tsx` `App.tsx` `StatusBar.tsx`(改) | picker、selectionMode |
|
|
35
|
+
| `src/tui/slash-commands.ts` `slash-handler.ts` `tui-chat.ts`(改) | `/sessions` `/resume` `/reload` |
|
|
36
|
+
| `src/tui/agent-runner.ts`(改) | `getSystemPrompt`、子 agent 回调 |
|
|
37
|
+
| `src/tui/InputBar.tsx`(改) | 历史落盘、编辑键、点菜单 |
|
|
38
|
+
| `src/tui/MessageList.tsx`(改) | 去前缀复制、双击三击、选择模式、子工具行 |
|
|
39
|
+
| `src/tools/task.ts`(改) | `onSubToolCall` / `onSubToolResult` |
|
|
40
|
+
| `README.md` `/help` `AGENTS.md`(改) | 文案 |
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Task 1: hydrate.ts
|
|
45
|
+
|
|
46
|
+
**Files:**
|
|
47
|
+
- Create: `src/tui/hydrate.ts`
|
|
48
|
+
- Test: `tests/hydrate.test.ts`
|
|
49
|
+
|
|
50
|
+
- [ ] **Step 1: 写失败测试**
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { expect, test } from "bun:test"
|
|
54
|
+
import type { ModelMessage } from "ai"
|
|
55
|
+
import { hydrateMessages, lastUserText } from "../src/tui/hydrate.js"
|
|
56
|
+
|
|
57
|
+
test("hydrateMessages maps user string content", () => {
|
|
58
|
+
const out = hydrateMessages([{ role: "user", content: "hello" }])
|
|
59
|
+
expect(out).toHaveLength(1)
|
|
60
|
+
expect(out[0]).toMatchObject({ role: "user", content: "hello" })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test("hydrateMessages uses image placeholder when there is no text", () => {
|
|
64
|
+
const out = hydrateMessages([{
|
|
65
|
+
role: "user",
|
|
66
|
+
content: [{ type: "image", image: "x" }],
|
|
67
|
+
} as ModelMessage])
|
|
68
|
+
expect(out[0]!.content).toBe("[📎 图片]")
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test("hydrateMessages keeps user text and ignores image parts when both exist", () => {
|
|
72
|
+
const out = hydrateMessages([{
|
|
73
|
+
role: "user",
|
|
74
|
+
content: [
|
|
75
|
+
{ type: "text", text: "看这张" },
|
|
76
|
+
{ type: "image", image: "x" },
|
|
77
|
+
],
|
|
78
|
+
} as ModelMessage])
|
|
79
|
+
expect(out[0]!.content).toBe("看这张")
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test("hydrateMessages keeps assistant text and drops tool-call parts", () => {
|
|
83
|
+
const out = hydrateMessages([{
|
|
84
|
+
role: "assistant",
|
|
85
|
+
content: [
|
|
86
|
+
{ type: "text", text: "ok" },
|
|
87
|
+
{ type: "tool-call", toolCallId: "1", toolName: "bash", input: {} },
|
|
88
|
+
],
|
|
89
|
+
} as ModelMessage])
|
|
90
|
+
expect(out).toHaveLength(1)
|
|
91
|
+
expect(out[0]).toMatchObject({ role: "assistant", content: "ok" })
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test("hydrateMessages maps tool results", () => {
|
|
95
|
+
const out = hydrateMessages([{
|
|
96
|
+
role: "tool",
|
|
97
|
+
content: [{ type: "tool-result", toolCallId: "1", toolName: "bash", output: "done" }],
|
|
98
|
+
} as ModelMessage])
|
|
99
|
+
expect(out).toHaveLength(1)
|
|
100
|
+
expect(out[0]!.role).toBe("tool")
|
|
101
|
+
expect(out[0]!.toolName).toBe("bash")
|
|
102
|
+
expect(out[0]!.content.length).toBeGreaterThan(0)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test("hydrateMessages skips unknown roles and empty assistant", () => {
|
|
106
|
+
expect(hydrateMessages([
|
|
107
|
+
{ role: "system", content: "nope" } as ModelMessage,
|
|
108
|
+
{ role: "assistant", content: [] } as ModelMessage,
|
|
109
|
+
])).toEqual([])
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test("lastUserText returns the last user string", () => {
|
|
113
|
+
expect(lastUserText([
|
|
114
|
+
{ role: "user", content: "one" },
|
|
115
|
+
{ role: "assistant", content: "a" },
|
|
116
|
+
{ role: "user", content: [{ type: "text", text: "two" }] },
|
|
117
|
+
] as ModelMessage[])).toBe("two")
|
|
118
|
+
expect(lastUserText([{ role: "assistant", content: "a" }] as ModelMessage[])).toBeNull()
|
|
119
|
+
})
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
把 `lastUserText` 放在 `hydrate.ts`(spec 写在 session-switch,同文件或 hydrate 均可;本计划放 hydrate,session-switch 只做守卫)。
|
|
123
|
+
|
|
124
|
+
- [ ] **Step 2: 运行确认失败**
|
|
125
|
+
|
|
126
|
+
Run: `bun test tests/hydrate.test.ts`
|
|
127
|
+
Expected: FAIL(模块不存在)
|
|
128
|
+
|
|
129
|
+
- [ ] **Step 3: 实现 `src/tui/hydrate.ts`**
|
|
130
|
+
|
|
131
|
+
- `user`:`content` 为 string 用其本身;为数组则拼接所有 `type === "text"` 的 `text`;没有任何文本但有 image/file part → `[📎 图片]`;完全空 → 跳过
|
|
132
|
+
- `assistant`:string 或 text parts 拼接;无文本 → 跳过
|
|
133
|
+
- `tool`:读 `content` 数组里 `tool-result`:`toolName` 缺则跳过;`content` = `summarizeToolCall` 若无 input 则用 `toolName`;`toolResult` / `toolResultSummary` 用 `toolResultText` / `summarizeToolResult`
|
|
134
|
+
- id:`hyd-${index}-${role}`;`timestamp: 0`
|
|
135
|
+
- 其它 role:跳过
|
|
136
|
+
|
|
137
|
+
`lastUserText`:从后往前找 `role === "user"`,规则与 hydrate 的 user 文本相同;仅图片返回 `null`。
|
|
138
|
+
|
|
139
|
+
- [ ] **Step 4: 运行确认通过**
|
|
140
|
+
|
|
141
|
+
Run: `bun test tests/hydrate.test.ts`
|
|
142
|
+
Expected: PASS
|
|
143
|
+
|
|
144
|
+
- [ ] **Step 5: 提交**
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
git add src/tui/hydrate.ts tests/hydrate.test.ts
|
|
148
|
+
git commit -m "feat: 将会话消息映射为 TUI 行"
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## Task 2: session-switch 守卫
|
|
154
|
+
|
|
155
|
+
**Files:**
|
|
156
|
+
- Create: `src/tui/session-switch.ts`
|
|
157
|
+
- Test: `tests/session-switch.test.ts`
|
|
158
|
+
|
|
159
|
+
- [ ] **Step 1: 写失败测试**
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
import { expect, test } from "bun:test"
|
|
163
|
+
import { prepareSwitch } from "../src/tui/session-switch.js"
|
|
164
|
+
|
|
165
|
+
test("prepareSwitch rejects while running", () => {
|
|
166
|
+
expect(prepareSwitch({ running: true, currentId: "a", targetId: "b", exists: true })).toEqual({
|
|
167
|
+
ok: false, reason: "running",
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test("prepareSwitch rejects the current session", () => {
|
|
172
|
+
expect(prepareSwitch({ running: false, currentId: "a", targetId: "a", exists: true })).toEqual({
|
|
173
|
+
ok: false, reason: "current",
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test("prepareSwitch rejects a missing session", () => {
|
|
178
|
+
expect(prepareSwitch({ running: false, currentId: "a", targetId: "z", exists: false })).toEqual({
|
|
179
|
+
ok: false, reason: "missing",
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test("prepareSwitch accepts a different existing session", () => {
|
|
184
|
+
expect(prepareSwitch({ running: false, currentId: "a", targetId: "b", exists: true })).toEqual({
|
|
185
|
+
ok: true,
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
- [ ] **Step 2: FAIL** — `bun test tests/session-switch.test.ts`
|
|
191
|
+
|
|
192
|
+
- [ ] **Step 3: 实现**
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
export type SwitchFail = "running" | "current" | "missing"
|
|
196
|
+
|
|
197
|
+
export function prepareSwitch(opts: {
|
|
198
|
+
running: boolean
|
|
199
|
+
currentId: string | undefined
|
|
200
|
+
targetId: string
|
|
201
|
+
exists: boolean
|
|
202
|
+
}): { ok: true } | { ok: false; reason: SwitchFail } {
|
|
203
|
+
if (opts.running) return { ok: false, reason: "running" }
|
|
204
|
+
if (opts.currentId === opts.targetId) return { ok: false, reason: "current" }
|
|
205
|
+
if (!opts.exists) return { ok: false, reason: "missing" }
|
|
206
|
+
return { ok: true }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function switchFailMessage(reason: SwitchFail): string {
|
|
210
|
+
if (reason === "running") return "请先等待当前回合结束或按 Esc 取消"
|
|
211
|
+
if (reason === "current") return "已是当前会话"
|
|
212
|
+
return "会话不存在"
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
- [ ] **Step 4: PASS** — `bun test tests/session-switch.test.ts`
|
|
217
|
+
|
|
218
|
+
- [ ] **Step 5: 提交** `feat: 会话切换守卫`
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## Task 3: 输入历史文件
|
|
223
|
+
|
|
224
|
+
**Files:**
|
|
225
|
+
- Modify: `src/tui/input-history.ts`
|
|
226
|
+
- Modify: `tests/input-history.test.ts`
|
|
227
|
+
|
|
228
|
+
- [ ] **Step 1: 追加测试**
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
import { parseHistoryFile, serializeHistoryFile, HISTORY_LIMIT } from "../src/tui/input-history.js"
|
|
232
|
+
|
|
233
|
+
test("HISTORY_LIMIT is 1000", () => {
|
|
234
|
+
expect(HISTORY_LIMIT).toBe(1000)
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
test("parseHistoryFile reads entries array", () => {
|
|
238
|
+
expect(parseHistoryFile('{"entries":["a","b"]}')).toEqual(["a", "b"])
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test("parseHistoryFile returns null for corrupt payloads", () => {
|
|
242
|
+
expect(parseHistoryFile("{")).toBeNull()
|
|
243
|
+
expect(parseHistoryFile("[]")).toBeNull()
|
|
244
|
+
expect(parseHistoryFile('{"entries":[1]}')).toBeNull()
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
test("serializeHistoryFile round-trips", () => {
|
|
248
|
+
expect(parseHistoryFile(serializeHistoryFile(["x"]))).toEqual(["x"])
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
test("pushInput trims to HISTORY_LIMIT", () => {
|
|
252
|
+
let h = createHistory()
|
|
253
|
+
for (let i = 0; i < 1005; i++) h = pushInput(h, `e${i}`)
|
|
254
|
+
expect(h.entries).toHaveLength(1000)
|
|
255
|
+
expect(h.entries[0]).toBe("e5")
|
|
256
|
+
expect(h.entries[999]).toBe("e1004")
|
|
257
|
+
})
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
现有 `pushInput` 测试仍应通过;把 `const HISTORY_LIMIT = 500` 改为 `export const HISTORY_LIMIT = 1000`。
|
|
261
|
+
|
|
262
|
+
- [ ] **Step 2: FAIL**(LIMIT 仍是 500 / 无 parse 函数)
|
|
263
|
+
|
|
264
|
+
- [ ] **Step 3: 实现**
|
|
265
|
+
|
|
266
|
+
`parseHistoryFile(raw: string): string[] | null`:JSON 对象且 `entries` 为 string[] 才返回,否则 `null`。
|
|
267
|
+
|
|
268
|
+
`serializeHistoryFile(entries: string[]): string`:`JSON.stringify({ entries }, null, 2)`。
|
|
269
|
+
|
|
270
|
+
`loadInputHistory(): string[]`:读 `path.join(getConfigDir(), "input-history.json")`;不存在 → `[]`;parse 失败 → 尝试 `renameSync` 到 `input-history.json.corrupt-${Date.now()}`,返回 `[]`。
|
|
271
|
+
|
|
272
|
+
`saveInputHistory(entries: string[]): void`:目录 `mkdirSync`;写 `*.tmp-${randomUUID()}` 再 `renameSync`;catch 后尽量删 tmp,不抛。
|
|
273
|
+
|
|
274
|
+
`InputBar` 本 task **先不接** load/save(下一截 InputBar task)。本 task 只把纯函数和 fs 辅助做好并单测 parse/serialize/limit;`load`/`save` 若要用临时目录测,可在测试里传 path 重载:
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
export function loadInputHistoryFrom(file: string): string[]
|
|
278
|
+
export function saveInputHistoryTo(file: string, entries: string[]): void
|
|
279
|
+
export function loadInputHistory(): string[] {
|
|
280
|
+
return loadInputHistoryFrom(path.join(getConfigDir(), "input-history.json"))
|
|
281
|
+
}
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
给 `loadInputHistoryFrom` 加一个 bun test:写临时文件、读回、坏文件改名。用 `mkdtempSync`。
|
|
285
|
+
|
|
286
|
+
- [ ] **Step 4: PASS** — `bun test tests/input-history.test.ts`
|
|
287
|
+
|
|
288
|
+
- [ ] **Step 5: 提交** `feat: 输入历史上限 1000 并落盘`
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
## Task 4: moveCaretVertical + Home/End 扫描
|
|
293
|
+
|
|
294
|
+
**Files:**
|
|
295
|
+
- Modify: `src/tui/caret-pos.ts`
|
|
296
|
+
- Modify: `tests/caret-pos.test.ts`
|
|
297
|
+
|
|
298
|
+
- [ ] **Step 1: 追加测试**
|
|
299
|
+
|
|
300
|
+
```ts
|
|
301
|
+
import { moveCaretVertical, scanHomeEndKeys } from "../src/tui/caret-pos.js"
|
|
302
|
+
|
|
303
|
+
test("moveCaretVertical stays on the same display column across wrapped rows", () => {
|
|
304
|
+
// width 4 → "abcd" / "ef"
|
|
305
|
+
expect(moveCaretVertical("abcdef", 1, 1, 4)).toBe(5) // 'b' col1 → 'f' col1 of row1
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
test("moveCaretVertical up from the first row returns the same index", () => {
|
|
309
|
+
expect(moveCaretVertical("abcdef", 1, -1, 4)).toBe(1)
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
test("moveCaretVertical down from the last row returns the same index", () => {
|
|
313
|
+
expect(moveCaretVertical("abcdef", 5, 1, 4)).toBe(5)
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
test("moveCaretVertical crosses a newline", () => {
|
|
317
|
+
expect(moveCaretVertical("ab\ncd", 1, 1, 10)).toBe(4) // 'b' → 'd'
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
test("scanHomeEndKeys finds CSI H/F and 1~/4~", () => {
|
|
321
|
+
expect(scanHomeEndKeys("", "\x1b[H").actions).toEqual([{ kind: "home" }])
|
|
322
|
+
expect(scanHomeEndKeys("", "\x1b[F").actions).toEqual([{ kind: "end" }])
|
|
323
|
+
expect(scanHomeEndKeys("", "\x1b[1~").actions).toEqual([{ kind: "home" }])
|
|
324
|
+
expect(scanHomeEndKeys("", "\x1b[4~").actions).toEqual([{ kind: "end" }])
|
|
325
|
+
expect(scanHomeEndKeys("", "hello").actions).toEqual([])
|
|
326
|
+
})
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
`moveCaretVertical` 语义:用 `visualRows(value, maxWidth)` 找到 caret 所在行与该行内显示列(`displayWidth` of chars before caret in that row);目标行 = 当前行 ± 1,越界则返回原 `caretIndex`;目标行上用 `caretIndexFromClick` 同类:按显示列落到最近字符边界。`textStartColumn` 对函数无意义,直接在行内 width 上算。
|
|
330
|
+
|
|
331
|
+
建议实现:
|
|
332
|
+
|
|
333
|
+
```ts
|
|
334
|
+
export function moveCaretVertical(value: string, caretIndex: number, direction: 1 | -1, maxWidth: number): number {
|
|
335
|
+
const rows = visualRows(value, maxWidth)
|
|
336
|
+
// find row containing caretIndex (same loop as InputBar)
|
|
337
|
+
// col = displayWidth(row.text slice to offset)
|
|
338
|
+
// next = rowIndex + direction; if out of range return caretIndex
|
|
339
|
+
// walk next row by charWidth until col is reached
|
|
340
|
+
}
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
`scanHomeEndKeys` 模仿 `scanDeleteKeys`:匹配 `\x1b\[(?:[HF]|1~|4~|7~|8~)`,拆分跨 chunk。**不要**把 CSI `3~`(Delete)当成 End。
|
|
344
|
+
|
|
345
|
+
- [ ] **Step 2: FAIL**
|
|
346
|
+
|
|
347
|
+
- [ ] **Step 3: 实现**
|
|
348
|
+
|
|
349
|
+
- [ ] **Step 4: PASS** — `bun test tests/caret-pos.test.ts`
|
|
350
|
+
|
|
351
|
+
- [ ] **Step 5: 提交** `feat: 输入框折行上下移动与 Home/End 解析`
|
|
352
|
+
|
|
353
|
+
---
|
|
354
|
+
|
|
355
|
+
## Task 5: 复制去前缀 + 选词
|
|
356
|
+
|
|
357
|
+
**Files:**
|
|
358
|
+
- Modify: `src/tui/selection.ts`
|
|
359
|
+
- Modify: `tests/selection.test.ts`
|
|
360
|
+
|
|
361
|
+
- [ ] **Step 1: 追加测试**
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
import { stripCopyDecorations, wordRangeAt, type CopyKind } from "../src/tui/selection.js"
|
|
365
|
+
|
|
366
|
+
test("stripCopyDecorations drops user and tool prefixes", () => {
|
|
367
|
+
expect(stripCopyDecorations("> hello", "user-head")).toBe("hello")
|
|
368
|
+
expect(stripCopyDecorations("⚡ bash ▸ ls", "tool-head")).toBe("ls")
|
|
369
|
+
expect(stripCopyDecorations("⚡ bash ▾ ls", "tool-head")).toBe("ls")
|
|
370
|
+
expect(stripCopyDecorations(" output", "tool-body")).toBe("output")
|
|
371
|
+
expect(stripCopyDecorations("plain", "plain")).toBe("plain")
|
|
372
|
+
expect(stripCopyDecorations("hello", "user-head")).toBe("hello") // no prefix: unchanged
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
test("wordRangeAt selects the whitespace-delimited word", () => {
|
|
376
|
+
// "hello world" — click col 1 (e) → hello [0,5)
|
|
377
|
+
expect(wordRangeAt("hello world", 1)).toEqual({ start: 0, end: 5 })
|
|
378
|
+
expect(wordRangeAt("hello world", 6)).toEqual({ start: 6, end: 11 })
|
|
379
|
+
expect(wordRangeAt(" hi", 0)).toEqual({ start: 0, end: 0 }) // whitespace: empty
|
|
380
|
+
expect(wordRangeAt("你好 ab", 1)).toEqual({ start: 0, end: 2 }) // 你 is width 2, col 1 still in 你
|
|
381
|
+
})
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
`wordRangeAt(line, col)`:`line` 已是剥 ANSI 的显示串;`col` 为显示列。找到包含该列的字符,向两侧扩到空白。落在空白上返回 `{start: col, end: col}`(空选,调用方不当成词)。CJK 按空白切,`你好` 中间无空白则整段一起选。
|
|
385
|
+
|
|
386
|
+
`stripCopyDecorations`:
|
|
387
|
+
|
|
388
|
+
- `user-head`:`startsWith("> ")` 则 slice(2)
|
|
389
|
+
- `tool-head`:`/^(⚡ .*?[▸▾] )/` 替换为空
|
|
390
|
+
- `tool-body`:`startsWith(" ")` 则 slice(2)
|
|
391
|
+
- `plain`:原样
|
|
392
|
+
|
|
393
|
+
- [ ] **Step 2–4: FAIL / 实现 / PASS**
|
|
394
|
+
|
|
395
|
+
- [ ] **Step 5: 提交** `feat: 复制去装饰前缀与按词取选区`
|
|
396
|
+
|
|
397
|
+
---
|
|
398
|
+
|
|
399
|
+
## Task 6: 双击计数纯函数
|
|
400
|
+
|
|
401
|
+
**Files:**
|
|
402
|
+
- Create: `src/tui/click-count.ts`
|
|
403
|
+
- Test: `tests/click-count.test.ts`
|
|
404
|
+
|
|
405
|
+
- [ ] **Step 1: 测试**
|
|
406
|
+
|
|
407
|
+
```ts
|
|
408
|
+
import { expect, test } from "bun:test"
|
|
409
|
+
import { createClickCounter, noteClick } from "../src/tui/click-count.js"
|
|
410
|
+
|
|
411
|
+
test("first click is count 1", () => {
|
|
412
|
+
const c = createClickCounter()
|
|
413
|
+
expect(noteClick(c, { row: 1, col: 3, at: 1000 })).toBe(1)
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
test("second click within 400ms and 2 cols is a double", () => {
|
|
417
|
+
const c = createClickCounter()
|
|
418
|
+
noteClick(c, { row: 1, col: 3, at: 1000 })
|
|
419
|
+
expect(noteClick(c, { row: 1, col: 4, at: 1300 })).toBe(2)
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
test("triple click", () => {
|
|
423
|
+
const c = createClickCounter()
|
|
424
|
+
noteClick(c, { row: 2, col: 5, at: 0 })
|
|
425
|
+
noteClick(c, { row: 2, col: 5, at: 100 })
|
|
426
|
+
expect(noteClick(c, { row: 2, col: 6, at: 200 })).toBe(3)
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
test("expired or far click resets to 1", () => {
|
|
430
|
+
const c = createClickCounter()
|
|
431
|
+
noteClick(c, { row: 1, col: 3, at: 0 })
|
|
432
|
+
expect(noteClick(c, { row: 1, col: 3, at: 401 })).toBe(1)
|
|
433
|
+
expect(noteClick(c, { row: 2, col: 3, at: 450 })).toBe(1)
|
|
434
|
+
noteClick(c, { row: 2, col: 3, at: 500 })
|
|
435
|
+
expect(noteClick(c, { row: 2, col: 10, at: 600 })).toBe(1)
|
|
436
|
+
})
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
```ts
|
|
440
|
+
export const DOUBLE_CLICK_MS = 400
|
|
441
|
+
export const DOUBLE_CLICK_COL_SLOP = 2
|
|
442
|
+
|
|
443
|
+
export interface ClickCounter {
|
|
444
|
+
row: number
|
|
445
|
+
col: number
|
|
446
|
+
at: number
|
|
447
|
+
count: number
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export function createClickCounter(): ClickCounter {
|
|
451
|
+
return { row: -1, col: -1, at: -Infinity, count: 0 }
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export function noteClick(c: ClickCounter, ev: { row: number; col: number; at: number }): number {
|
|
455
|
+
const same = ev.row === c.row && Math.abs(ev.col - c.col) <= DOUBLE_CLICK_COL_SLOP && ev.at - c.at <= DOUBLE_CLICK_MS
|
|
456
|
+
c.count = same ? c.count + 1 : 1
|
|
457
|
+
c.row = ev.row
|
|
458
|
+
c.col = ev.col
|
|
459
|
+
c.at = ev.at
|
|
460
|
+
return c.count
|
|
461
|
+
}
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
- [ ] **Step 2–5: FAIL / 实现 / PASS / 提交** `feat: 双击三击计数`
|
|
465
|
+
|
|
466
|
+
---
|
|
467
|
+
|
|
468
|
+
## Task 7: runner getter + `/reload` + `--resume` 画出历史
|
|
469
|
+
|
|
470
|
+
**Files:**
|
|
471
|
+
- Modify: `src/tui/agent-runner.ts`
|
|
472
|
+
- Modify: `src/tui-chat.ts`
|
|
473
|
+
- Modify: `src/tui/slash-handler.ts`
|
|
474
|
+
- Modify: `src/tui/slash-commands.ts`
|
|
475
|
+
|
|
476
|
+
- [ ] **Step 1: 无单独失败测试**(Ink)。`npx tsc --noEmit` 必须过。
|
|
477
|
+
|
|
478
|
+
- [ ] **Step 2: 实现**
|
|
479
|
+
|
|
480
|
+
`AgentRunnerDeps`:删除 `codeSystemPrompt: string`,改为 `getSystemPrompt: () => string`。`runOnceWithSystem(..., deps.getSystemPrompt(), ...)`。
|
|
481
|
+
|
|
482
|
+
`tui-chat.ts`:
|
|
483
|
+
|
|
484
|
+
```ts
|
|
485
|
+
let systemPrompt = buildCodeSystemPrompt(project, instructions)
|
|
486
|
+
// ...
|
|
487
|
+
createAgentRunner({ ..., getSystemPrompt: () => systemPrompt })
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
`tui.start()` 之后、首条 prompt 之前:
|
|
491
|
+
|
|
492
|
+
```ts
|
|
493
|
+
if (messages.length > 0) {
|
|
494
|
+
const { hydrateMessages } = await import("./tui/hydrate.js")
|
|
495
|
+
tui.update({ messages: hydrateMessages(messages) })
|
|
496
|
+
}
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
`SlashState` 增加:
|
|
500
|
+
|
|
501
|
+
```ts
|
|
502
|
+
isRunning: () => boolean
|
|
503
|
+
reloadSystemPrompt: () => Promise<void>
|
|
504
|
+
applySession: (id: string) => void
|
|
505
|
+
openSessionPicker: () => void
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
(`applySession` / picker 本 task 可先留空实现或先只接 reload;若 typecheck 需要完整字段,本 task 就把 `applySession` 做成「仅 sysMsg 未实现」**禁止**——必须本 task 把 reload 做完,session 命令放到 Task 8,因此 **本 task 不要改 SlashState 的 session 字段**,只加 `reloadSystemPrompt`。)
|
|
509
|
+
|
|
510
|
+
`case "reload"`:`await state.reloadSystemPrompt()`。
|
|
511
|
+
|
|
512
|
+
`tui-chat` 传入:
|
|
513
|
+
|
|
514
|
+
```ts
|
|
515
|
+
reloadSystemPrompt: async () => {
|
|
516
|
+
const { loadInstructions } = await import("./instructions.js")
|
|
517
|
+
const next = await loadInstructions()
|
|
518
|
+
systemPrompt = buildCodeSystemPrompt(scanProject(), next)
|
|
519
|
+
sysMsg(tui, `✓ 已重载规则(${next.length} 字符)`)
|
|
520
|
+
}
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
`SLASH_COMMANDS` 加 `{ name: "reload", description: "重新加载规则" }`。`/help` 加 `/reload`。
|
|
524
|
+
|
|
525
|
+
- [ ] **Step 3:** `npx tsc --noEmit`
|
|
526
|
+
|
|
527
|
+
- [ ] **Step 4: 提交** `feat: 恢复会话画出历史并支持 /reload`
|
|
528
|
+
|
|
529
|
+
---
|
|
530
|
+
|
|
531
|
+
## Task 8: SessionPicker + `/sessions` `/resume`
|
|
532
|
+
|
|
533
|
+
**Files:**
|
|
534
|
+
- Create: `src/tui/SessionPicker.tsx`
|
|
535
|
+
- Modify: `src/tui/types.ts` `src/tui/index.tsx` `src/tui/App.tsx`
|
|
536
|
+
- Modify: `src/tui-chat.ts` `src/tui/slash-handler.ts` `src/tui/slash-commands.ts`
|
|
537
|
+
|
|
538
|
+
- [ ] **Step 1:** 以 `ModelPicker.tsx` 为模板复制为 `SessionPicker.tsx`:
|
|
539
|
+
|
|
540
|
+
- props:`currentId?: string`、`onSelect(id: string)`、`onCancel()`
|
|
541
|
+
- 数据:`listSessions()`(同步,无需 loading)
|
|
542
|
+
- 过滤:id 或 title 包含 query(大小写不敏感)
|
|
543
|
+
- 行文案:`` `${s.id} ${date} ${s.title} (${s.messageCount})` ``,当前加 ` ←`
|
|
544
|
+
- 标题:「选择会话」
|
|
545
|
+
- 空列表:「无已保存的会话」+ Esc 关闭(`tui-chat` 在打开前若 `listSessions().length === 0` 则只 sysMsg,不 `showSessionPicker`)
|
|
546
|
+
- `sessionPickerRows(terminalRows)`:与 `modelPickerRows` 同样公式
|
|
547
|
+
|
|
548
|
+
`TuiState.sessionPicker?: boolean`。`TuiPrompt` 可不动(现有 modelPicker 也是独立 boolean)。
|
|
549
|
+
|
|
550
|
+
`TuiCallbacks`:`onSessionPick(id: string)`、`onSessionCancel()`。`TuiRenderer` 构造函数 / `update` / `start` 的 `<App>` 透传。`showSessionPicker` / `hideSessionPicker`。
|
|
551
|
+
|
|
552
|
+
`App` footer:`sessionPicker` 与 `modelPicker` 同级互斥(先 confirm/question,再 model,再 session,再 input)。`footerRows` 在 sessionPicker 时用 `sessionPickerRows`。消息区:session picker 打开时 **不要**像 model picker 那样藏掉消息区(spec:只替换输入框)。`modelPickerActive` 仍藏消息区;`sessionPickerActive` 不藏。
|
|
553
|
+
|
|
554
|
+
`InputBar` `disabled`:`isRunning || selectionMode`(selectionMode 下一 task 再加,本 task 先不加)。
|
|
555
|
+
|
|
556
|
+
`slash-commands.ts`:
|
|
557
|
+
|
|
558
|
+
```ts
|
|
559
|
+
{ name: "sessions", description: "列出/切换已保存会话", aliases: ["resume"], argHint: "[id]" },
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
不要再单独一条 `resume`(alias 即可)。`filterSlashCommands` 已支持 aliases。
|
|
563
|
+
|
|
564
|
+
`tui-chat` `registerSkillCompletion` 旁注册:
|
|
565
|
+
|
|
566
|
+
```ts
|
|
567
|
+
setSlashArgProvider("sessions", () => listSessions().map((s) => ({ value: s.id, description: s.title })))
|
|
568
|
+
setSlashArgProvider("resume", ...) // 若 parse 用的是 alias 名,alias 走 sessions provider:在 getSlashArgOptions 里 command 可能是 resume。给 resume 也 set 同一 provider,或在 provider map 里两个 key。
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
`handleSlashCommand`:
|
|
572
|
+
|
|
573
|
+
```
|
|
574
|
+
case "resume":
|
|
575
|
+
case "sessions": {
|
|
576
|
+
const id = arg.trim()
|
|
577
|
+
if (!id) { state.openSessionPicker(); break }
|
|
578
|
+
state.applySession(id)
|
|
579
|
+
break
|
|
580
|
+
}
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
`applySession(id)` 在 `tui-chat`:
|
|
584
|
+
|
|
585
|
+
```ts
|
|
586
|
+
const { prepareSwitch, switchFailMessage } = await import("./tui/session-switch.js")
|
|
587
|
+
const { loadSession, saveSession, listSessions } = await import("./sessions.js")
|
|
588
|
+
const { hydrateMessages, lastUserText } = await import("./tui/hydrate.js")
|
|
589
|
+
const { clearStack } = await import("./tui/undo-stack.js")
|
|
590
|
+
const ready = prepareSwitch({
|
|
591
|
+
running: Boolean(runner?.getController()),
|
|
592
|
+
currentId: sessionId,
|
|
593
|
+
targetId: id,
|
|
594
|
+
exists: Boolean(loadSession(id)),
|
|
595
|
+
})
|
|
596
|
+
if (!ready.ok) { sysMsg(tui, switchFailMessage(ready.reason)); return }
|
|
597
|
+
if (messages.length > 0) sessionId = saveSession(messages, sessionId)
|
|
598
|
+
const session = loadSession(id)!
|
|
599
|
+
messages.length = 0
|
|
600
|
+
messages.push(...session.messages)
|
|
601
|
+
sessionId = id
|
|
602
|
+
undoStack = clearStack(undoStack)
|
|
603
|
+
tracker.resetContext()
|
|
604
|
+
lastUserMessage = lastUserText(session.messages)
|
|
605
|
+
tui.update({ messages: hydrateMessages(session.messages) })
|
|
606
|
+
sysMsg(tui, `✓ 已切换到: ${session.meta.title}`)
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
注意:`sysMsg` 会再 `addMessage` 一条系统提示,所以 `update` 之后再 `sysMsg`,提示出现在历史之后。
|
|
610
|
+
|
|
611
|
+
`onSessionPick`:`hideSessionPicker()` + `applySession(id)`。`onSessionCancel`:hide。
|
|
612
|
+
|
|
613
|
+
`openSessionPicker`:若 `listSessions().length === 0` sysMsg「无已保存的会话」;否则 `tui.showSessionPicker()`。
|
|
614
|
+
|
|
615
|
+
`/help` 把 `/sessions` 改成列出/切换,并写 `/resume` 为别名。
|
|
616
|
+
|
|
617
|
+
- [ ] **Step 2:** `npx tsc --noEmit`
|
|
618
|
+
|
|
619
|
+
- [ ] **Step 3: 提交** `feat: TUI 内切换会话`
|
|
620
|
+
|
|
621
|
+
---
|
|
622
|
+
|
|
623
|
+
## Task 9: InputBar 接历史、编辑键、点菜单
|
|
624
|
+
|
|
625
|
+
**Files:**
|
|
626
|
+
- Modify: `src/tui/InputBar.tsx`
|
|
627
|
+
|
|
628
|
+
- [ ] **Step 1:** 无单元测试(几何依赖终端)。实现后 `npx tsc --noEmit`。
|
|
629
|
+
|
|
630
|
+
- [ ] **Step 2: 实现**
|
|
631
|
+
|
|
632
|
+
1. **历史落盘:** 初始 `createHistory()` 后 `entries: loadInputHistory()`。每次 `pushInput` 成功后 `saveInputHistory(next.entries)`。
|
|
633
|
+
2. **Home/End:** `useInput` 里 `key.home` / `key.end`(若 Ink 类型有)调用 `moveToLineStart` / `moveToLineEnd`。raw stdin `scanHomeEndKeys` 与 delete 同通道,避免漏终端。
|
|
634
|
+
3. **↑↓:** 菜单/参数菜单打开时逻辑不变。否则:
|
|
635
|
+
|
|
636
|
+
```ts
|
|
637
|
+
const maxW = inputTextWidth(columns)
|
|
638
|
+
if (key.upArrow) {
|
|
639
|
+
const next = moveCaretVertical(value, caret, -1, maxW)
|
|
640
|
+
if (next !== caret) { applyEdit(() => ({ value, caretIndex: next })); return }
|
|
641
|
+
browseTo(1)
|
|
642
|
+
return
|
|
643
|
+
}
|
|
644
|
+
if (key.downArrow) {
|
|
645
|
+
const next = moveCaretVertical(value, caret, 1, maxW)
|
|
646
|
+
if (next !== caret) { applyEdit(() => ({ value, caretIndex: next })); return }
|
|
647
|
+
browseTo(-1)
|
|
648
|
+
return
|
|
649
|
+
}
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
用 `editingRef.current` 的 value/caret,不要闭包过期 state。
|
|
653
|
+
|
|
654
|
+
4. **点菜单:** 在现有 SGR press 处理里,若 `menuOpen`/`argMenuOpen`:
|
|
655
|
+
|
|
656
|
+
选项行的终端行号(1-based)= `inputTopRow + rows.length + hintRows + 3 + i`
|
|
657
|
+
(+3 = margin 1 + border 1 + header 1,与 `menuRows = n + 5` 的构成一致:margin+2borders+header+footer)
|
|
658
|
+
|
|
659
|
+
`argMenuOpen` 同样:header 一行。若点中第 i 项:命令菜单 `acceptCommand(matches[i])`,参数菜单 `acceptArg(argOptions[i])`。点在输入内容行:保持定位光标。点在菜单 chrome(边框/标题)忽略。
|
|
660
|
+
|
|
661
|
+
`acceptCommand` / `acceptArg` 必须稳定(ref),因为 raw listener 订阅一次。
|
|
662
|
+
|
|
663
|
+
- [ ] **Step 3:** `npx tsc --noEmit`
|
|
664
|
+
|
|
665
|
+
- [ ] **Step 4: 提交** `feat: 输入历史持久化与编辑键、点击菜单`
|
|
666
|
+
|
|
667
|
+
---
|
|
668
|
+
|
|
669
|
+
## Task 10: MessageList 复制去前缀、双击三击、Ctrl+B
|
|
670
|
+
|
|
671
|
+
**Files:**
|
|
672
|
+
- Modify: `src/tui/MessageList.tsx` `src/tui/types.ts` `src/tui/App.tsx` `src/tui/index.tsx` `src/tui/StatusBar.tsx` `src/tui/InputBar.tsx`
|
|
673
|
+
|
|
674
|
+
- [ ] **Step 1:** 纯函数已在 Task 5–6。本 task 接线。
|
|
675
|
+
|
|
676
|
+
- [ ] **Step 2: 去前缀复制**
|
|
677
|
+
|
|
678
|
+
构建 `areaRows` 时同时构建平行数组 `areaKinds: CopyKind[]`(与 `rows` 对齐,null 行用 `"plain"`)。根据 `messageLines` 的 role:user 第一条 `"user-head"` 其余 `"plain"`;tool 第一条 `"tool-head"`,展开后续 `"tool-body"`;其它 `"plain"`。
|
|
679
|
+
|
|
680
|
+
`finalizeCopy` 对每一段 `extractText` 后再 `stripCopyDecorations(text, kind)`。空文本:`onCopyNotice("未选中文本")`(现状)。`writeClipboard` false:`onCopyNotice("复制失败")`(现状;spec:失败不要「已复制」——现状文案已是「复制失败」,保留)。
|
|
681
|
+
|
|
682
|
+
抽出 `textFromSelection(rows, kinds, sel) => string` 便于选择模式复用。
|
|
683
|
+
|
|
684
|
+
- [ ] **Step 3: 双击 / 三击**
|
|
685
|
+
|
|
686
|
+
`noteClick` 在 `outcome.type === "click"` 时调用(`at: Date.now()`,row/col 用事件)。
|
|
687
|
+
|
|
688
|
+
- count 1:现有 `handleClick(row)`(工具展开)
|
|
689
|
+
- count 2:对该 `areaRows[rowIndex]` 剥 ANSI,`wordRangeAt`;设 `selection` 为该行该列范围(终端坐标);立刻 `finalizeCopy`
|
|
690
|
+
- count 3:找到该 row 所属 message 的 visible 行起止,设 selection 为整段,`finalizeCopy`
|
|
691
|
+
|
|
692
|
+
需要 `area` 映射 row → message index。可在现有 `buildArea`/`slice` 里带上。三击选「当前可见的全部行」= 该消息在 `areaRows` 里非 null 的连续行。
|
|
693
|
+
|
|
694
|
+
- [ ] **Step 4: Ctrl+B 选择模式**
|
|
695
|
+
|
|
696
|
+
`TuiState.selectionMode?: boolean`。`TuiRenderer.setSelectionMode(on: boolean)`。
|
|
697
|
+
|
|
698
|
+
`App` `useInput`:
|
|
699
|
+
|
|
700
|
+
```ts
|
|
701
|
+
if (key.ctrl && input === "b") {
|
|
702
|
+
if (confirm || question || modelPicker || sessionPicker) return
|
|
703
|
+
if (initialState.isRunning) return
|
|
704
|
+
onToggleSelectionMode()
|
|
705
|
+
return
|
|
706
|
+
}
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
新增 callback `onToggleSelectionMode`:`tui.setSelectionMode(!state.selectionMode)`。进入时 MessageList 需要起点:用 ref 记下上次 click 的 cell;没有则第一条非空 `areaRows` 的 `{row: index+1, col: 2}`(paddingLeft 后第一字符,与现有 toCell 一致)。
|
|
710
|
+
|
|
711
|
+
`MessageList` props:`selectionMode: boolean`、`onExitSelectionMode(copy: boolean)`。
|
|
712
|
+
|
|
713
|
+
当 `selectionMode`:
|
|
714
|
+
|
|
715
|
+
- `InputBar` `disabled={isRunning || selectionMode}`,placeholder 可仍「输入消息」
|
|
716
|
+
- `useInput` 在 MessageList:**不要**一按键就 `clearDrag`+清 selection(现状会清掉选区)。`selectionMode` 为真时跳过这段 clear。
|
|
717
|
+
- 方向键:只改 `selection.cur`(若 selection 为 null 则用起点作为 anchor 和 cur)
|
|
718
|
+
- Home/End:cur.col 到 1 或行宽
|
|
719
|
+
- Enter:`finalizeCopy` + `onExitSelectionMode` 但 **保留** selection 高亮(spec:与拖拽相同,保持到下次点击)——即 `setSelectionMode(false)` 不清 selection
|
|
720
|
+
- Esc:`setSelection(null)` + 退出模式,不复制
|
|
721
|
+
- 滚轮:现状清 selection;spec:选择模式保持、夹紧 cur。滚动处理里若 `selectionMode` 则不要 `setSelection(null)`,只更新 window 并把 cur.row clamp 到可见区
|
|
722
|
+
|
|
723
|
+
StatusBar:`selectionMode` 且无 `copyNotice` 时,分隔线右侧(或取代 notice)显示 `选择模式 ↑↓←→扩选 Enter复制 Esc退出`。过长则 `truncate-end`。
|
|
724
|
+
|
|
725
|
+
Ctrl+C:`onExit` 现有逻辑;`setSelectionMode(false)` 在 `requestExit` 里做。
|
|
726
|
+
|
|
727
|
+
InputBar 里 `if (key.ctrl || key.meta) return` 会吞掉 Ctrl+B。App 的 `useInput` 与 InputBar 都会收到。Ink 里后挂载的子组件可能先处理。为避免 InputBar 抢:在 InputBar **最前面** `if (key.ctrl && input === "b") return` 让事件继续?Ink 不冒泡。必须在 **App 和 InputBar 都处理**:InputBar 若 disabled(selectionMode)则 `useInput` 可设 `{ isActive: !disabled }`。进入选择模式后 InputBar disabled,App 处理方向键……但方向键在 MessageList。
|
|
728
|
+
|
|
729
|
+
进入前 Ctrl+B:InputBar 未 disabled。在 InputBar `useInput` 开头:
|
|
730
|
+
|
|
731
|
+
```ts
|
|
732
|
+
if (key.ctrl && input === "b") {
|
|
733
|
+
onSelectionMode?.()
|
|
734
|
+
return
|
|
735
|
+
}
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
`onSelectionMode` 从 App 传入,调用 `onToggleSelectionMode`。这样不依赖 App 抢先。
|
|
739
|
+
|
|
740
|
+
- [ ] **Step 5:** `npx tsc --noEmit` 且 `bun test tests/selection.test.ts tests/click-count.test.ts`
|
|
741
|
+
|
|
742
|
+
- [ ] **Step 6: 提交** `feat: 消息区双击三击、去前缀复制与键盘选择`
|
|
743
|
+
|
|
744
|
+
---
|
|
745
|
+
|
|
746
|
+
## Task 11: 子 agent 工具行
|
|
747
|
+
|
|
748
|
+
**Files:**
|
|
749
|
+
- Modify: `src/tools/task.ts`
|
|
750
|
+
- Modify: `src/tui/types.ts` `src/tui/index.tsx` `src/tui/MessageList.tsx` `src/tui/agent-runner.ts`
|
|
751
|
+
- Test: `tests/task-subagent.test.ts`(回调是否在有 sink 时不写 stderr —— 可抽 `shouldLogSubAgent(sink)` 过重;改为测 `format` 不强制。最小:给 `runSubAgent` 导出测试用的「记录 tool-call 到数组」若难测 streamText 则只测类型与 `appendToolChild` 纯合并:
|
|
752
|
+
|
|
753
|
+
```ts
|
|
754
|
+
export function appendChild(
|
|
755
|
+
children: NonNullable<TuiMessage["toolChildren"]>,
|
|
756
|
+
child: NonNullable<TuiMessage["toolChildren"]>[number],
|
|
757
|
+
replaceLastSameName = false,
|
|
758
|
+
): ...
|
|
759
|
+
```
|
|
760
|
+
|
|
761
|
+
不必过度设计。实现回调 + UI;用 bun test 测一个纯 `mergeToolChild(messages, toolCallId, child)`。)
|
|
762
|
+
|
|
763
|
+
- [ ] **Step 1: `mergeToolChild` 测试**
|
|
764
|
+
|
|
765
|
+
```ts
|
|
766
|
+
test("mergeToolChild appends to the matching tool row", () => {
|
|
767
|
+
const msgs: TuiMessage[] = [
|
|
768
|
+
{ id: "tool-abc", role: "tool", content: "task", toolName: "task", toolCallId: "abc", timestamp: 0 },
|
|
769
|
+
]
|
|
770
|
+
const out = mergeToolChild(msgs, "abc", { name: "grep", summary: "foo" })
|
|
771
|
+
expect(out[0]!.toolChildren).toEqual([{ name: "grep", summary: "foo" }])
|
|
772
|
+
})
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
放 `src/tui/tool-children.ts`。
|
|
776
|
+
|
|
777
|
+
- [ ] **Step 2: `task.ts`**
|
|
778
|
+
|
|
779
|
+
`createTaskTool(..., onUsage?, hooks?: { onSubToolCall, onSubToolResult })`。`runSubAgent` 在 `tool-call` / `tool-result` 事件调 hooks;`if (!hooks)` 才 `console.error` 现有进度。
|
|
780
|
+
|
|
781
|
+
`execute` 第二参取 `toolCallId`(AI SDK `ToolExecutionOptions`)。agent-runner 不能直接传 per-call hooks 进 `createTools`。用模块级 sink:
|
|
782
|
+
|
|
783
|
+
```ts
|
|
784
|
+
let subSink: { parentId: string; onCall: ...; onResult: ... } | null = null
|
|
785
|
+
export function setSubAgentSink(sink: typeof subSink) { subSink = sink }
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
**更干净、符合增量:** 在 `RunOptions` 加可选 `onSubAgent?: { onToolCall, onToolResult }` 太宽。采用与 `setTuiConfirm` 相同的:
|
|
789
|
+
|
|
790
|
+
```ts
|
|
791
|
+
// task.ts
|
|
792
|
+
type SubListener = (event: { parentToolCallId?: string; name: string; input?: unknown; output?: unknown; isError?: boolean; phase: "call" | "result" }) => void
|
|
793
|
+
let listener: SubListener | null = null
|
|
794
|
+
export function setSubAgentListener(fn: SubListener | null) { listener = fn }
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
`execute(args, options)` 里 `const parentId = options?.toolCallId`。事件带 `parentToolCallId`。
|
|
798
|
+
|
|
799
|
+
agent-runner `run()` 开始:`setSubAgentListener((ev) => { if (ev.phase === "call") tui.appendToolChild(...) })`;`finally` 里 `setSubAgentListener(null)`。
|
|
800
|
+
|
|
801
|
+
`TuiRenderer.appendToolChild(parentToolCallId, child)`:`this.state.messages = mergeToolChild(...)` 然后 rerender。
|
|
802
|
+
|
|
803
|
+
- [ ] **Step 3: MessageList `messageLines` tool 展开**
|
|
804
|
+
|
|
805
|
+
若 `msg.toolChildren?.length`:展开时在 call 行之后、result 之前插入:
|
|
806
|
+
|
|
807
|
+
```
|
|
808
|
+
↳ grep foo
|
|
809
|
+
result preview
|
|
810
|
+
```
|
|
811
|
+
|
|
812
|
+
用 `wrapByWidth`。未展开不展示 children。`rowCounts` 自动随 `messageLines` 变。
|
|
813
|
+
|
|
814
|
+
- [ ] **Step 4:** `npx tsc --noEmit`;`bun test tests/tool-children.test.ts`(或 hydrate 旁)
|
|
815
|
+
|
|
816
|
+
- [ ] **Step 5: 提交** `feat: 子任务工具行展开显示内部调用`
|
|
817
|
+
|
|
818
|
+
---
|
|
819
|
+
|
|
820
|
+
## Task 12: 文档
|
|
821
|
+
|
|
822
|
+
**Files:**
|
|
823
|
+
- Modify: `packages/min-agent/README.md`
|
|
824
|
+
- Modify: `src/tui/slash-handler.ts` `/help`(若 Task 7–8 已改则补快捷键)
|
|
825
|
+
- Modify: `packages/min-agent/AGENTS.md`
|
|
826
|
+
|
|
827
|
+
- [ ] **Step 1:**
|
|
828
|
+
|
|
829
|
+
README 交互命令表:
|
|
830
|
+
|
|
831
|
+
- `/sessions [id]` 列出或切换会话
|
|
832
|
+
- `/resume [id]` 同 `/sessions`
|
|
833
|
+
- `/reload` 重新加载规则
|
|
834
|
+
|
|
835
|
+
快捷键段(Ctrl+C 旁):
|
|
836
|
+
|
|
837
|
+
- **Ctrl+B** 消息区选择模式(方向键扩选,Enter 复制,Esc 退出)
|
|
838
|
+
- **Home / End** 行首 / 行尾
|
|
839
|
+
- **鼠标** 双击选词、三击选段;拖拽选择松手复制(复制时不含 `>` / 工具前缀)
|
|
840
|
+
|
|
841
|
+
Configuration 区补一行:输入历史 `~/.min-agent/input-history.json`。
|
|
842
|
+
|
|
843
|
+
AGENTS.md 配置列表加:`~/.min-agent/input-history.json` 全局输入历史(上限 1000)。
|
|
844
|
+
|
|
845
|
+
用户可见文案不要写 TUI、hydrate、Ink。
|
|
846
|
+
|
|
847
|
+
- [ ] **Step 2: 全量**
|
|
848
|
+
|
|
849
|
+
Run: `npx tsc --noEmit` 且 `bun test`(`packages/min-agent`)且仓库根 `bun run typecheck`
|
|
850
|
+
|
|
851
|
+
Expected: PASS
|
|
852
|
+
|
|
853
|
+
- [ ] **Step 3: 提交** `docs: 会话切换、选择模式与输入历史说明`
|
|
854
|
+
|
|
855
|
+
---
|
|
856
|
+
|
|
857
|
+
## 实现注意事项
|
|
858
|
+
|
|
859
|
+
- 不要 import `cli.ts` 做测试。
|
|
860
|
+
- `messages` 必须就地 splice/push,不能换新数组引用(runner 闭包持有原数组)。
|
|
861
|
+
- `hydrate` 之后 `sysMsg` 会再插一条,不要把系统提示写进 hydrate。
|
|
862
|
+
- `--resume` 画历史发生在 `tui.start()` 之后,否则 `update` 没有 inkInstance。
|
|
863
|
+
- InputBar 与 MessageList 都开了 SGR;选择模式不要重复 enable/disable 鼠标。
|
|
864
|
+
- `task` 的 sink 必须在 `run()` finally 清掉,避免 exec 路径吃到 TUI listener。
|
|
865
|
+
- 用户可见字符串用中文,与现有 `/help` 一致。
|
|
866
|
+
|
|
867
|
+
---
|
|
868
|
+
|
|
869
|
+
## 自检
|
|
870
|
+
|
|
871
|
+
- 每项有 FAIL 测试或明确「无单测、靠 tsc」
|
|
872
|
+
- 任务按依赖排序:纯函数 → runner/reload/hydrate 启动 → picker → InputBar → MessageList → task → docs
|
|
873
|
+
- 未包含第 2–5 批范围
|