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.
Files changed (81) hide show
  1. package/README.md +146 -18
  2. package/dist/agent.js +293 -408
  3. package/dist/assistant-stream.js +11 -7
  4. package/dist/cli.js +403 -140
  5. package/dist/clipboard.js +59 -23
  6. package/dist/code-mode.js +3 -3
  7. package/dist/compaction.js +182 -81
  8. package/dist/config.js +186 -35
  9. package/dist/confirm.js +55 -6
  10. package/dist/context-window.js +67 -54
  11. package/dist/doom-loop.js +19 -12
  12. package/dist/http.js +119 -0
  13. package/dist/instructions.js +51 -33
  14. package/dist/logger.js +66 -0
  15. package/dist/markdown.js +3 -44
  16. package/dist/mcp.js +547 -100
  17. package/dist/memory.js +48 -6
  18. package/dist/output.js +36 -27
  19. package/dist/paste-handler.js +3 -3
  20. package/dist/plugins.js +33 -6
  21. package/dist/pricing.js +119 -0
  22. package/dist/provider.js +17 -15
  23. package/dist/serve.js +658 -369
  24. package/dist/sessions.js +151 -13
  25. package/dist/skills.js +466 -76
  26. package/dist/synthetic.js +7 -0
  27. package/dist/title-gen.js +2 -1
  28. package/dist/tool-display.js +173 -0
  29. package/dist/tool-output.js +54 -45
  30. package/dist/tools/apply_patch.js +191 -0
  31. package/dist/tools/backend.js +61 -0
  32. package/dist/tools/bash.js +147 -70
  33. package/dist/tools/code_search.js +6 -5
  34. package/dist/tools/edit.js +23 -7
  35. package/dist/tools/explore.js +80 -12
  36. package/dist/tools/glob.js +3 -3
  37. package/dist/tools/grep.js +146 -14
  38. package/dist/tools/index.js +7 -7
  39. package/dist/tools/question.js +4 -22
  40. package/dist/tools/read.js +71 -11
  41. package/dist/tools/task.js +33 -20
  42. package/dist/tools/todo.js +83 -73
  43. package/dist/tools/web_fetch.js +150 -46
  44. package/dist/tools/web_search.js +706 -28
  45. package/dist/tools/write.js +13 -7
  46. package/dist/tui/App.js +40 -6
  47. package/dist/tui/ConfirmBar.js +24 -3
  48. package/dist/tui/InputBar.js +390 -45
  49. package/dist/tui/MessageList.js +533 -20
  50. package/dist/tui/ModelPicker.js +108 -0
  51. package/dist/tui/QuestionBar.js +104 -0
  52. package/dist/tui/StatusBar.js +19 -11
  53. package/dist/tui/agent-runner.js +103 -0
  54. package/dist/tui/caret-pos.js +134 -0
  55. package/dist/tui/caret.js +69 -0
  56. package/dist/tui/diff-view.js +61 -0
  57. package/dist/tui/drag-state.js +44 -0
  58. package/dist/tui/index.js +153 -24
  59. package/dist/tui/input-history.js +44 -0
  60. package/dist/tui/layout.js +17 -0
  61. package/dist/tui/mouse.js +46 -0
  62. package/dist/tui/selection.js +134 -0
  63. package/dist/tui/slash-commands.js +90 -0
  64. package/dist/tui/slash-handler.js +370 -0
  65. package/dist/tui/text-width.js +91 -0
  66. package/dist/tui/theme.js +12 -0
  67. package/dist/tui/undo-stack.js +14 -0
  68. package/dist/tui/use-sgr-mouse.js +27 -0
  69. package/dist/tui-chat.js +111 -331
  70. package/dist/updater.js +57 -0
  71. package/docs/API.md +160 -14
  72. package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
  73. package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
  74. package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
  75. package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
  76. package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
  77. package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
  78. package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
  79. package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
  80. package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
  81. 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 定义并用于签名与调用处。