min-agent 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +146 -18
- package/dist/agent.js +293 -408
- package/dist/assistant-stream.js +11 -7
- package/dist/cli.js +403 -140
- package/dist/clipboard.js +59 -23
- package/dist/code-mode.js +3 -3
- package/dist/compaction.js +182 -81
- package/dist/config.js +186 -35
- package/dist/confirm.js +55 -6
- package/dist/context-window.js +67 -54
- package/dist/doom-loop.js +19 -12
- package/dist/http.js +119 -0
- package/dist/instructions.js +51 -33
- package/dist/logger.js +66 -0
- package/dist/markdown.js +3 -44
- package/dist/mcp.js +547 -100
- package/dist/memory.js +48 -6
- package/dist/output.js +36 -27
- package/dist/paste-handler.js +3 -3
- package/dist/plugins.js +33 -6
- package/dist/pricing.js +119 -0
- package/dist/provider.js +17 -15
- package/dist/serve.js +658 -369
- package/dist/sessions.js +151 -13
- package/dist/skills.js +466 -76
- package/dist/synthetic.js +7 -0
- package/dist/title-gen.js +2 -1
- package/dist/tool-display.js +173 -0
- package/dist/tool-output.js +54 -45
- package/dist/tools/apply_patch.js +191 -0
- package/dist/tools/backend.js +61 -0
- package/dist/tools/bash.js +147 -70
- package/dist/tools/code_search.js +6 -5
- package/dist/tools/edit.js +23 -7
- package/dist/tools/explore.js +80 -12
- package/dist/tools/glob.js +3 -3
- package/dist/tools/grep.js +146 -14
- package/dist/tools/index.js +7 -7
- package/dist/tools/question.js +4 -22
- package/dist/tools/read.js +71 -11
- package/dist/tools/task.js +33 -20
- package/dist/tools/todo.js +83 -73
- package/dist/tools/web_fetch.js +150 -46
- package/dist/tools/web_search.js +706 -28
- package/dist/tools/write.js +13 -7
- package/dist/tui/App.js +40 -6
- package/dist/tui/ConfirmBar.js +24 -3
- package/dist/tui/InputBar.js +390 -45
- package/dist/tui/MessageList.js +533 -20
- package/dist/tui/ModelPicker.js +108 -0
- package/dist/tui/QuestionBar.js +104 -0
- package/dist/tui/StatusBar.js +19 -11
- package/dist/tui/agent-runner.js +103 -0
- package/dist/tui/caret-pos.js +134 -0
- package/dist/tui/caret.js +69 -0
- package/dist/tui/diff-view.js +61 -0
- package/dist/tui/drag-state.js +44 -0
- package/dist/tui/index.js +153 -24
- package/dist/tui/input-history.js +44 -0
- package/dist/tui/layout.js +17 -0
- package/dist/tui/mouse.js +46 -0
- package/dist/tui/selection.js +134 -0
- package/dist/tui/slash-commands.js +90 -0
- package/dist/tui/slash-handler.js +370 -0
- package/dist/tui/text-width.js +91 -0
- package/dist/tui/theme.js +12 -0
- package/dist/tui/undo-stack.js +14 -0
- package/dist/tui/use-sgr-mouse.js +27 -0
- package/dist/tui-chat.js +111 -331
- package/dist/updater.js +57 -0
- package/docs/API.md +160 -14
- package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
- package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
- package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
- package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
- package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
- package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
- package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
- package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
- package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
- package/package.json +7 -8
|
@@ -0,0 +1,1510 @@
|
|
|
1
|
+
# 第一批:TUI 交互体验改进 Implementation Plan
|
|
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:** 修复 `chat -i` 图片丢弃缺陷,补齐 TUI 输入历史、undo/redo、diff、会话管理、成本估算等交互体验。
|
|
6
|
+
|
|
7
|
+
**Architecture:** 交互逻辑抽为纯函数模块(`src/tui/input-history.ts`、`src/tui/undo-stack.ts`、`src/tui/diff-view.ts`、`src/pricing.ts`),TUI 层只做状态集成;图片加载抽为 `agent.ts` 的 `loadImageParts()`,CLI 单次模式与 TUI 共用。
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript, React 18 + Ink 5, bun:test
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md`
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 文件结构
|
|
16
|
+
|
|
17
|
+
| 文件 | 职责 |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `src/tui/input-history.ts`(新) | 输入历史纯函数 |
|
|
20
|
+
| `src/tui/undo-stack.ts`(新) | 回合记录与撤销纯函数 |
|
|
21
|
+
| `src/tui/diff-view.ts`(新) | git diff 提取纯函数 |
|
|
22
|
+
| `src/pricing.ts`(新) | 模型价格查询/缓存/成本估算 |
|
|
23
|
+
| `tests/input-history.test.ts`(新) | 输入历史测试 |
|
|
24
|
+
| `tests/undo-stack.test.ts`(新) | 撤销栈测试 |
|
|
25
|
+
| `tests/diff-view.test.ts`(新) | diff 提取测试 |
|
|
26
|
+
| `tests/pricing.test.ts`(新) | 价格/成本测试 |
|
|
27
|
+
| `tests/image-parts.test.ts`(新) | 图片加载测试 |
|
|
28
|
+
| `src/tui/InputBar.tsx`(改) | 输入历史集成 |
|
|
29
|
+
| `src/tui/index.tsx`(改) | TuiRenderer 加 `messageCount()` / `truncateMessages()` |
|
|
30
|
+
| `src/sessions.ts`(改) | `renameSession()` |
|
|
31
|
+
| `tests/sessions.test.ts`(改) | renameSession 测试 |
|
|
32
|
+
| `src/agent.ts`(改) | 提取 `loadImageParts()` |
|
|
33
|
+
| `src/tui-chat.ts`(改) | images 注入 + 新斜杠命令 + 成本显示 |
|
|
34
|
+
| `src/cli.ts`(改) | chat 分支透传 images |
|
|
35
|
+
| `src/config.ts`(改) | `AppConfig.pricing` 字段 |
|
|
36
|
+
| `src/tui/slash-commands.ts`(改) | 菜单新命令 |
|
|
37
|
+
| `README.md`(改) | 命令表同步 |
|
|
38
|
+
|
|
39
|
+
测试命令(`packages/min-agent` 下):`bun test tests/<file>`、全量 `bun test`、类型 `npx tsc --noEmit`。
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Task 1: input-history.ts — 输入历史纯函数
|
|
44
|
+
|
|
45
|
+
**Files:**
|
|
46
|
+
- Create: `src/tui/input-history.ts`
|
|
47
|
+
- Test: `tests/input-history.test.ts`
|
|
48
|
+
|
|
49
|
+
- [ ] **Step 1: 写失败测试**
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { expect, test } from "bun:test"
|
|
53
|
+
import { createHistory, pushInput, browseHistory, resetHistory } from "../src/tui/input-history.js"
|
|
54
|
+
|
|
55
|
+
test("createHistory starts empty", () => {
|
|
56
|
+
expect(createHistory()).toEqual({ entries: [], index: -1, draft: "" })
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test("pushInput appends submitted text", () => {
|
|
60
|
+
const h = pushInput(pushInput(createHistory(), "a"), "b")
|
|
61
|
+
expect(h.entries).toEqual(["a", "b"])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test("pushInput skips empty text", () => {
|
|
65
|
+
expect(pushInput(createHistory(), "")).toEqual(createHistory())
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test("pushInput deduplicates consecutive identical entries", () => {
|
|
69
|
+
const h = pushInput(pushInput(createHistory(), "a"), "a")
|
|
70
|
+
expect(h.entries).toEqual(["a"])
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test("browseHistory up enters history with the most recent entry", () => {
|
|
74
|
+
const h = pushInput(pushInput(createHistory(), "first"), "second")
|
|
75
|
+
expect(browseHistory(h, 1)).toEqual({
|
|
76
|
+
text: "second",
|
|
77
|
+
state: { entries: ["first", "second"], index: 1, draft: "" },
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test("browseHistory up walks to older entries and clamps", () => {
|
|
82
|
+
const h = pushInput(pushInput(createHistory(), "first"), "second")
|
|
83
|
+
const older = browseHistory(browseHistory(h, 1).state, 1)
|
|
84
|
+
expect(older.text).toBe("first")
|
|
85
|
+
const clamped = browseHistory(older.state, 1)
|
|
86
|
+
expect(clamped.text).toBe("first")
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test("browseHistory down returns to the draft after the newest entry", () => {
|
|
90
|
+
const h = pushInput(pushInput(createHistory(), "first"), "second")
|
|
91
|
+
const entered = browseHistory(h, 1).state
|
|
92
|
+
const draft = browseHistory(entered, -1)
|
|
93
|
+
expect(draft.text).toBe("")
|
|
94
|
+
expect(draft.state.index).toBe(-1)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test("browseHistory down at the draft is a no-op", () => {
|
|
98
|
+
const h = pushInput(createHistory(), "a")
|
|
99
|
+
expect(browseHistory(h, -1)).toEqual({ text: "", state: h })
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test("browseHistory with empty history returns the draft", () => {
|
|
103
|
+
const h = createHistory()
|
|
104
|
+
expect(browseHistory(h, 1)).toEqual({ text: "", state: h })
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test("browseHistory keeps the draft while browsing", () => {
|
|
108
|
+
const h = { entries: ["a"], index: -1, draft: "draft" }
|
|
109
|
+
const r = browseHistory(h, 1)
|
|
110
|
+
expect(r.text).toBe("a")
|
|
111
|
+
expect(r.state.draft).toBe("draft")
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test("resetHistory restores the draft and leaves history", () => {
|
|
115
|
+
const h = { entries: ["a"], index: 0, draft: "draft" }
|
|
116
|
+
expect(resetHistory(h, "edited")).toEqual({ entries: ["a"], index: -1, draft: "edited" })
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test("resetHistory is a no-op at the draft with the same text", () => {
|
|
120
|
+
const h = { entries: ["a"], index: -1, draft: "draft" }
|
|
121
|
+
expect(resetHistory(h, "draft")).toBe(h)
|
|
122
|
+
})
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
- [ ] **Step 2: 运行测试确认失败**
|
|
126
|
+
|
|
127
|
+
Run: `bun test tests/input-history.test.ts`
|
|
128
|
+
Expected: FAIL — `Cannot find module "../src/tui/input-history.js"`
|
|
129
|
+
|
|
130
|
+
- [ ] **Step 3: 实现 input-history.ts**
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
export interface HistoryState {
|
|
134
|
+
entries: string[]
|
|
135
|
+
index: number
|
|
136
|
+
draft: string
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function createHistory(): HistoryState {
|
|
140
|
+
return { entries: [], index: -1, draft: "" }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function pushInput(state: HistoryState, text: string): HistoryState {
|
|
144
|
+
if (!text) return state
|
|
145
|
+
if (state.entries.length > 0 && state.entries[state.entries.length - 1] === text) return state
|
|
146
|
+
return { entries: [...state.entries, text], index: -1, draft: "" }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function browseHistory(state: HistoryState, direction: 1 | -1): { text: string; state: HistoryState } {
|
|
150
|
+
const count = state.entries.length
|
|
151
|
+
if (count === 0) return { text: state.draft, state }
|
|
152
|
+
if (direction === 1) {
|
|
153
|
+
const index = state.index === -1 ? count - 1 : Math.max(0, state.index - 1)
|
|
154
|
+
return { text: state.entries[index]!, state: { ...state, index } }
|
|
155
|
+
}
|
|
156
|
+
if (state.index === -1) return { text: state.draft, state }
|
|
157
|
+
if (state.index >= count - 1) return { text: state.draft, state: { ...state, index: -1 } }
|
|
158
|
+
const index = state.index + 1
|
|
159
|
+
return { text: state.entries[index]!, state: { ...state, index } }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function resetHistory(state: HistoryState, text: string): HistoryState {
|
|
163
|
+
if (state.index === -1 && state.draft === text) return state
|
|
164
|
+
return { entries: state.entries, index: -1, draft: text }
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
- [ ] **Step 4: 运行测试确认通过**
|
|
169
|
+
|
|
170
|
+
Run: `bun test tests/input-history.test.ts`
|
|
171
|
+
Expected: PASS (12 个用例)
|
|
172
|
+
|
|
173
|
+
- [ ] **Step 5: 提交**
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
git add src/tui/input-history.ts tests/input-history.test.ts
|
|
177
|
+
git commit -m "feat: 输入历史纯函数模块"
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## Task 2: undo-stack.ts — 回合撤销栈纯函数
|
|
183
|
+
|
|
184
|
+
**Files:**
|
|
185
|
+
- Create: `src/tui/undo-stack.ts`
|
|
186
|
+
- Test: `tests/undo-stack.test.ts`
|
|
187
|
+
|
|
188
|
+
- [ ] **Step 1: 写失败测试**
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
import { expect, test } from "bun:test"
|
|
192
|
+
import { createUndoStack, pushTurn, popTurn, clearStack } from "../src/tui/undo-stack.js"
|
|
193
|
+
|
|
194
|
+
test("createUndoStack starts empty", () => {
|
|
195
|
+
expect(createUndoStack()).toEqual([])
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test("pushTurn appends a turn", () => {
|
|
199
|
+
const stack = pushTurn(createUndoStack(), 0, 3, 5)
|
|
200
|
+
expect(stack).toEqual([{ start: 0, end: 3, uiEnd: 5 }])
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test("popTurn returns the last turn and a shrunk stack", () => {
|
|
204
|
+
let stack = pushTurn(pushTurn(createUndoStack(), 0, 3, 5), 3, 7, 10)
|
|
205
|
+
const last = popTurn(stack)
|
|
206
|
+
expect(last).toEqual({ turn: { start: 3, end: 7, uiEnd: 10 }, stack: [{ start: 0, end: 3, uiEnd: 5 }] })
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test("popTurn on an empty stack returns null", () => {
|
|
210
|
+
expect(popTurn(createUndoStack())).toBeNull()
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test("clearStack empties the stack", () => {
|
|
214
|
+
const stack = pushTurn(createUndoStack(), 0, 3, 5)
|
|
215
|
+
expect(clearStack(stack)).toEqual([])
|
|
216
|
+
})
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
- [ ] **Step 2: 运行测试确认失败**
|
|
220
|
+
|
|
221
|
+
Run: `bun test tests/undo-stack.test.ts`
|
|
222
|
+
Expected: FAIL — module not found
|
|
223
|
+
|
|
224
|
+
- [ ] **Step 3: 实现 undo-stack.ts**
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
export interface TurnRecord {
|
|
228
|
+
start: number
|
|
229
|
+
end: number
|
|
230
|
+
uiEnd: number
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function createUndoStack(): TurnRecord[] {
|
|
234
|
+
return []
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function pushTurn(stack: TurnRecord[], start: number, end: number, uiEnd: number): TurnRecord[] {
|
|
238
|
+
return [...stack, { start, end, uiEnd }]
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function popTurn(stack: TurnRecord[]): { turn: TurnRecord; stack: TurnRecord[] } | null {
|
|
242
|
+
if (stack.length === 0) return null
|
|
243
|
+
return { turn: stack[stack.length - 1]!, stack: stack.slice(0, -1) }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function clearStack(stack: TurnRecord[]): TurnRecord[] {
|
|
247
|
+
return []
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
- [ ] **Step 4: 运行测试确认通过**
|
|
252
|
+
|
|
253
|
+
Run: `bun test tests/undo-stack.test.ts`
|
|
254
|
+
Expected: PASS (5 个用例)
|
|
255
|
+
|
|
256
|
+
- [ ] **Step 5: 提交**
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
git add src/tui/undo-stack.ts tests/undo-stack.test.ts
|
|
260
|
+
git commit -m "feat: 回合撤销栈纯函数模块"
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## Task 3: diff-view.ts — 工作区 diff 提取
|
|
266
|
+
|
|
267
|
+
**Files:**
|
|
268
|
+
- Create: `src/tui/diff-view.ts`
|
|
269
|
+
- Test: `tests/diff-view.test.ts`
|
|
270
|
+
|
|
271
|
+
- [ ] **Step 1: 写失败测试**
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
import { expect, test } from "bun:test"
|
|
275
|
+
import { getWorkingTreeDiff } from "../src/tui/diff-view.js"
|
|
276
|
+
|
|
277
|
+
function runStub(commands: Record<string, string>): (cmd: string, cwd: string) => string {
|
|
278
|
+
return (cmd) => {
|
|
279
|
+
const out = commands[cmd]
|
|
280
|
+
if (out === undefined) throw new Error(`not stubbed: ${cmd}`)
|
|
281
|
+
return out
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
test("returns the diff with stat for a git repo with changes", () => {
|
|
286
|
+
const run = runStub({
|
|
287
|
+
"git rev-parse --is-inside-work-tree": "true",
|
|
288
|
+
"git diff --stat": " src/a.ts | 2 +-\n 1 file changed",
|
|
289
|
+
"git diff": "diff --git a/src/a.ts b/src/a.ts\n+line\n",
|
|
290
|
+
})
|
|
291
|
+
const result = getWorkingTreeDiff("/tmp/x", run)
|
|
292
|
+
expect(result).toEqual({
|
|
293
|
+
ok: true,
|
|
294
|
+
diff: " src/a.ts | 2 +-\n 1 file changed\n\ndiff --git a/src/a.ts b/src/a.ts\n+line\n",
|
|
295
|
+
})
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
test("returns not_git when rev-parse fails", () => {
|
|
299
|
+
const result = getWorkingTreeDiff("/tmp/x", runStub({}))
|
|
300
|
+
expect(result).toEqual({ ok: false, reason: "not_git", message: "当前目录不是 git 仓库" })
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
test("returns no_changes for an empty diff", () => {
|
|
304
|
+
const run = runStub({
|
|
305
|
+
"git rev-parse --is-inside-work-tree": "true",
|
|
306
|
+
"git diff --stat": "",
|
|
307
|
+
"git diff": "",
|
|
308
|
+
})
|
|
309
|
+
const result = getWorkingTreeDiff("/tmp/x", run)
|
|
310
|
+
expect(result).toEqual({ ok: false, reason: "no_changes", message: "工作区无未提交变更" })
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
test("truncates very long diffs", () => {
|
|
314
|
+
const run = runStub({
|
|
315
|
+
"git rev-parse --is-inside-work-tree": "true",
|
|
316
|
+
"git diff --stat": " 1 file changed",
|
|
317
|
+
"git diff": "x".repeat(9000),
|
|
318
|
+
})
|
|
319
|
+
const result = getWorkingTreeDiff("/tmp/x", run)
|
|
320
|
+
if (result.ok) expect(result.diff.length).toBeLessThan(8200)
|
|
321
|
+
})
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
- [ ] **Step 2: 运行测试确认失败**
|
|
325
|
+
|
|
326
|
+
Run: `bun test tests/diff-view.test.ts`
|
|
327
|
+
Expected: FAIL — module not found
|
|
328
|
+
|
|
329
|
+
- [ ] **Step 3: 实现 diff-view.ts**
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
import { execSync } from "child_process"
|
|
333
|
+
|
|
334
|
+
export type DiffResult =
|
|
335
|
+
| { ok: true; diff: string }
|
|
336
|
+
| { ok: false; reason: "not_git" | "no_changes" | "error"; message: string }
|
|
337
|
+
|
|
338
|
+
const MAX_DIFF_CHARS = 8000
|
|
339
|
+
|
|
340
|
+
export function getWorkingTreeDiff(
|
|
341
|
+
cwd: string = process.cwd(),
|
|
342
|
+
run: (cmd: string, cwd: string) => string = defaultRun,
|
|
343
|
+
): DiffResult {
|
|
344
|
+
try {
|
|
345
|
+
run("git rev-parse --is-inside-work-tree", cwd)
|
|
346
|
+
} catch {
|
|
347
|
+
return { ok: false, reason: "not_git", message: "当前目录不是 git 仓库" }
|
|
348
|
+
}
|
|
349
|
+
let stat = ""
|
|
350
|
+
let full = ""
|
|
351
|
+
try {
|
|
352
|
+
stat = run("git diff --stat", cwd).trim()
|
|
353
|
+
full = run("git diff", cwd)
|
|
354
|
+
} catch (err: any) {
|
|
355
|
+
return { ok: false, reason: "error", message: err?.message ?? String(err) }
|
|
356
|
+
}
|
|
357
|
+
if (!full.trim()) return { ok: false, reason: "no_changes", message: "工作区无未提交变更" }
|
|
358
|
+
const body = full.length > MAX_DIFF_CHARS ? full.slice(0, MAX_DIFF_CHARS) + "\n… [diff 过长,已截断]" : full
|
|
359
|
+
return { ok: true, diff: stat ? `${stat}\n\n${body}` : body }
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function defaultRun(cmd: string, cwd: string): string {
|
|
363
|
+
return execSync(cmd, { cwd, encoding: "utf-8", timeout: 10000, stdio: "pipe" })
|
|
364
|
+
}
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
- [ ] **Step 4: 运行测试确认通过**
|
|
368
|
+
|
|
369
|
+
Run: `bun test tests/diff-view.test.ts`
|
|
370
|
+
Expected: PASS (4 个用例)
|
|
371
|
+
|
|
372
|
+
- [ ] **Step 5: 提交**
|
|
373
|
+
|
|
374
|
+
```bash
|
|
375
|
+
git add src/tui/diff-view.ts tests/diff-view.test.ts
|
|
376
|
+
git commit -m "feat: 工作区 diff 提取模块"
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
---
|
|
380
|
+
|
|
381
|
+
## Task 4: pricing.ts + config.pricing — 成本估算
|
|
382
|
+
|
|
383
|
+
**Files:**
|
|
384
|
+
- Create: `src/pricing.ts`
|
|
385
|
+
- Modify: `src/config.ts`(AppConfig 加 pricing 字段)
|
|
386
|
+
- Test: `tests/pricing.test.ts`
|
|
387
|
+
|
|
388
|
+
- [ ] **Step 1: 写失败测试**
|
|
389
|
+
|
|
390
|
+
```ts
|
|
391
|
+
import { expect, test } from "bun:test"
|
|
392
|
+
import { mkdtempSync, rmSync, realpathSync, writeFileSync } from "fs"
|
|
393
|
+
import os from "os"
|
|
394
|
+
import path from "path"
|
|
395
|
+
import { estimateCost, formatCost, getModelPrice } from "../src/pricing.js"
|
|
396
|
+
|
|
397
|
+
const modelsDev = {
|
|
398
|
+
provider1: {
|
|
399
|
+
models: {
|
|
400
|
+
"my-model": { pricing: { input: 2.5, output: 10 } },
|
|
401
|
+
"other-model": { pricing: { input: 1, output: 2 } },
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function makeTemp(): string {
|
|
407
|
+
return realpathSync(mkdtempSync(path.join(os.tmpdir(), "min-agent-pricing-")))
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
test("estimateCost computes USD from tokens and price", () => {
|
|
411
|
+
const price = { inputPerMillion: 2.5, outputPerMillion: 10 }
|
|
412
|
+
expect(estimateCost({ inputTokens: 1_000_000, outputTokens: 500_000 }, price)).toBe(7.5)
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
test("estimateCost returns null without a price", () => {
|
|
416
|
+
expect(estimateCost({ inputTokens: 100, outputTokens: 50 }, null)).toBeNull()
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
test("formatCost renders USD or --", () => {
|
|
420
|
+
expect(formatCost(0.01234)).toBe("$0.0123")
|
|
421
|
+
expect(formatCost(0.12345)).toBe("$0.123")
|
|
422
|
+
expect(formatCost(null)).toBe("--")
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
test("getModelPrice fetches from models.dev and caches", async () => {
|
|
426
|
+
const base = makeTemp()
|
|
427
|
+
const oldDir = process.env.MIN_AGENT_CONFIG_DIR
|
|
428
|
+
process.env.MIN_AGENT_CONFIG_DIR = base
|
|
429
|
+
const calls: string[] = []
|
|
430
|
+
const originalFetch = globalThis.fetch
|
|
431
|
+
globalThis.fetch = (async (url: any) => {
|
|
432
|
+
calls.push(String(url))
|
|
433
|
+
return new Response(JSON.stringify(modelsDev), { status: 200 })
|
|
434
|
+
}) as typeof fetch
|
|
435
|
+
try {
|
|
436
|
+
const first = await getModelPrice("my-model")
|
|
437
|
+
expect(first).toEqual({ inputPerMillion: 2.5, outputPerMillion: 10 })
|
|
438
|
+
const second = await getModelPrice("my-model")
|
|
439
|
+
expect(second).toEqual(first)
|
|
440
|
+
expect(calls.length).toBe(1)
|
|
441
|
+
} finally {
|
|
442
|
+
globalThis.fetch = originalFetch
|
|
443
|
+
if (oldDir) process.env.MIN_AGENT_CONFIG_DIR = oldDir
|
|
444
|
+
else delete process.env.MIN_AGENT_CONFIG_DIR
|
|
445
|
+
rmSync(base, { recursive: true, force: true })
|
|
446
|
+
}
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
test("getModelPrice returns null when models.dev has no match", async () => {
|
|
450
|
+
const base = makeTemp()
|
|
451
|
+
const oldDir = process.env.MIN_AGENT_CONFIG_DIR
|
|
452
|
+
process.env.MIN_AGENT_CONFIG_DIR = base
|
|
453
|
+
const originalFetch = globalThis.fetch
|
|
454
|
+
globalThis.fetch = (async () => new Response(JSON.stringify(modelsDev), { status: 200 })) as typeof fetch
|
|
455
|
+
try {
|
|
456
|
+
expect(await getModelPrice("no-such-model")).toBeNull()
|
|
457
|
+
} finally {
|
|
458
|
+
globalThis.fetch = originalFetch
|
|
459
|
+
if (oldDir) process.env.MIN_AGENT_CONFIG_DIR = oldDir
|
|
460
|
+
else delete process.env.MIN_AGENT_CONFIG_DIR
|
|
461
|
+
rmSync(base, { recursive: true, force: true })
|
|
462
|
+
}
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
test("getModelPrice prefers the config override without fetching", async () => {
|
|
466
|
+
const base = makeTemp()
|
|
467
|
+
const oldDir = process.env.MIN_AGENT_CONFIG_DIR
|
|
468
|
+
process.env.MIN_AGENT_CONFIG_DIR = base
|
|
469
|
+
writeFileSync(
|
|
470
|
+
path.join(base, "config.json"),
|
|
471
|
+
JSON.stringify({ pricing: { "my-model": { inputPerMillion: 99, outputPerMillion: 199 } } }),
|
|
472
|
+
)
|
|
473
|
+
let fetched = false
|
|
474
|
+
const originalFetch = globalThis.fetch
|
|
475
|
+
globalThis.fetch = (async () => {
|
|
476
|
+
fetched = true
|
|
477
|
+
return new Response(JSON.stringify(modelsDev), { status: 200 })
|
|
478
|
+
}) as typeof fetch
|
|
479
|
+
try {
|
|
480
|
+
const price = await getModelPrice("my-model")
|
|
481
|
+
expect(price).toEqual({ inputPerMillion: 99, outputPerMillion: 199 })
|
|
482
|
+
expect(fetched).toBe(false)
|
|
483
|
+
} finally {
|
|
484
|
+
globalThis.fetch = originalFetch
|
|
485
|
+
if (oldDir) process.env.MIN_AGENT_CONFIG_DIR = oldDir
|
|
486
|
+
else delete process.env.MIN_AGENT_CONFIG_DIR
|
|
487
|
+
rmSync(base, { recursive: true, force: true })
|
|
488
|
+
}
|
|
489
|
+
})
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
- [ ] **Step 2: 运行测试确认失败**
|
|
493
|
+
|
|
494
|
+
Run: `bun test tests/pricing.test.ts`
|
|
495
|
+
Expected: FAIL — module not found
|
|
496
|
+
|
|
497
|
+
- [ ] **Step 3: config.ts 加 pricing 字段**
|
|
498
|
+
|
|
499
|
+
在 `src/config.ts` 的 `AppConfig` 接口末尾(`webFetchURL` 之后)添加:
|
|
500
|
+
|
|
501
|
+
```ts
|
|
502
|
+
/** Per-model price overrides (USD per 1M tokens), used for cost estimation */
|
|
503
|
+
pricing?: Record<string, { inputPerMillion: number; outputPerMillion: number }>
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
- [ ] **Step 4: 实现 pricing.ts**
|
|
507
|
+
|
|
508
|
+
```ts
|
|
509
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs"
|
|
510
|
+
import path from "path"
|
|
511
|
+
import { getConfigDir, loadConfig } from "./config.js"
|
|
512
|
+
|
|
513
|
+
export interface ModelPrice {
|
|
514
|
+
inputPerMillion: number
|
|
515
|
+
outputPerMillion: number
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const CACHE_TTL = 7 * 24 * 60 * 60 * 1000
|
|
519
|
+
|
|
520
|
+
interface CacheEntry {
|
|
521
|
+
price: ModelPrice
|
|
522
|
+
timestamp: number
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
type Cache = Record<string, CacheEntry>
|
|
526
|
+
|
|
527
|
+
const memoryCache = new Map<string, CacheEntry>()
|
|
528
|
+
const inFlight = new Map<string, Promise<ModelPrice | null>>()
|
|
529
|
+
|
|
530
|
+
function cacheFile(): string {
|
|
531
|
+
return path.join(getConfigDir(), "pricing-cache.json")
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function loadDiskCache(): Cache {
|
|
535
|
+
const file = cacheFile()
|
|
536
|
+
if (!existsSync(file)) return {}
|
|
537
|
+
try {
|
|
538
|
+
return JSON.parse(readFileSync(file, "utf-8"))
|
|
539
|
+
} catch {
|
|
540
|
+
return {}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function saveCache(cache: Cache) {
|
|
545
|
+
const file = cacheFile()
|
|
546
|
+
mkdirSync(path.dirname(file), { recursive: true })
|
|
547
|
+
writeFileSync(file, JSON.stringify(cache), "utf-8")
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function getCached(modelId: string): CacheEntry | null {
|
|
551
|
+
const memory = memoryCache.get(modelId)
|
|
552
|
+
if (memory && Date.now() - memory.timestamp <= CACHE_TTL) return memory
|
|
553
|
+
const disk = loadDiskCache()[modelId]
|
|
554
|
+
if (disk && Date.now() - disk.timestamp <= CACHE_TTL) {
|
|
555
|
+
memoryCache.set(modelId, disk)
|
|
556
|
+
return disk
|
|
557
|
+
}
|
|
558
|
+
return null
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function setCache(modelId: string, price: ModelPrice) {
|
|
562
|
+
const entry = { price, timestamp: Date.now() }
|
|
563
|
+
memoryCache.set(modelId, entry)
|
|
564
|
+
const cache = loadDiskCache()
|
|
565
|
+
cache[modelId] = entry
|
|
566
|
+
saveCache(cache)
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function priceFromConfig(modelId: string): ModelPrice | null {
|
|
570
|
+
const override = loadConfig().pricing?.[modelId]
|
|
571
|
+
if (!override) return null
|
|
572
|
+
if (typeof override.inputPerMillion !== "number" || typeof override.outputPerMillion !== "number") return null
|
|
573
|
+
return { inputPerMillion: override.inputPerMillion, outputPerMillion: override.outputPerMillion }
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async function fetchFromModelsDev(modelId: string): Promise<ModelPrice | null> {
|
|
577
|
+
try {
|
|
578
|
+
const response = await fetch("https://models.dev/api.json", { signal: AbortSignal.timeout(10000) })
|
|
579
|
+
if (!response.ok) return null
|
|
580
|
+
const providers = (await response.json()) as Record<string, any>
|
|
581
|
+
for (const provider of Object.values(providers)) {
|
|
582
|
+
if (!provider.models) continue
|
|
583
|
+
for (const [id, model] of Object.entries<any>(provider.models)) {
|
|
584
|
+
if (id === modelId || id.endsWith(`/${modelId}`) || modelId.endsWith(`/${id}`)) {
|
|
585
|
+
const p = model?.pricing
|
|
586
|
+
if (p && typeof p.input === "number" && typeof p.output === "number") {
|
|
587
|
+
return { inputPerMillion: p.input, outputPerMillion: p.output }
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return null
|
|
593
|
+
} catch {
|
|
594
|
+
return null
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
export async function getModelPrice(modelId: string): Promise<ModelPrice | null> {
|
|
599
|
+
const override = priceFromConfig(modelId)
|
|
600
|
+
if (override) return override
|
|
601
|
+
const cached = getCached(modelId)
|
|
602
|
+
if (cached) return cached.price
|
|
603
|
+
const pending = inFlight.get(modelId)
|
|
604
|
+
if (pending) return pending
|
|
605
|
+
const probing = fetchFromModelsDev(modelId).then((price) => {
|
|
606
|
+
if (price) setCache(modelId, price)
|
|
607
|
+
return price
|
|
608
|
+
})
|
|
609
|
+
inFlight.set(modelId, probing)
|
|
610
|
+
try {
|
|
611
|
+
return await probing
|
|
612
|
+
} finally {
|
|
613
|
+
inFlight.delete(modelId)
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
export function estimateCost(
|
|
618
|
+
usage: { inputTokens: number; outputTokens: number },
|
|
619
|
+
price: ModelPrice | null,
|
|
620
|
+
): number | null {
|
|
621
|
+
if (!price) return null
|
|
622
|
+
const cost = (usage.inputTokens / 1_000_000) * price.inputPerMillion +
|
|
623
|
+
(usage.outputTokens / 1_000_000) * price.outputPerMillion
|
|
624
|
+
return cost > 0 ? cost : null
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
export function formatCost(cost: number | null): string {
|
|
628
|
+
if (cost === null) return "--"
|
|
629
|
+
return cost >= 0.01 ? `$${cost.toFixed(3)}` : `$${cost.toFixed(4)}`
|
|
630
|
+
}
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
- [ ] **Step 5: 运行测试确认通过**
|
|
634
|
+
|
|
635
|
+
Run: `bun test tests/pricing.test.ts`
|
|
636
|
+
Expected: PASS (6 个用例)
|
|
637
|
+
|
|
638
|
+
- [ ] **Step 6: 提交**
|
|
639
|
+
|
|
640
|
+
```bash
|
|
641
|
+
git add src/pricing.ts src/config.ts tests/pricing.test.ts
|
|
642
|
+
git commit -m "feat: 模型价格查询与成本估算"
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
---
|
|
646
|
+
|
|
647
|
+
## Task 5: TuiRenderer — messageCount / truncateMessages
|
|
648
|
+
|
|
649
|
+
**Files:**
|
|
650
|
+
- Modify: `src/tui/index.tsx`
|
|
651
|
+
|
|
652
|
+
- [ ] **Step 1: 实现**
|
|
653
|
+
|
|
654
|
+
在 `src/tui/index.tsx` 的 `addMessage` 方法之后添加:
|
|
655
|
+
|
|
656
|
+
```ts
|
|
657
|
+
/** Number of messages currently rendered (used by /undo). */
|
|
658
|
+
messageCount() {
|
|
659
|
+
return this.state.messages.length
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** Keep only the first `count` messages (used by /undo). */
|
|
663
|
+
truncateMessages(count: number) {
|
|
664
|
+
this.state.messages = this.state.messages.slice(0, count)
|
|
665
|
+
this.update({})
|
|
666
|
+
}
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
- [ ] **Step 2: 类型检查**
|
|
670
|
+
|
|
671
|
+
Run: `npx tsc --noEmit`
|
|
672
|
+
Expected: 无错误
|
|
673
|
+
|
|
674
|
+
- [ ] **Step 3: 提交**
|
|
675
|
+
|
|
676
|
+
```bash
|
|
677
|
+
git add src/tui/index.tsx
|
|
678
|
+
git commit -m "feat: TuiRenderer 支持消息截断"
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
---
|
|
682
|
+
|
|
683
|
+
## Task 6: sessions.ts — renameSession
|
|
684
|
+
|
|
685
|
+
**Files:**
|
|
686
|
+
- Modify: `src/sessions.ts`
|
|
687
|
+
- Test: `tests/sessions.test.ts`
|
|
688
|
+
|
|
689
|
+
- [ ] **Step 1: 写失败测试**
|
|
690
|
+
|
|
691
|
+
在 `tests/sessions.test.ts` 末尾追加:
|
|
692
|
+
|
|
693
|
+
```ts
|
|
694
|
+
test("renameSession updates the title", () => {
|
|
695
|
+
const id = saveSession([{ role: "user", content: "hi" }])
|
|
696
|
+
expect(renameSession(id, "new title")).toBe(true)
|
|
697
|
+
expect(loadSession(id)!.meta.title).toBe("new title")
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
test("renameSession returns false for a missing session", () => {
|
|
701
|
+
expect(renameSession("no-such-id", "x")).toBe(false)
|
|
702
|
+
})
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
并更新 import 行:
|
|
706
|
+
|
|
707
|
+
```ts
|
|
708
|
+
import { saveSession, loadSession, listSessions, deleteSession, renameSession } from "../src/sessions.js"
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
- [ ] **Step 2: 运行测试确认失败**
|
|
712
|
+
|
|
713
|
+
Run: `bun test tests/sessions.test.ts`
|
|
714
|
+
Expected: FAIL — `renameSession is not a function`
|
|
715
|
+
|
|
716
|
+
- [ ] **Step 3: 实现 renameSession**
|
|
717
|
+
|
|
718
|
+
在 `src/sessions.ts` 的 `deleteSession` 之后添加:
|
|
719
|
+
|
|
720
|
+
```ts
|
|
721
|
+
export function renameSession(id: string, title: string): boolean {
|
|
722
|
+
const file = sessionPath(id)
|
|
723
|
+
if (!existsSync(file)) return false
|
|
724
|
+
try {
|
|
725
|
+
const data = JSON.parse(readFileSync(file, "utf-8")) as SessionData
|
|
726
|
+
data.meta.title = title
|
|
727
|
+
data.meta.updated = new Date().toISOString()
|
|
728
|
+
writeFileSync(file, JSON.stringify(data, null, 2), "utf-8")
|
|
729
|
+
return true
|
|
730
|
+
} catch {
|
|
731
|
+
return false
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
- [ ] **Step 4: 运行测试确认通过**
|
|
737
|
+
|
|
738
|
+
Run: `bun test tests/sessions.test.ts`
|
|
739
|
+
Expected: PASS(原有用例 + 2 个新用例)
|
|
740
|
+
|
|
741
|
+
- [ ] **Step 5: 提交**
|
|
742
|
+
|
|
743
|
+
```bash
|
|
744
|
+
git add src/sessions.ts tests/sessions.test.ts
|
|
745
|
+
git commit -m "feat: 会话重命名"
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
---
|
|
749
|
+
|
|
750
|
+
## Task 7: agent.ts — 提取 loadImageParts
|
|
751
|
+
|
|
752
|
+
**Files:**
|
|
753
|
+
- Modify: `src/agent.ts`(`buildUserContent` 重构,第 74-110 行)
|
|
754
|
+
- Test: `tests/image-parts.test.ts`
|
|
755
|
+
|
|
756
|
+
- [ ] **Step 1: 写失败测试**
|
|
757
|
+
|
|
758
|
+
```ts
|
|
759
|
+
import { expect, test } from "bun:test"
|
|
760
|
+
import { mkdtempSync, writeFileSync, rmSync, realpathSync } from "fs"
|
|
761
|
+
import os from "os"
|
|
762
|
+
import path from "path"
|
|
763
|
+
import { loadImageParts } from "../src/agent.js"
|
|
764
|
+
|
|
765
|
+
function makeTemp(): string {
|
|
766
|
+
return realpathSync(mkdtempSync(path.join(os.tmpdir(), "min-agent-images-")))
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
test("loadImageParts returns image parts for existing files", () => {
|
|
770
|
+
const base = makeTemp()
|
|
771
|
+
try {
|
|
772
|
+
writeFileSync(path.join(base, "a.png"), "fake-png")
|
|
773
|
+
writeFileSync(path.join(base, "b.jpg"), "fake-jpg")
|
|
774
|
+
const parts = loadImageParts(["a.png", "b.jpg"], () => {})
|
|
775
|
+
expect(parts.length).toBe(2)
|
|
776
|
+
expect(parts[0]).toEqual({ type: "image", image: Buffer.from("fake-png"), mimeType: "image/png" })
|
|
777
|
+
expect(parts[1]).toEqual({ type: "image", image: Buffer.from("fake-jpg"), mimeType: "image/jpeg" })
|
|
778
|
+
} finally {
|
|
779
|
+
rmSync(base, { recursive: true, force: true })
|
|
780
|
+
}
|
|
781
|
+
})
|
|
782
|
+
|
|
783
|
+
test("loadImageParts resolves relative to cwd", () => {
|
|
784
|
+
const base = makeTemp()
|
|
785
|
+
try {
|
|
786
|
+
writeFileSync(path.join(base, "pic.gif"), "gif")
|
|
787
|
+
const parts = loadImageParts(["pic.gif"], () => {})
|
|
788
|
+
expect(parts.length).toBe(0)
|
|
789
|
+
const oldCwd = process.cwd()
|
|
790
|
+
process.chdir(base)
|
|
791
|
+
try {
|
|
792
|
+
const inCwd = loadImageParts(["pic.gif"], () => {})
|
|
793
|
+
expect(inCwd.length).toBe(1)
|
|
794
|
+
expect(inCwd[0]).toEqual({ type: "image", image: Buffer.from("gif"), mimeType: "image/gif" })
|
|
795
|
+
} finally {
|
|
796
|
+
process.chdir(oldCwd)
|
|
797
|
+
}
|
|
798
|
+
} finally {
|
|
799
|
+
rmSync(base, { recursive: true, force: true })
|
|
800
|
+
}
|
|
801
|
+
})
|
|
802
|
+
|
|
803
|
+
test("loadImageParts notifies not_found and skips", () => {
|
|
804
|
+
const base = makeTemp()
|
|
805
|
+
const events: string[] = []
|
|
806
|
+
try {
|
|
807
|
+
const parts = loadImageParts(["missing.png"], (kind, p) => events.push(`${kind}:${p}`))
|
|
808
|
+
expect(parts.length).toBe(0)
|
|
809
|
+
expect(events).toEqual(["not_found:missing.png"])
|
|
810
|
+
} finally {
|
|
811
|
+
rmSync(base, { recursive: true, force: true })
|
|
812
|
+
}
|
|
813
|
+
})
|
|
814
|
+
|
|
815
|
+
test("loadImageParts notifies too_large and skips", () => {
|
|
816
|
+
const base = makeTemp()
|
|
817
|
+
const events: string[] = []
|
|
818
|
+
try {
|
|
819
|
+
writeFileSync(path.join(base, "big.png"), Buffer.alloc(5 * 1024 * 1024 + 1))
|
|
820
|
+
const parts = loadImageParts(["big.png"], (kind, p) => events.push(`${kind}:${p}`))
|
|
821
|
+
expect(parts.length).toBe(0)
|
|
822
|
+
expect(events).toEqual(["too_large:big.png"])
|
|
823
|
+
} finally {
|
|
824
|
+
rmSync(base, { recursive: true, force: true })
|
|
825
|
+
}
|
|
826
|
+
})
|
|
827
|
+
|
|
828
|
+
test("loadImageParts notifies attached for each loaded image", () => {
|
|
829
|
+
const base = makeTemp()
|
|
830
|
+
const events: string[] = []
|
|
831
|
+
try {
|
|
832
|
+
writeFileSync(path.join(base, "ok.png"), "x")
|
|
833
|
+
const parts = loadImageParts(["ok.png"], (kind, p) => events.push(`${kind}:${p}`))
|
|
834
|
+
expect(parts.length).toBe(1)
|
|
835
|
+
expect(events).toEqual(["attached:ok.png"])
|
|
836
|
+
} finally {
|
|
837
|
+
rmSync(base, { recursive: true, force: true })
|
|
838
|
+
}
|
|
839
|
+
})
|
|
840
|
+
```
|
|
841
|
+
|
|
842
|
+
- [ ] **Step 2: 运行测试确认失败**
|
|
843
|
+
|
|
844
|
+
Run: `bun test tests/image-parts.test.ts`
|
|
845
|
+
Expected: FAIL — `loadImageParts is not a function`
|
|
846
|
+
|
|
847
|
+
- [ ] **Step 3: 重构 agent.ts**
|
|
848
|
+
|
|
849
|
+
替换 `src/agent.ts` 第 74-110 行(`buildUserContent` 整个函数)为:
|
|
850
|
+
|
|
851
|
+
```ts
|
|
852
|
+
export type ImageNotify = (kind: "attached" | "not_found" | "too_large", imgPath: string, sizeBytes?: number) => void
|
|
853
|
+
|
|
854
|
+
export function loadImageParts(
|
|
855
|
+
imagePaths: string[],
|
|
856
|
+
notify?: ImageNotify,
|
|
857
|
+
): Array<{ type: "image"; image: Buffer; mimeType: string }> {
|
|
858
|
+
const parts: Array<{ type: "image"; image: Buffer; mimeType: string }> = []
|
|
859
|
+
for (const imgPath of imagePaths) {
|
|
860
|
+
const resolved = path.resolve(process.cwd(), imgPath)
|
|
861
|
+
if (!existsSync(resolved)) {
|
|
862
|
+
notify?.("not_found", imgPath)
|
|
863
|
+
continue
|
|
864
|
+
}
|
|
865
|
+
const size = statSync(resolved).size
|
|
866
|
+
if (size > MAX_IMAGE_BYTES) {
|
|
867
|
+
notify?.("too_large", imgPath, size)
|
|
868
|
+
continue
|
|
869
|
+
}
|
|
870
|
+
const data = readFileSync(resolved)
|
|
871
|
+
const ext = path.extname(resolved).toLowerCase()
|
|
872
|
+
const mimeMap: Record<string, string> = {
|
|
873
|
+
".png": "image/png",
|
|
874
|
+
".jpg": "image/jpeg",
|
|
875
|
+
".jpeg": "image/jpeg",
|
|
876
|
+
".gif": "image/gif",
|
|
877
|
+
".webp": "image/webp",
|
|
878
|
+
}
|
|
879
|
+
parts.push({ type: "image", image: data, mimeType: mimeMap[ext] ?? "image/png" })
|
|
880
|
+
notify?.("attached", imgPath)
|
|
881
|
+
}
|
|
882
|
+
return parts
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/** Build user message content, optionally with images */
|
|
886
|
+
export async function buildUserContent(message: string, imagePaths?: string[]): Promise<any> {
|
|
887
|
+
if (!imagePaths || imagePaths.length === 0) return message
|
|
888
|
+
const images = loadImageParts(imagePaths, (kind, imgPath, sizeBytes) => {
|
|
889
|
+
if (kind === "not_found") {
|
|
890
|
+
console.error(`\x1b[33m Warning: Image not found: ${imgPath}\x1b[0m`)
|
|
891
|
+
} else if (kind === "too_large") {
|
|
892
|
+
console.error(
|
|
893
|
+
`\x1b[33m Warning: Image skipped (${((sizeBytes ?? 0) / 1024 / 1024).toFixed(1)} MB exceeds ${MAX_IMAGE_BYTES / 1024 / 1024} MB limit): ${imgPath}\x1b[0m`,
|
|
894
|
+
)
|
|
895
|
+
} else {
|
|
896
|
+
console.log(`\x1b[90m 📎 ${imgPath}\x1b[0m`)
|
|
897
|
+
}
|
|
898
|
+
})
|
|
899
|
+
return [{ type: "text", text: message }, ...images]
|
|
900
|
+
}
|
|
901
|
+
```
|
|
902
|
+
|
|
903
|
+
- [ ] **Step 4: 运行测试确认通过**
|
|
904
|
+
|
|
905
|
+
Run: `bun test tests/image-parts.test.ts`
|
|
906
|
+
Expected: PASS (5 个用例)
|
|
907
|
+
|
|
908
|
+
同时运行 `bun test`(确保 buildUserContent 重构没破坏现有用例)。
|
|
909
|
+
|
|
910
|
+
- [ ] **Step 5: 提交**
|
|
911
|
+
|
|
912
|
+
```bash
|
|
913
|
+
git add src/agent.ts tests/image-parts.test.ts
|
|
914
|
+
git commit -m "refactor: 提取图片加载函数供 TUI 复用"
|
|
915
|
+
```
|
|
916
|
+
|
|
917
|
+
---
|
|
918
|
+
|
|
919
|
+
## Task 8: InputBar.tsx — 输入历史集成
|
|
920
|
+
|
|
921
|
+
**Files:**
|
|
922
|
+
- Modify: `src/tui/InputBar.tsx`
|
|
923
|
+
|
|
924
|
+
- [ ] **Step 1: 修改 import 行(第 5 行附近)**
|
|
925
|
+
|
|
926
|
+
```ts
|
|
927
|
+
import { createHistory, pushInput, browseHistory, resetHistory, type HistoryState } from "./input-history.js"
|
|
928
|
+
```
|
|
929
|
+
|
|
930
|
+
- [ ] **Step 2: 添加历史状态(第 80 行 `const width = columns || 80` 之后)**
|
|
931
|
+
|
|
932
|
+
```ts
|
|
933
|
+
const [historyState, setHistoryState] = useState<HistoryState>(() => createHistory())
|
|
934
|
+
const historyRef = useRef(historyState)
|
|
935
|
+
```
|
|
936
|
+
|
|
937
|
+
- [ ] **Step 3: applyEdit 内重置历史(第 87-94 行)**
|
|
938
|
+
|
|
939
|
+
```ts
|
|
940
|
+
const applyEdit = useCallback(
|
|
941
|
+
(edit: (value: string, caretIndex: number) => { value: string; caretIndex: number }) => {
|
|
942
|
+
const after = edit(editingRef.current.value, editingRef.current.caretIndex)
|
|
943
|
+
editingRef.current = after
|
|
944
|
+
setEditing(after)
|
|
945
|
+
const nextHistory = resetHistory(historyRef.current, after.value)
|
|
946
|
+
if (nextHistory !== historyRef.current) {
|
|
947
|
+
historyRef.current = nextHistory
|
|
948
|
+
setHistoryState(nextHistory)
|
|
949
|
+
}
|
|
950
|
+
},
|
|
951
|
+
[],
|
|
952
|
+
)
|
|
953
|
+
```
|
|
954
|
+
|
|
955
|
+
- [ ] **Step 4: 添加提交与浏览函数(applyEdit 之后)**
|
|
956
|
+
|
|
957
|
+
```ts
|
|
958
|
+
const submit = (text: string) => {
|
|
959
|
+
historyRef.current = pushInput(historyRef.current, text)
|
|
960
|
+
setHistoryState(historyRef.current)
|
|
961
|
+
onSubmit(text)
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
const browseTo = (direction: 1 | -1) => {
|
|
965
|
+
const { text, state } = browseHistory(historyRef.current, direction)
|
|
966
|
+
historyRef.current = state
|
|
967
|
+
setHistoryState(state)
|
|
968
|
+
if (text !== editingRef.current.value) {
|
|
969
|
+
const after = { value: text, caretIndex: Array.from(text).length }
|
|
970
|
+
editingRef.current = after
|
|
971
|
+
setEditing(after)
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
```
|
|
975
|
+
|
|
976
|
+
- [ ] **Step 5: acceptCommand 改用 submit(第 111 行)**
|
|
977
|
+
|
|
978
|
+
```ts
|
|
979
|
+
submit(`/${cmd.name}`)
|
|
980
|
+
```
|
|
981
|
+
|
|
982
|
+
- [ ] **Step 6: Enter 提交改用 submit(第 166-168 行)**
|
|
983
|
+
|
|
984
|
+
```ts
|
|
985
|
+
const text = v.trim()
|
|
986
|
+
if (text) submit(text)
|
|
987
|
+
applyEdit(() => ({ value: "", caretIndex: 0 }))
|
|
988
|
+
return
|
|
989
|
+
```
|
|
990
|
+
|
|
991
|
+
- [ ] **Step 7: 添加历史导航(Ctrl+J 分支之前,即 menuOpen 块之后)**
|
|
992
|
+
|
|
993
|
+
```ts
|
|
994
|
+
// Input history: ↑ older, ↓ newer (only when the slash menu is closed)
|
|
995
|
+
if (key.upArrow) {
|
|
996
|
+
browseTo(1)
|
|
997
|
+
return
|
|
998
|
+
}
|
|
999
|
+
if (key.downArrow) {
|
|
1000
|
+
browseTo(-1)
|
|
1001
|
+
return
|
|
1002
|
+
}
|
|
1003
|
+
```
|
|
1004
|
+
|
|
1005
|
+
- [ ] **Step 8: 验证**
|
|
1006
|
+
|
|
1007
|
+
Run: `npx tsc --noEmit` 和 `bun test`
|
|
1008
|
+
Expected: 无类型错误,全部测试通过
|
|
1009
|
+
|
|
1010
|
+
- [ ] **Step 9: 提交**
|
|
1011
|
+
|
|
1012
|
+
```bash
|
|
1013
|
+
git add src/tui/InputBar.tsx
|
|
1014
|
+
git commit -m "feat: 输入框历史记录导航"
|
|
1015
|
+
```
|
|
1016
|
+
|
|
1017
|
+
---
|
|
1018
|
+
|
|
1019
|
+
## Task 9: tui-chat.ts — 图片注入 + 新斜杠命令 + 成本显示
|
|
1020
|
+
|
|
1021
|
+
**Files:**
|
|
1022
|
+
- Modify: `src/tui-chat.ts`
|
|
1023
|
+
|
|
1024
|
+
本任务涉及同一文件的多处修改,按顺序执行。
|
|
1025
|
+
|
|
1026
|
+
- [ ] **Step 1: 修改 import(文件头)**
|
|
1027
|
+
|
|
1028
|
+
替换:
|
|
1029
|
+
|
|
1030
|
+
```ts
|
|
1031
|
+
import { runOnce, runOnceWithSystem, type RunOnceCallbacks } from "./agent.js"
|
|
1032
|
+
```
|
|
1033
|
+
|
|
1034
|
+
为:
|
|
1035
|
+
|
|
1036
|
+
```ts
|
|
1037
|
+
import { runOnce, runOnceWithSystem, loadImageParts, type RunOnceCallbacks } from "./agent.js"
|
|
1038
|
+
import { getWorkingTreeDiff } from "./tui/diff-view.js"
|
|
1039
|
+
import { createUndoStack, pushTurn, popTurn, clearStack, type TurnRecord } from "./tui/undo-stack.js"
|
|
1040
|
+
import { getModelPrice, estimateCost, formatCost } from "./pricing.js"
|
|
1041
|
+
```
|
|
1042
|
+
|
|
1043
|
+
- [ ] **Step 2: TuiOptions 加 images 字段**
|
|
1044
|
+
|
|
1045
|
+
```ts
|
|
1046
|
+
export interface TuiOptions {
|
|
1047
|
+
modelId?: string
|
|
1048
|
+
resumeSessionId?: string
|
|
1049
|
+
mode: "chat" | "code"
|
|
1050
|
+
images?: string[]
|
|
1051
|
+
}
|
|
1052
|
+
```
|
|
1053
|
+
|
|
1054
|
+
`runTui` 解构处改为 `const { modelId, resumeSessionId, mode, images } = opts`。
|
|
1055
|
+
|
|
1056
|
+
- [ ] **Step 3: 添加状态变量(第 45-48 行 messages/sessionId/currentModel/tracker 之后)**
|
|
1057
|
+
|
|
1058
|
+
```ts
|
|
1059
|
+
let undoStack = createUndoStack()
|
|
1060
|
+
let lastUserMessage: string | null = null
|
|
1061
|
+
```
|
|
1062
|
+
|
|
1063
|
+
- [ ] **Step 4: executeAgent 记录回合边界**
|
|
1064
|
+
|
|
1065
|
+
在 `executeAgent = async () => {` 开头(第 129 行 `abortController = new AbortController()` 之前)添加:
|
|
1066
|
+
|
|
1067
|
+
```ts
|
|
1068
|
+
const turnStart = messages.length
|
|
1069
|
+
const uiStart = tui.messageCount()
|
|
1070
|
+
```
|
|
1071
|
+
|
|
1072
|
+
在 `executeAgent` 的 `finally` 块(第 208-211 行)改为:
|
|
1073
|
+
|
|
1074
|
+
```ts
|
|
1075
|
+
} finally {
|
|
1076
|
+
undoStack = pushTurn(undoStack, turnStart, messages.length, uiStart)
|
|
1077
|
+
abortController = null
|
|
1078
|
+
tui.setRunning(false)
|
|
1079
|
+
}
|
|
1080
|
+
```
|
|
1081
|
+
|
|
1082
|
+
- [ ] **Step 5: onRunFinish 显示成本**
|
|
1083
|
+
|
|
1084
|
+
现有 onRunFinish(第 167-193 行)中 `tui.addMessage` 的 content 改为:
|
|
1085
|
+
|
|
1086
|
+
```ts
|
|
1087
|
+
const price = await getModelPrice(currentModel ?? "")
|
|
1088
|
+
const cost = formatCost(estimateCost({ inputTokens: input, outputTokens: output }, price))
|
|
1089
|
+
tui.addMessage({
|
|
1090
|
+
id: `done-${Date.now()}`,
|
|
1091
|
+
role: "system",
|
|
1092
|
+
content: `Done in ${info.stepCount} step(s) | Tokens: ${input} in / ${output} out / ${total} total | Context: ${pct}% | 约 ${cost}`,
|
|
1093
|
+
timestamp: Date.now(),
|
|
1094
|
+
})
|
|
1095
|
+
```
|
|
1096
|
+
|
|
1097
|
+
(`pct` 行保持原样)
|
|
1098
|
+
|
|
1099
|
+
- [ ] **Step 6: onSubmit 记录 lastUserMessage**
|
|
1100
|
+
|
|
1101
|
+
`onSubmit` 回调(第 65 行)中,斜杠命令处理之后、`tui.addMessage` 之前添加:
|
|
1102
|
+
|
|
1103
|
+
```ts
|
|
1104
|
+
lastUserMessage = text
|
|
1105
|
+
```
|
|
1106
|
+
|
|
1107
|
+
- [ ] **Step 7: 启动时注入图片(tui.start() 之后、exitPromise 之前)**
|
|
1108
|
+
|
|
1109
|
+
在 `tui.start()` 与 `process.on("SIGINT", requestExit)` 之间添加:
|
|
1110
|
+
|
|
1111
|
+
```ts
|
|
1112
|
+
if (images && images.length > 0) {
|
|
1113
|
+
const notify = (kind: "attached" | "not_found" | "too_large", imgPath: string, sizeBytes?: number) => {
|
|
1114
|
+
if (kind === "not_found") sysMsg(tui, `⚠ 图片不存在: ${imgPath}`)
|
|
1115
|
+
else if (kind === "too_large") sysMsg(tui, `⚠ 图片超过 ${5} MB 限制: ${imgPath}`)
|
|
1116
|
+
else sysMsg(tui, `📎 ${imgPath}`)
|
|
1117
|
+
}
|
|
1118
|
+
const parts: any[] = [{ type: "text", text: "" }, ...loadImageParts(images, notify)]
|
|
1119
|
+
if (parts.length > 1) {
|
|
1120
|
+
messages.push({ role: "user", content: parts })
|
|
1121
|
+
tui.addMessage({
|
|
1122
|
+
id: `user-${Date.now()}`,
|
|
1123
|
+
role: "user",
|
|
1124
|
+
content: `[📎 图片] ${images.join(", ")}`,
|
|
1125
|
+
timestamp: Date.now(),
|
|
1126
|
+
})
|
|
1127
|
+
await executeAgent()
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
```
|
|
1131
|
+
|
|
1132
|
+
注意:`executeAgent` 在其定义之后调用(tui.start() 在 executeAgent 定义之后执行,顺序安全)。
|
|
1133
|
+
|
|
1134
|
+
- [ ] **Step 8: 更新 handleSlashCommand 签名与调用**
|
|
1135
|
+
|
|
1136
|
+
调用处(第 67 行)改为:
|
|
1137
|
+
|
|
1138
|
+
```ts
|
|
1139
|
+
const result = await handleSlashCommand(
|
|
1140
|
+
text,
|
|
1141
|
+
tui,
|
|
1142
|
+
messages,
|
|
1143
|
+
currentModel,
|
|
1144
|
+
tracker,
|
|
1145
|
+
(model) => {
|
|
1146
|
+
currentModel = model
|
|
1147
|
+
tui.setModel(model)
|
|
1148
|
+
},
|
|
1149
|
+
requestExit,
|
|
1150
|
+
{
|
|
1151
|
+
undoStack,
|
|
1152
|
+
setUndoStack: (s) => { undoStack = s },
|
|
1153
|
+
lastUserMessage,
|
|
1154
|
+
setLastUserMessage: (t) => { lastUserMessage = t },
|
|
1155
|
+
sessionId,
|
|
1156
|
+
setSessionId: (id) => { sessionId = id },
|
|
1157
|
+
},
|
|
1158
|
+
)
|
|
1159
|
+
```
|
|
1160
|
+
|
|
1161
|
+
签名改为:
|
|
1162
|
+
|
|
1163
|
+
```ts
|
|
1164
|
+
interface SlashState {
|
|
1165
|
+
undoStack: TurnRecord[]
|
|
1166
|
+
setUndoStack: (s: TurnRecord[]) => void
|
|
1167
|
+
lastUserMessage: string | null
|
|
1168
|
+
setLastUserMessage: (t: string | null) => void
|
|
1169
|
+
sessionId: string | undefined
|
|
1170
|
+
setSessionId: (id: string | undefined) => void
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
async function handleSlashCommand(
|
|
1174
|
+
input: string,
|
|
1175
|
+
tui: TuiRenderer,
|
|
1176
|
+
messages: ModelMessage[],
|
|
1177
|
+
currentModel: string | undefined,
|
|
1178
|
+
tracker: TokenTracker,
|
|
1179
|
+
setModel: (model: string) => void,
|
|
1180
|
+
requestExit: () => void,
|
|
1181
|
+
state: SlashState,
|
|
1182
|
+
): Promise<"run" | void> {
|
|
1183
|
+
```
|
|
1184
|
+
|
|
1185
|
+
- [ ] **Step 9: 新增斜杠命令 case(在 `case "clear"` 之后添加)**
|
|
1186
|
+
|
|
1187
|
+
```ts
|
|
1188
|
+
case "undo": {
|
|
1189
|
+
const popped = popTurn(state.undoStack)
|
|
1190
|
+
if (!popped) {
|
|
1191
|
+
sysMsg(tui, "没有可撤销的内容")
|
|
1192
|
+
break
|
|
1193
|
+
}
|
|
1194
|
+
state.setUndoStack(popped.stack)
|
|
1195
|
+
messages.splice(popped.turn.start, popped.turn.end - popped.turn.start)
|
|
1196
|
+
tui.truncateMessages(popped.turn.uiEnd)
|
|
1197
|
+
sysMsg(tui, "✓ 已撤销 1 轮")
|
|
1198
|
+
break
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
case "redo": {
|
|
1202
|
+
if (!state.lastUserMessage) {
|
|
1203
|
+
sysMsg(tui, "没有可重发的消息")
|
|
1204
|
+
break
|
|
1205
|
+
}
|
|
1206
|
+
const text = state.lastUserMessage
|
|
1207
|
+
tui.addMessage({
|
|
1208
|
+
id: `user-${Date.now()}`,
|
|
1209
|
+
role: "user",
|
|
1210
|
+
content: text,
|
|
1211
|
+
timestamp: Date.now(),
|
|
1212
|
+
})
|
|
1213
|
+
messages.push({ role: "user", content: text })
|
|
1214
|
+
return "run"
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
case "diff": {
|
|
1218
|
+
const result = getWorkingTreeDiff()
|
|
1219
|
+
if (!result.ok) {
|
|
1220
|
+
const msg =
|
|
1221
|
+
result.reason === "not_git" ? "非 git 仓库,无法显示 diff"
|
|
1222
|
+
: result.reason === "no_changes" ? "工作区无未提交变更"
|
|
1223
|
+
: `diff 失败: ${result.message}`
|
|
1224
|
+
sysMsg(tui, msg)
|
|
1225
|
+
} else {
|
|
1226
|
+
sysMsg(tui, result.diff)
|
|
1227
|
+
}
|
|
1228
|
+
break
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
case "sessions": {
|
|
1232
|
+
const { listSessions } = await import("./sessions.js")
|
|
1233
|
+
const sessions = listSessions()
|
|
1234
|
+
if (sessions.length === 0) {
|
|
1235
|
+
sysMsg(tui, "无已保存的会话")
|
|
1236
|
+
} else {
|
|
1237
|
+
const lines = sessions.map((s) => {
|
|
1238
|
+
const current = state.sessionId === s.id ? " ← 当前" : ""
|
|
1239
|
+
return ` ${s.id} ${s.title} (${s.messageCount} msgs)${current}`
|
|
1240
|
+
})
|
|
1241
|
+
sysMsg(tui, `会话 (${sessions.length}):\n${lines.join("\n")}\n\n恢复: min-agent chat --resume <id>`)
|
|
1242
|
+
}
|
|
1243
|
+
break
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
case "rename": {
|
|
1247
|
+
const title = arg.trim()
|
|
1248
|
+
if (!title) {
|
|
1249
|
+
sysMsg(tui, "用法: /rename <标题>")
|
|
1250
|
+
break
|
|
1251
|
+
}
|
|
1252
|
+
const { saveSession, renameSession } = await import("./sessions.js")
|
|
1253
|
+
let id = state.sessionId
|
|
1254
|
+
if (!id) {
|
|
1255
|
+
id = saveSession(messages)
|
|
1256
|
+
state.setSessionId(id)
|
|
1257
|
+
}
|
|
1258
|
+
if (renameSession(id, title)) {
|
|
1259
|
+
sysMsg(tui, `✓ 会话已重命名为: ${title}`)
|
|
1260
|
+
} else {
|
|
1261
|
+
sysMsg(tui, "重命名失败: 会话不存在")
|
|
1262
|
+
}
|
|
1263
|
+
break
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
case "attach":
|
|
1267
|
+
case "image": {
|
|
1268
|
+
const filePath = arg
|
|
1269
|
+
if (!filePath) {
|
|
1270
|
+
sysMsg(tui, "用法: /attach <图片路径>")
|
|
1271
|
+
break
|
|
1272
|
+
}
|
|
1273
|
+
const notify = (kind: "attached" | "not_found" | "too_large", imgPath: string) => {
|
|
1274
|
+
if (kind === "not_found") sysMsg(tui, `⚠ 图片不存在: ${imgPath}`)
|
|
1275
|
+
else if (kind === "too_large") sysMsg(tui, `⚠ 图片超过 5MB 限制: ${imgPath}`)
|
|
1276
|
+
}
|
|
1277
|
+
const parts: any[] = [{ type: "text", text: "" }, ...loadImageParts([filePath], notify)]
|
|
1278
|
+
if (parts.length <= 1) break
|
|
1279
|
+
messages.push({ role: "user", content: parts })
|
|
1280
|
+
tui.addMessage({
|
|
1281
|
+
id: `user-${Date.now()}`,
|
|
1282
|
+
role: "user",
|
|
1283
|
+
content: `[📎 图片] ${filePath}`,
|
|
1284
|
+
timestamp: Date.now(),
|
|
1285
|
+
})
|
|
1286
|
+
return "run"
|
|
1287
|
+
}
|
|
1288
|
+
```
|
|
1289
|
+
|
|
1290
|
+
- [ ] **Step 10: `/clear` 清空 undo 栈**
|
|
1291
|
+
|
|
1292
|
+
`case "clear"` 内 `tui.update({ messages: [] })` 之后添加:
|
|
1293
|
+
|
|
1294
|
+
```ts
|
|
1295
|
+
state.setUndoStack(clearStack(state.undoStack))
|
|
1296
|
+
```
|
|
1297
|
+
|
|
1298
|
+
- [ ] **Step 11: `/tokens` 加成本行**
|
|
1299
|
+
|
|
1300
|
+
`case "tokens"` 的 `sysMsg` 数组改为:
|
|
1301
|
+
|
|
1302
|
+
```ts
|
|
1303
|
+
const price = await getModelPrice(currentModel ?? "")
|
|
1304
|
+
const cost = formatCost(
|
|
1305
|
+
estimateCost({ inputTokens: tracker.totalInputTokens, outputTokens: tracker.totalOutputTokens }, price),
|
|
1306
|
+
)
|
|
1307
|
+
sysMsg(tui, [
|
|
1308
|
+
`Context: ${tracker.lastInputTokens} / ${ctxWindow} tokens (${pct}%)`,
|
|
1309
|
+
`Total: ${tracker.totalInputTokens} in / ${tracker.totalOutputTokens} out`,
|
|
1310
|
+
`Cost: 约 ${cost}`,
|
|
1311
|
+
`Messages: ${messages.length}`,
|
|
1312
|
+
].join("\n"))
|
|
1313
|
+
```
|
|
1314
|
+
|
|
1315
|
+
- [ ] **Step 12: `/help` 同步(`case "help"` 的命令列表)**
|
|
1316
|
+
|
|
1317
|
+
```ts
|
|
1318
|
+
" /clear 清除对话历史",
|
|
1319
|
+
" /compact 压缩上下文",
|
|
1320
|
+
" /model [n] 交互式选择/切换模型",
|
|
1321
|
+
" /memory [t] 查看/保存记忆",
|
|
1322
|
+
" /tokens 显示 token 用量与成本",
|
|
1323
|
+
" /undo 撤销上一轮",
|
|
1324
|
+
" /redo 重发最后一条消息",
|
|
1325
|
+
" /diff 显示工作区未提交变更",
|
|
1326
|
+
" /sessions 列出已保存会话",
|
|
1327
|
+
" /rename <t> 重命名当前会话",
|
|
1328
|
+
" /attach <p> 附加本地图片",
|
|
1329
|
+
" /mcp 显示 MCP 状态",
|
|
1330
|
+
" /skills 显示可用 skills",
|
|
1331
|
+
" /path 显示工作目录",
|
|
1332
|
+
" /paste [t] 粘贴剪贴板图片 + 可选提示",
|
|
1333
|
+
" /help 显示帮助",
|
|
1334
|
+
" /exit 退出",
|
|
1335
|
+
```
|
|
1336
|
+
|
|
1337
|
+
- [ ] **Step 13: 验证**
|
|
1338
|
+
|
|
1339
|
+
Run: `npx tsc --noEmit` 和 `bun test`
|
|
1340
|
+
Expected: 无类型错误,全部测试通过
|
|
1341
|
+
|
|
1342
|
+
- [ ] **Step 14: 提交**
|
|
1343
|
+
|
|
1344
|
+
```bash
|
|
1345
|
+
git add src/tui-chat.ts
|
|
1346
|
+
git commit -m "feat: TUI 图片注入、undo/redo/diff/会话命令与成本显示"
|
|
1347
|
+
```
|
|
1348
|
+
|
|
1349
|
+
---
|
|
1350
|
+
|
|
1351
|
+
## Task 10: cli.ts — chat 分支透传 images
|
|
1352
|
+
|
|
1353
|
+
**Files:**
|
|
1354
|
+
- Modify: `src/cli.ts:225-227`
|
|
1355
|
+
|
|
1356
|
+
- [ ] **Step 1: 修改 chat 分支**
|
|
1357
|
+
|
|
1358
|
+
```ts
|
|
1359
|
+
if (!message) {
|
|
1360
|
+
const { runTui } = await import("./tui-chat.js")
|
|
1361
|
+
await runTui({ modelId: modelOverride, resumeSessionId: resumeId, mode: "chat", images })
|
|
1362
|
+
} else {
|
|
1363
|
+
```
|
|
1364
|
+
|
|
1365
|
+
- [ ] **Step 2: 验证**
|
|
1366
|
+
|
|
1367
|
+
Run: `npx tsc --noEmit`
|
|
1368
|
+
Expected: 无错误
|
|
1369
|
+
|
|
1370
|
+
- [ ] **Step 3: 提交**
|
|
1371
|
+
|
|
1372
|
+
```bash
|
|
1373
|
+
git add src/cli.ts
|
|
1374
|
+
git commit -m "fix: chat -i 无消息时图片不再丢失"
|
|
1375
|
+
```
|
|
1376
|
+
|
|
1377
|
+
---
|
|
1378
|
+
|
|
1379
|
+
## Task 11: slash-commands.ts 菜单同步
|
|
1380
|
+
|
|
1381
|
+
**Files:**
|
|
1382
|
+
- Modify: `src/tui/slash-commands.ts`
|
|
1383
|
+
|
|
1384
|
+
- [ ] **Step 1: 修改 SLASH_COMMANDS**
|
|
1385
|
+
|
|
1386
|
+
```ts
|
|
1387
|
+
export const SLASH_COMMANDS: SlashCommand[] = [
|
|
1388
|
+
{ name: "clear", description: "清除对话历史" },
|
|
1389
|
+
{ name: "compact", description: "压缩上下文" },
|
|
1390
|
+
{ name: "model", description: "选择/切换模型" },
|
|
1391
|
+
{ name: "memory", description: "查看/保存记忆", argHint: "[t]" },
|
|
1392
|
+
{ name: "tokens", description: "显示 token 用量与成本" },
|
|
1393
|
+
{ name: "undo", description: "撤销上一轮" },
|
|
1394
|
+
{ name: "redo", description: "重发最后一条消息" },
|
|
1395
|
+
{ name: "diff", description: "显示工作区未提交变更" },
|
|
1396
|
+
{ name: "sessions", description: "列出已保存会话" },
|
|
1397
|
+
{ name: "rename", description: "重命名当前会话", argHint: "<title>" },
|
|
1398
|
+
{ name: "attach", description: "附加本地图片", argHint: "<path>", aliases: ["image"] },
|
|
1399
|
+
{ name: "mcp", description: "显示 MCP 状态" },
|
|
1400
|
+
{ name: "skills", description: "显示可用 skills" },
|
|
1401
|
+
{ name: "path", description: "显示工作目录", aliases: ["pwd"] },
|
|
1402
|
+
{ name: "paste", description: "粘贴剪贴板图片", argHint: "[t]" },
|
|
1403
|
+
{ name: "help", description: "显示帮助" },
|
|
1404
|
+
{ name: "exit", description: "退出", aliases: ["quit", "q"] },
|
|
1405
|
+
]
|
|
1406
|
+
```
|
|
1407
|
+
|
|
1408
|
+
- [ ] **Step 2: 验证**
|
|
1409
|
+
|
|
1410
|
+
Run: `npx tsc --noEmit`
|
|
1411
|
+
Expected: 无错误
|
|
1412
|
+
|
|
1413
|
+
- [ ] **Step 3: 提交**
|
|
1414
|
+
|
|
1415
|
+
```bash
|
|
1416
|
+
git add src/tui/slash-commands.ts
|
|
1417
|
+
git commit -m "feat: 斜杠命令菜单同步新命令"
|
|
1418
|
+
```
|
|
1419
|
+
|
|
1420
|
+
---
|
|
1421
|
+
|
|
1422
|
+
## Task 12: README 同步
|
|
1423
|
+
|
|
1424
|
+
**Files:**
|
|
1425
|
+
- Modify: `README.md`(Interactive Commands 表格)
|
|
1426
|
+
|
|
1427
|
+
- [ ] **Step 1: 更新命令表**
|
|
1428
|
+
|
|
1429
|
+
替换 README.md 第 44-57 行的表格:
|
|
1430
|
+
|
|
1431
|
+
```markdown
|
|
1432
|
+
| Command | Description |
|
|
1433
|
+
|---------|-------------|
|
|
1434
|
+
| `/clear` | Clear history |
|
|
1435
|
+
| `/compact` | Force compaction |
|
|
1436
|
+
| `/model [name]` | Interactive model picker (↑↓ navigate, Enter confirm) |
|
|
1437
|
+
| `/memory [text]` | List/save memory |
|
|
1438
|
+
| `/tokens` | Context usage & cost |
|
|
1439
|
+
| `/undo` | Undo last turn |
|
|
1440
|
+
| `/redo` | Resend last message |
|
|
1441
|
+
| `/diff` | Show working tree diff |
|
|
1442
|
+
| `/sessions` | List saved sessions |
|
|
1443
|
+
| `/rename <title>` | Rename current session |
|
|
1444
|
+
| `/attach <path>` | Attach a local image |
|
|
1445
|
+
| `/paste [text]` | Clipboard image |
|
|
1446
|
+
| `/mcp` | MCP server status |
|
|
1447
|
+
| `/skills` | Available skills |
|
|
1448
|
+
| `/models` | List available models |
|
|
1449
|
+
| `/path` | Working directory |
|
|
1450
|
+
| `/help` | All commands |
|
|
1451
|
+
| `/exit` | Exit |
|
|
1452
|
+
```
|
|
1453
|
+
|
|
1454
|
+
- [ ] **Step 2: 提交**
|
|
1455
|
+
|
|
1456
|
+
```bash
|
|
1457
|
+
git add README.md
|
|
1458
|
+
git commit -m "docs: README 交互命令同步"
|
|
1459
|
+
```
|
|
1460
|
+
|
|
1461
|
+
---
|
|
1462
|
+
|
|
1463
|
+
## Task 13: 全量验证
|
|
1464
|
+
|
|
1465
|
+
- [ ] **Step 1: 类型检查**
|
|
1466
|
+
|
|
1467
|
+
Run: `npx tsc --noEmit`
|
|
1468
|
+
Expected: 无错误
|
|
1469
|
+
|
|
1470
|
+
- [ ] **Step 2: 全量测试**
|
|
1471
|
+
|
|
1472
|
+
Run: `bun test`
|
|
1473
|
+
Expected: 全部通过(含新增 4 个测试文件)
|
|
1474
|
+
|
|
1475
|
+
- [ ] **Step 3: 手动冒烟**
|
|
1476
|
+
|
|
1477
|
+
Run: `bun run src/cli.ts chat -i tests/fixtures/missing.png "测试"`(不存在的图片应警告并继续)
|
|
1478
|
+
|
|
1479
|
+
手动验证清单(交互模式 `bun run src/cli.ts chat`):
|
|
1480
|
+
1. ↑/↓ 浏览历史输入,Esc 恢复草稿
|
|
1481
|
+
2. `/undo` 撤销上一轮;连续 `/undo`;空栈提示
|
|
1482
|
+
3. `/redo` 重发最后消息
|
|
1483
|
+
4. `/diff` 在 git 仓库显示变更
|
|
1484
|
+
5. `/attach <png>` 附加图片并触发分析
|
|
1485
|
+
6. `/rename 新标题` 后 `/sessions` 显示新标题
|
|
1486
|
+
7. `/tokens` 显示成本行;完成消息带成本
|
|
1487
|
+
8. `/help` 菜单包含全部新命令
|
|
1488
|
+
|
|
1489
|
+
- [ ] **Step 4: 提交(如有遗留改动)**
|
|
1490
|
+
|
|
1491
|
+
```bash
|
|
1492
|
+
git add -A
|
|
1493
|
+
git commit -m "chore: 第一批验证收尾"
|
|
1494
|
+
```
|
|
1495
|
+
|
|
1496
|
+
---
|
|
1497
|
+
|
|
1498
|
+
## 自审记录
|
|
1499
|
+
|
|
1500
|
+
**Spec 覆盖:**
|
|
1501
|
+
- 缺陷 1(图片丢弃)→ Task 7、9、10
|
|
1502
|
+
- 缺陷 2(README/help 同步)→ Task 11、12
|
|
1503
|
+
- 输入历史 → Task 1、8
|
|
1504
|
+
- /undo → Task 2、5、9(Step 9)
|
|
1505
|
+
- 成本估算 → Task 4、9(Step 5、11)
|
|
1506
|
+
- /diff → Task 3、9
|
|
1507
|
+
- /redo → Task 9
|
|
1508
|
+
- /sessions + /rename → Task 6、9
|
|
1509
|
+
|
|
1510
|
+
**类型一致性:** `TurnRecord` 三字段(start/end/uiEnd)在 Task 2 定义,Task 5 的 truncateMessages 与 Task 9 的 undo 逻辑均一致使用;`SlashState` 接口在 Task 9 定义并用于签名与调用处。
|