min-agent 0.4.1 → 0.5.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 +32 -2
- package/dist/agent.js +36 -22
- package/dist/cli/commands/chat.js +3 -0
- package/dist/cli/commands/exec.js +3 -0
- package/dist/cli/commands/index.js +22 -5
- package/dist/cli/commands/memory.js +33 -15
- package/dist/cli/commands/think.js +12 -0
- package/dist/cli/commands/write-config.js +22 -0
- package/dist/cli/option-helpers.js +13 -1
- package/dist/cli/program.js +50 -13
- package/dist/code-mode.js +1 -1
- package/dist/config.js +41 -0
- package/dist/context-window.js +8 -28
- package/dist/memory-cli.js +33 -0
- package/dist/memory.js +127 -46
- package/dist/model-catalog.js +285 -0
- package/dist/permission-cli.js +1 -4
- package/dist/provider.js +4 -1
- package/dist/reasoning-stream.js +158 -0
- package/dist/sandbox-cli.js +1 -4
- package/dist/scope.js +23 -0
- package/dist/serve/common.js +22 -1
- package/dist/serve/routes-chat.js +21 -1
- package/dist/serve/routes-memory.js +31 -2
- package/dist/serve/routes-meta.js +34 -6
- package/dist/think-cli.js +36 -0
- package/dist/thinking-wire.js +228 -0
- package/dist/thinking.js +142 -0
- package/dist/token-display.js +10 -7
- package/dist/tools/todo.js +22 -8
- package/dist/tui/App.js +36 -8
- package/dist/tui/InputBar.js +109 -36
- package/dist/tui/MessageList.js +53 -22
- package/dist/tui/StatusBar.js +7 -3
- package/dist/tui/ThinkPicker.js +77 -0
- package/dist/tui/bracketed-paste.js +37 -0
- package/dist/tui/caret-pos.js +10 -8
- package/dist/tui/index.js +7 -1
- package/dist/tui/layout.js +17 -0
- package/dist/tui/overlay-input.js +12 -0
- package/dist/tui/paste-draft.js +173 -0
- package/dist/tui/selection.js +8 -2
- package/dist/tui/slash-commands.js +18 -1
- package/dist/tui/slash-handler.js +61 -17
- package/dist/tui/text-width.js +6 -6
- package/dist/tui-chat.js +63 -7
- package/docs/API.md +50 -4
- package/docs/superpowers/plans/2026-08-23-input-paste-attachments.md +475 -0
- package/docs/superpowers/plans/2026-08-23-thinking-wire-profile.md +450 -0
- package/docs/superpowers/specs/2026-08-23-input-paste-attachments-design.md +174 -0
- package/docs/superpowers/specs/2026-08-23-thinking-wire-profile-design.md +140 -0
- package/package.json +1 -1
- package/skills/self-config/SKILL.md +5 -4
- package/skills/self-config/reference.md +10 -5
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
# 输入框粘贴图片与文本片段 — 实现计划
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** 输入框 Ctrl+V 把剪贴板图片挂成 `[图片 #N]`;一次粘贴超过一行收成 `[粘贴 #N · X 行]`;Enter 时图片进 image part、片段展开全文;短标记留在对话区和输入历史。
|
|
6
|
+
|
|
7
|
+
**Architecture:** 粘贴判定、附件表、展开逻辑放在无 React 的 `paste-draft.ts`;bracketed paste 扫包装在 `bracketed-paste.ts`;cluster 显示宽可注入,让现有 `visualRows` / 点击换算把私有区码点当成一块。InputBar 只持有草稿表并接线。`onSubmit` 改为 `SubmittedPrompt`。
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript, React 18 + Ink 5, bun:test
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-08-23-input-paste-attachments-design.md`
|
|
12
|
+
|
|
13
|
+
测试命令(`packages/min-agent` 下):`bun test tests/<file>`;类型:`npx tsc --noEmit`;仓库根目录:`bun run typecheck`、`bun run check`。
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 文件结构
|
|
18
|
+
|
|
19
|
+
| 文件 | 职责 |
|
|
20
|
+
|------|------|
|
|
21
|
+
| `src/tui/paste-draft.ts`(新) | 规范化粘贴、单行/多行、附件表、标记文案、展开 `display`/`content`、孤儿回收 |
|
|
22
|
+
| `src/tui/bracketed-paste.ts`(新) | 启用序列、stdin 扫包、`useInput` 应忽略的碎片 |
|
|
23
|
+
| `src/tui/text-width.ts`(改) | `wrapByWidth` 可选 cluster 宽度覆盖 |
|
|
24
|
+
| `src/tui/caret-pos.ts`(改) | `visualRows` / `caretIndexFromClick` / `moveCaretVertical` 透传宽度覆盖 |
|
|
25
|
+
| `src/tui/types.ts`(改) | 导出 `SubmittedPrompt`(或从 paste-draft 再导出) |
|
|
26
|
+
| `src/tui/InputBar.tsx`(改) | bracketed paste、Ctrl+V、旁路表、绘制标记、`onNotice`、按 `SubmittedPrompt` 提交 |
|
|
27
|
+
| `src/tui/App.tsx` `index.tsx`(改) | `onSubmit` / `onNotice` |
|
|
28
|
+
| `src/tui-chat.ts`(改) | `display`/`content` 入列;`/` 走命令;`onNotice` → `sysMsg` |
|
|
29
|
+
| `src/tui/slash-handler.ts`(改) | `/help` 快捷键 |
|
|
30
|
+
| `README.md`(改) | 中英快捷键 |
|
|
31
|
+
| `tests/paste-draft.test.ts`(新) | |
|
|
32
|
+
| `tests/bracketed-paste.test.ts`(新) | |
|
|
33
|
+
| `tests/caret-pos.test.ts`(改) | 宽标记点击 |
|
|
34
|
+
|
|
35
|
+
`docs/API.md`、`skills/self-config/`、`/paste`、`/attach`、CLI `-i` 不改。
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Task 1: paste-draft.ts
|
|
40
|
+
|
|
41
|
+
**Files:**
|
|
42
|
+
- Create: `src/tui/paste-draft.ts`
|
|
43
|
+
- Test: `tests/paste-draft.test.ts`
|
|
44
|
+
|
|
45
|
+
- [ ] **Step 1: 写失败测试**
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { expect, test } from "bun:test"
|
|
49
|
+
import {
|
|
50
|
+
IMAGE_MAX_BYTES,
|
|
51
|
+
SNIPPET_MAX_CHARS,
|
|
52
|
+
NOTICE_IMAGE_TOO_LARGE,
|
|
53
|
+
NOTICE_SNIPPET_TOO_LARGE,
|
|
54
|
+
createPasteDraft,
|
|
55
|
+
normalizePastedText,
|
|
56
|
+
applyPastedText,
|
|
57
|
+
applyClipboardImage,
|
|
58
|
+
pruneAttachments,
|
|
59
|
+
expandDisplay,
|
|
60
|
+
expandContent,
|
|
61
|
+
resetPasteDraft,
|
|
62
|
+
chipClusterWidth,
|
|
63
|
+
imageLabel,
|
|
64
|
+
snippetLabel,
|
|
65
|
+
} from "../src/tui/paste-draft.js"
|
|
66
|
+
|
|
67
|
+
test("normalizePastedText maps CRLF and strips one trailing newline", () => {
|
|
68
|
+
expect(normalizePastedText("a\r\nb\r\n")).toBe("a\nb")
|
|
69
|
+
expect(normalizePastedText("hello\n")).toBe("hello")
|
|
70
|
+
expect(normalizePastedText("hello")).toBe("hello")
|
|
71
|
+
expect(normalizePastedText("\n")).toBe("")
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test("single-line paste including a trailing newline inserts as text", () => {
|
|
75
|
+
const r = applyPastedText(createPasteDraft(), "hello\n")
|
|
76
|
+
expect(r.kind).toBe("text")
|
|
77
|
+
if (r.kind === "text") expect(r.text).toBe("hello")
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test("paste with a remaining newline becomes a snippet chip", () => {
|
|
81
|
+
const r = applyPastedText(createPasteDraft(), "a\nb\n")
|
|
82
|
+
expect(r.kind).toBe("snippet")
|
|
83
|
+
if (r.kind !== "snippet") return
|
|
84
|
+
expect(expandDisplay(r.char, r.draft)).toBe("[粘贴 #1 · 2 行]")
|
|
85
|
+
expect(snippetLabel(1, 2)).toBe("[粘贴 #1 · 2 行]")
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test("two snippets number independently from images", () => {
|
|
89
|
+
let draft = createPasteDraft()
|
|
90
|
+
const s1 = applyPastedText(draft, "a\nb")
|
|
91
|
+
if (s1.kind !== "snippet") throw new Error("expected snippet")
|
|
92
|
+
const img = applyClipboardImage(s1.draft, { data: Buffer.from("png"), mimeType: "image/png" })
|
|
93
|
+
if (img.kind !== "image") throw new Error("expected image")
|
|
94
|
+
const s2 = applyPastedText(img.draft, "c\nd")
|
|
95
|
+
if (s2.kind !== "snippet") throw new Error("expected snippet")
|
|
96
|
+
expect(expandDisplay(s1.char + img.char + s2.char, s2.draft)).toBe("[粘贴 #1 · 2 行][图片 #1][粘贴 #2 · 2 行]")
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test("snippet over SNIPPET_MAX_CHARS is rejected", () => {
|
|
100
|
+
const r = applyPastedText(createPasteDraft(), `a\n${"x".repeat(SNIPPET_MAX_CHARS)}`)
|
|
101
|
+
expect(r).toEqual({ kind: "reject", notice: NOTICE_SNIPPET_TOO_LARGE, draft: expect.anything() })
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test("clipboard image over IMAGE_MAX_BYTES is rejected", () => {
|
|
105
|
+
const r = applyClipboardImage(createPasteDraft(), {
|
|
106
|
+
data: Buffer.alloc(IMAGE_MAX_BYTES + 1),
|
|
107
|
+
mimeType: "image/png",
|
|
108
|
+
})
|
|
109
|
+
expect(r.kind).toBe("reject")
|
|
110
|
+
if (r.kind === "reject") expect(r.notice).toBe(NOTICE_IMAGE_TOO_LARGE)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test("clipboard image null is a no-op", () => {
|
|
114
|
+
const draft = createPasteDraft()
|
|
115
|
+
const r = applyClipboardImage(draft, null)
|
|
116
|
+
expect(r.kind).toBe("none")
|
|
117
|
+
expect(r.draft).toBe(draft)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test("expandContent keeps text+snippet as a string and interleaves images", () => {
|
|
121
|
+
let draft = createPasteDraft()
|
|
122
|
+
const s = applyPastedText(draft, "a\nb")
|
|
123
|
+
if (s.kind !== "snippet") throw new Error("expected snippet")
|
|
124
|
+
const textOnly = expandContent(`看${s.char}`, s.draft)
|
|
125
|
+
expect(textOnly).toBe(`看\n--- 粘贴 #1 ---\na\nb\n--- 粘贴 #1 ---`)
|
|
126
|
+
|
|
127
|
+
const img = applyClipboardImage(s.draft, { data: Buffer.from("png"), mimeType: "image/png" })
|
|
128
|
+
if (img.kind !== "image") throw new Error("expected image")
|
|
129
|
+
const mixed = expandContent(`看${img.char}${s.char}`, img.draft)
|
|
130
|
+
expect(Array.isArray(mixed)).toBe(true)
|
|
131
|
+
if (!Array.isArray(mixed)) return
|
|
132
|
+
expect(mixed[0]).toEqual({ type: "text", text: "看" })
|
|
133
|
+
expect(mixed[1]).toMatchObject({ type: "image", mimeType: "image/png" })
|
|
134
|
+
expect(mixed[2]).toEqual({
|
|
135
|
+
type: "text",
|
|
136
|
+
text: `\n--- 粘贴 #1 ---\na\nb\n--- 粘贴 #1 ---`,
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test("pruneAttachments drops chips no longer in value and resetPasteDraft restarts ids", () => {
|
|
141
|
+
const s = applyPastedText(createPasteDraft(), "a\nb")
|
|
142
|
+
if (s.kind !== "snippet") throw new Error("expected snippet")
|
|
143
|
+
const pruned = pruneAttachments(s.draft, "")
|
|
144
|
+
expect(expandDisplay(s.char, pruned)).toBe("�")
|
|
145
|
+
const again = applyPastedText(resetPasteDraft(), "c\nd")
|
|
146
|
+
if (again.kind !== "snippet") throw new Error("expected snippet")
|
|
147
|
+
expect(expandDisplay(again.char, again.draft)).toBe("[粘贴 #1 · 2 行]")
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test("chipClusterWidth is the painted label width capped by maxWidth", () => {
|
|
151
|
+
const s = applyPastedText(createPasteDraft(), "a\nb")
|
|
152
|
+
if (s.kind !== "snippet") throw new Error("expected snippet")
|
|
153
|
+
const full = chipClusterWidth(s.char, s.draft, 80)
|
|
154
|
+
const cap = chipClusterWidth(s.char, s.draft, 4)
|
|
155
|
+
expect(full).toBeGreaterThan(4)
|
|
156
|
+
expect(cap).toBe(4)
|
|
157
|
+
expect(chipClusterWidth("x", s.draft, 80)).toBeUndefined()
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test("imageLabel format", () => {
|
|
161
|
+
expect(imageLabel(1)).toBe("[图片 #1]")
|
|
162
|
+
})
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
- [ ] **Step 2: 运行确认失败**
|
|
166
|
+
|
|
167
|
+
Run: `cd packages/min-agent && bun test tests/paste-draft.test.ts`
|
|
168
|
+
Expected: FAIL(模块不存在)
|
|
169
|
+
|
|
170
|
+
- [ ] **Step 3: 实现**
|
|
171
|
+
|
|
172
|
+
要点(函数式,不 `any`):
|
|
173
|
+
|
|
174
|
+
- `PASTE_CHIP_BASE = 0xe000`;每条草稿 `nextCodePoint` 递增,本条内不复用。
|
|
175
|
+
- 附件表 `Map<string, DraftAttachment>`,key 为那一个私有区字符。
|
|
176
|
+
- `normalizePastedText`:`\r\n`/`\r` → `\n`,再去掉末尾一个 `\n`。
|
|
177
|
+
- `applyPastedText`:规范化后 `length > SNIPPET_MAX_CHARS`(`1_000_000`)→ `reject`;仍含 `\n` → 分配 snippet(`id` 按 snippet 递增,`lineCount = text.split("\n").length`);否则 `{ kind: "text", text }`,draft 不变。
|
|
178
|
+
- `applyClipboardImage`:`null` → `{ kind: "none" }`;`data.length > IMAGE_MAX_BYTES`(`5 * 1024 * 1024`,与 `agent.ts` 的上限相同)→ `reject`;否则分配 image。
|
|
179
|
+
- `expandDisplay`:已登记码点换成 `imageLabel` / `snippetLabel`,未登记私有区(`0xE000–0xF8FF`)画 `�`。
|
|
180
|
+
- `expandContent`:从左到右,连续普通字(含未登记私有区按 `�`)合成 text;snippet 单独 text part,格式为若前面已有内容且不以 `\n` 结尾则先补 `\n`,再 `` `--- 粘贴 #${id} ---\n${text}\n--- 粘贴 #${id} ---` ``;image 为 `{ type: "image", image: Buffer, mimeType }`。无 image part 时把所有 text part 拼成一个 `string`。
|
|
181
|
+
- `pruneAttachments`:留下 `value` 里还出现的 key。
|
|
182
|
+
- `resetPasteDraft`:等同 `createPasteDraft()`。
|
|
183
|
+
- `chipClusterWidth`:已登记则 `min(displayWidth(label), maxWidth)`,否则 `undefined`。
|
|
184
|
+
- 导出 `SubmittedPrompt`:`{ display: string; content: string | Array<{ type: "text"; text: string } | { type: "image"; image: Buffer; mimeType: string }> }`。
|
|
185
|
+
- 导出 `buildSubmittedPrompt(value, draft): SubmittedPrompt`(`display` + `content`)。
|
|
186
|
+
|
|
187
|
+
- [ ] **Step 4: 运行确认通过**
|
|
188
|
+
|
|
189
|
+
Run: `cd packages/min-agent && bun test tests/paste-draft.test.ts`
|
|
190
|
+
Expected: PASS
|
|
191
|
+
|
|
192
|
+
- [ ] **Step 5: 提交**
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
git add src/tui/paste-draft.ts tests/paste-draft.test.ts
|
|
196
|
+
git commit -m "feat: 输入草稿附件表与粘贴展开"
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## Task 2: bracketed-paste.ts
|
|
202
|
+
|
|
203
|
+
**Files:**
|
|
204
|
+
- Create: `src/tui/bracketed-paste.ts`
|
|
205
|
+
- Test: `tests/bracketed-paste.test.ts`
|
|
206
|
+
|
|
207
|
+
- [ ] **Step 1: 写失败测试**
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
import { expect, test } from "bun:test"
|
|
211
|
+
import {
|
|
212
|
+
BRACKETED_PASTE_ENABLE,
|
|
213
|
+
BRACKETED_PASTE_DISABLE,
|
|
214
|
+
scanBracketedPaste,
|
|
215
|
+
shouldIgnorePasteInput,
|
|
216
|
+
} from "../src/tui/bracketed-paste.js"
|
|
217
|
+
|
|
218
|
+
test("enable/disable sequences", () => {
|
|
219
|
+
expect(BRACKETED_PASTE_ENABLE).toBe("\x1b[?2004h")
|
|
220
|
+
expect(BRACKETED_PASTE_DISABLE).toBe("\x1b[?2004l")
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test("scanBracketedPaste extracts a complete paste in one chunk", () => {
|
|
224
|
+
const r = scanBracketedPaste("", "\x1b[200~hello\nworld\x1b[201~")
|
|
225
|
+
expect(r).toEqual({ buffer: "", pastes: ["hello\nworld"], active: false })
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test("scanBracketedPaste spans chunks and stays active until the end marker", () => {
|
|
229
|
+
const a = scanBracketedPaste("", "\x1b[200~hel")
|
|
230
|
+
expect(a.pastes).toEqual([])
|
|
231
|
+
expect(a.active).toBe(true)
|
|
232
|
+
const b = scanBracketedPaste(a.buffer, "lo\x1b[201~more")
|
|
233
|
+
expect(b.pastes).toEqual(["hello"])
|
|
234
|
+
expect(b.active).toBe(false)
|
|
235
|
+
expect(b.buffer).toBe("more")
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test("scanBracketedPaste keeps an incomplete ESC prefix", () => {
|
|
239
|
+
const r = scanBracketedPaste("", "pre\x1b[200")
|
|
240
|
+
expect(r.pastes).toEqual([])
|
|
241
|
+
expect(r.buffer.endsWith("\x1b[200")).toBe(true)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test("shouldIgnorePasteInput covers ink-stripped markers and active paste", () => {
|
|
245
|
+
expect(shouldIgnorePasteInput("abc", false)).toBe(false)
|
|
246
|
+
expect(shouldIgnorePasteInput("[200~hi", false)).toBe(true)
|
|
247
|
+
expect(shouldIgnorePasteInput("hi[201~", false)).toBe(true)
|
|
248
|
+
expect(shouldIgnorePasteInput("\x1b[200~x", false)).toBe(true)
|
|
249
|
+
expect(shouldIgnorePasteInput("x", true)).toBe(true)
|
|
250
|
+
})
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
- [ ] **Step 2: 运行确认失败**
|
|
254
|
+
|
|
255
|
+
Run: `cd packages/min-agent && bun test tests/bracketed-paste.test.ts`
|
|
256
|
+
Expected: FAIL
|
|
257
|
+
|
|
258
|
+
- [ ] **Step 3: 实现**
|
|
259
|
+
|
|
260
|
+
`scanBracketedPaste(buffer, chunk)`:拼串后循环找 `\x1b[200~` … `\x1b[201~`。开始标记之后、结束标记之前的正文进 `pastes`(可多段)。若已开始未结束:`active: true`,`buffer` 为开始标记后的正文(可再含不完整的 `\x1b[201` 前缀)。若未开始:把最后的不完整 `\x1b` 前缀留在 `buffer`,其它非粘贴字节丢弃(与 Kitty 累加器一样,粘贴通道不把普通键当正文;普通键仍走 `useInput`)。
|
|
261
|
+
|
|
262
|
+
注意:未处于粘贴态时,chunk 里开始标记之前的普通字符不要当 paste;也**不要**从 raw 通道插入它们(避免与 `useInput` 双写)。`buffer` 在未开始时只保留可能的 `ESC[200~` 前缀。
|
|
263
|
+
|
|
264
|
+
`shouldIgnorePasteInput(input, active)`:`active` 为真,或 `input` 含 `\x1b[200~` / `\x1b[201~` / `[200~` / `[201~`。
|
|
265
|
+
|
|
266
|
+
- [ ] **Step 4: 运行确认通过**
|
|
267
|
+
|
|
268
|
+
Run: `cd packages/min-agent && bun test tests/bracketed-paste.test.ts`
|
|
269
|
+
Expected: PASS
|
|
270
|
+
|
|
271
|
+
- [ ] **Step 5: 提交**
|
|
272
|
+
|
|
273
|
+
```bash
|
|
274
|
+
git add src/tui/bracketed-paste.ts tests/bracketed-paste.test.ts
|
|
275
|
+
git commit -m "feat: 扫描终端 bracketed paste 数据包"
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
---
|
|
279
|
+
|
|
280
|
+
## Task 3: cluster 宽度注入
|
|
281
|
+
|
|
282
|
+
**Files:**
|
|
283
|
+
- Modify: `src/tui/text-width.ts`
|
|
284
|
+
- Modify: `src/tui/caret-pos.ts`
|
|
285
|
+
- Test: `tests/caret-pos.test.ts`(追加)
|
|
286
|
+
- Test: `tests/text-width.test.ts`(若改了 `wrapByWidth` 签名,现有测试必须仍过)
|
|
287
|
+
|
|
288
|
+
- [ ] **Step 1: 写失败测试**
|
|
289
|
+
|
|
290
|
+
在 `tests/caret-pos.test.ts` 追加:
|
|
291
|
+
|
|
292
|
+
```ts
|
|
293
|
+
test("visualRows keeps a wide chip on one row using cluster width lookup", () => {
|
|
294
|
+
const chip = "\uE000"
|
|
295
|
+
const rows = visualRows(`ab${chip}cd`, 80, (c) => (c === chip ? 10 : undefined))
|
|
296
|
+
expect(rows).toEqual([{ text: `ab${chip}cd`, startIndex: 0 }])
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
test("caretIndexFromClick maps the whole chip width to one code point", () => {
|
|
300
|
+
const chip = "\uE000"
|
|
301
|
+
const rows = visualRows(chip, 80, (c) => (c === chip ? 10 : undefined))
|
|
302
|
+
expect(caretIndexFromClick(rows, 0, 5, 5, (c) => (c === chip ? 10 : undefined))).toBe(0)
|
|
303
|
+
expect(caretIndexFromClick(rows, 0, 14, 5, (c) => (c === chip ? 10 : undefined))).toBe(0)
|
|
304
|
+
expect(caretIndexFromClick(rows, 0, 15, 5, (c) => (c === chip ? 10 : undefined))).toBe(1)
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
test("a chip wider than maxWidth still occupies one visual row", () => {
|
|
308
|
+
const chip = "\uE000"
|
|
309
|
+
const rows = visualRows(chip, 4, (c) => (c === chip ? 4 : undefined))
|
|
310
|
+
expect(rows).toEqual([{ text: chip, startIndex: 0 }])
|
|
311
|
+
})
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
- [ ] **Step 2: 运行确认失败**
|
|
315
|
+
|
|
316
|
+
Run: `cd packages/min-agent && bun test tests/caret-pos.test.ts`
|
|
317
|
+
Expected: FAIL(多余参数 / 宽标记被拆行)
|
|
318
|
+
|
|
319
|
+
- [ ] **Step 3: 实现**
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
export type ClusterWidthLookup = (cluster: string) => number | undefined
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
- `wrapByWidth(line, maxWidth, lookup?)`:对每个 cluster,`w = lookup?.(g) ?? clusterWidth(g)`。`lookup` 已把过宽标记收成 `<= maxWidth`,因此不会拆开。
|
|
326
|
+
- `visualRows(value, maxWidth, lookup?)` 把 `lookup` 传给 `wrapByWidth`。
|
|
327
|
+
- `caretIndexFromClick(rows, rowIndex, clickColumn, textStartColumn, lookup?)`:累加宽度时用 `lookup?.(g) ??` 现有 `clustersOf` 宽度。
|
|
328
|
+
- `moveCaretVertical(..., maxWidth, lookup?)`:内部 `visualRows` / `caretIndexFromClick` 都传入 `lookup`。
|
|
329
|
+
- 现有调用不传 `lookup`,行为与现在完全相同。
|
|
330
|
+
|
|
331
|
+
- [ ] **Step 4: 运行确认通过**
|
|
332
|
+
|
|
333
|
+
Run: `cd packages/min-agent && bun test tests/caret-pos.test.ts tests/text-width.test.ts`
|
|
334
|
+
Expected: PASS
|
|
335
|
+
|
|
336
|
+
- [ ] **Step 5: 提交**
|
|
337
|
+
|
|
338
|
+
```bash
|
|
339
|
+
git add src/tui/text-width.ts src/tui/caret-pos.ts tests/caret-pos.test.ts tests/text-width.test.ts
|
|
340
|
+
git commit -m "feat: 输入折行与点击识别宽标记"
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
---
|
|
344
|
+
|
|
345
|
+
## Task 4: `SubmittedPrompt` 接线
|
|
346
|
+
|
|
347
|
+
**Files:**
|
|
348
|
+
- Modify: `src/tui/types.ts`(若类型放这里,从 `paste-draft` re-export 亦可,只选一处)
|
|
349
|
+
- Modify: `src/tui/index.tsx`
|
|
350
|
+
- Modify: `src/tui/App.tsx`
|
|
351
|
+
- Modify: `src/tui/InputBar.tsx`(本步只改提交形状:无附件时 `{ display: text, content: text }`)
|
|
352
|
+
- Modify: `src/tui-chat.ts`
|
|
353
|
+
|
|
354
|
+
- [ ] **Step 1: 改类型并让现有路径等价**
|
|
355
|
+
|
|
356
|
+
`TuiCallbacks.onSubmit: (prompt: SubmittedPrompt) => void`
|
|
357
|
+
|
|
358
|
+
`App` / `InputBar` 同步。InputBar 现有 `submit(text)` 改为:
|
|
359
|
+
|
|
360
|
+
```ts
|
|
361
|
+
onSubmit({ display: text, content: text })
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
`tui-chat`:
|
|
365
|
+
|
|
366
|
+
```ts
|
|
367
|
+
onSubmit: async (prompt) => {
|
|
368
|
+
const text = prompt.display
|
|
369
|
+
if (text.trim().startsWith("/")) {
|
|
370
|
+
// 现有 handleSlashCommand(text, ...),附件已不在 content 里
|
|
371
|
+
...
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
lastUserMessage = text
|
|
375
|
+
tui.addMessage({ id: `user-${Date.now()}`, role: "user", content: text, timestamp: Date.now() })
|
|
376
|
+
messages.push({ role: "user", content: prompt.content })
|
|
377
|
+
...
|
|
378
|
+
}
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
增加 `onNotice?: (text: string) => void`:`tui-chat` 里 `sysMsg(tui, text)`;`TuiRenderer` → `App` → `InputBar` 透传(本步 InputBar 可先接 prop 暂不调用)。
|
|
382
|
+
|
|
383
|
+
- [ ] **Step 2: 类型检查**
|
|
384
|
+
|
|
385
|
+
Run: `cd packages/min-agent && npx tsc --noEmit`
|
|
386
|
+
Expected: PASS(无其它 `onSubmit` 调用方)
|
|
387
|
+
|
|
388
|
+
- [ ] **Step 3: 提交**
|
|
389
|
+
|
|
390
|
+
```bash
|
|
391
|
+
git add src/tui/types.ts src/tui/index.tsx src/tui/App.tsx src/tui/InputBar.tsx src/tui-chat.ts
|
|
392
|
+
git commit -m "feat: 输入提交改为 display 与 content"
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
---
|
|
396
|
+
|
|
397
|
+
## Task 5: InputBar 粘贴与标记
|
|
398
|
+
|
|
399
|
+
**Files:**
|
|
400
|
+
- Modify: `src/tui/InputBar.tsx`
|
|
401
|
+
|
|
402
|
+
- [ ] **Step 1: 集成行为(无 Ink 渲染测试;逻辑用 Task 1–2 的纯函数)**
|
|
403
|
+
|
|
404
|
+
1. `draftRef = useRef(createPasteDraft())`。`applyEdit` 之后 `draftRef.current = pruneAttachments(draftRef.current, after.value)`。
|
|
405
|
+
2. 打开/关闭 bracketed paste:与鼠标协议同一 `useEffect`,stdout 写 `BRACKETED_PASTE_ENABLE` / `DISABLE`(先 enable 再 mouse,卸载时先 mouse disable 再 paste disable,或与 mouse 写在同一串,但必须成对)。
|
|
406
|
+
3. raw `handleData` 里对 chunk 调 `scanBracketedPaste`;`pasteActiveRef` 跟随 `active`。每个 `pastes[]` 项:`applyPastedText`;`text` 则 `insertAt`;`snippet` 则更新 `draftRef` 后 `insertAt` 该码点;`reject` 则 `onNotice(notice)`。
|
|
407
|
+
4. `useInput` 在处理 Enter 之前:若 `shouldIgnorePasteInput(input, pasteActiveRef.current)` 则 `return`。删掉现在那套「字符串里含 `[200~` 就当普通插入」的分支。
|
|
408
|
+
5. `key.ctrl && input === "v"`:放在 `Ctrl+B` 之后、`if (key.ctrl || key.meta) return` 之前。`getClipboardImage()` → `applyClipboardImage`;`image` 插入码点;`reject` 则 `onNotice`;`none` 静默。
|
|
409
|
+
6. 绘制:`visualRows(value, textWidth, lookup)`,`lookup = (c) => chipClusterWidth(c, draftRef.current, textWidth)`。画每一行时把已登记码点换成标记文案(过宽用 `…` 截到 `textWidth`,与 `chipClusterWidth` 一致)。caret 列用同一套宽度。`moveCaretVertical` / 点击同样传 `lookup`。
|
|
410
|
+
7. Enter:`buildSubmittedPrompt(value, draft)`;`display.trim()` 或 `draft.attachments.size > 0` 才 `onSubmit`;随后 `draftRef.current = resetPasteDraft()`。历史 `pushInput` 用 `display`。
|
|
411
|
+
8. Ctrl+C 清空:`applyEdit` 清空 value 后 prune,再 `resetPasteDraft()`(计数回到 1)。
|
|
412
|
+
9. 从历史上召回:只改 `value`,不要往旁路表里塞东西(召回的 `[粘贴 #1 · 2 行]` 是普通字)。
|
|
413
|
+
|
|
414
|
+
标记过宽截断:若 `displayWidth(label) > maxWidth`,按 cluster 累加到 `maxWidth - displayWidth("…")` 再加 `…`。`chipClusterWidth` 已是 `min(labelWidth, maxWidth)`,绘制宽度必须与它相同,否则点击会偏。
|
|
415
|
+
|
|
416
|
+
- [ ] **Step 2: 运行相关测试 + 类型**
|
|
417
|
+
|
|
418
|
+
Run: `cd packages/min-agent && bun test tests/paste-draft.test.ts tests/bracketed-paste.test.ts tests/caret-pos.test.ts tests/kitty-input.test.ts && npx tsc --noEmit`
|
|
419
|
+
Expected: PASS
|
|
420
|
+
|
|
421
|
+
- [ ] **Step 3: 提交**
|
|
422
|
+
|
|
423
|
+
```bash
|
|
424
|
+
git add src/tui/InputBar.tsx
|
|
425
|
+
git commit -m "feat: 输入框粘贴图片与多行文本片段"
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
---
|
|
429
|
+
|
|
430
|
+
## Task 6: README 与 /help
|
|
431
|
+
|
|
432
|
+
**Files:**
|
|
433
|
+
- Modify: `README.md`
|
|
434
|
+
- Modify: `src/tui/slash-handler.ts`
|
|
435
|
+
|
|
436
|
+
- [ ] **Step 1: 文案**
|
|
437
|
+
|
|
438
|
+
英文快捷键行(约第 81 行)改为同时提到 Ctrl+V 贴图、多行粘贴收成片段,例如:
|
|
439
|
+
|
|
440
|
+
`Shortcuts: **Ctrl+C** clears the input (press twice to exit) · **Ctrl+V** pastes an image · a multi-line paste becomes a snippet · **Ctrl+B** selects text to copy · mouse drag-select also copies on release.`
|
|
441
|
+
|
|
442
|
+
中文(约第 185 行)对称:
|
|
443
|
+
|
|
444
|
+
`快捷键:**Ctrl+C** 清空输入框(连按两次退出)· **Ctrl+V** 粘贴图片 · 多行粘贴会收成片段 · **Ctrl+B** 选中文本以复制 · 也支持鼠标拖拽选中并自动复制。`
|
|
445
|
+
|
|
446
|
+
`/help` 快捷键段在 `Ctrl+J` 旁增加两行(对齐现有空格):
|
|
447
|
+
|
|
448
|
+
```
|
|
449
|
+
Ctrl+V 粘贴剪贴板图片到输入框
|
|
450
|
+
多行粘贴 收成片段,发送时带上全文
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
不要写私有区码点、bracketed paste、Ink 等实现词。
|
|
454
|
+
|
|
455
|
+
- [ ] **Step 2: 提交**
|
|
456
|
+
|
|
457
|
+
```bash
|
|
458
|
+
git add README.md src/tui/slash-handler.ts
|
|
459
|
+
git commit -m "docs: 说明输入框贴图与多行片段"
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
---
|
|
463
|
+
|
|
464
|
+
## 手工验收
|
|
465
|
+
|
|
466
|
+
在 `packages/min-agent` 下 `bun run src/cli.ts`(或仓库根目录 `bun run dev:agent`):
|
|
467
|
+
|
|
468
|
+
1. 粘贴一行 `hello` → 输入框出现 `hello`,不发送
|
|
469
|
+
2. 粘贴两行 → 出现 `[粘贴 #1 · 2 行]`,左右键一次跳过整块,退格整块消失
|
|
470
|
+
3. 在标记前后打字再 Enter → 对话区是短标记;模型侧应能读到 `--- 粘贴 #1 ---` 包着的原文(可看会话文件或临时 log)
|
|
471
|
+
4. 复制一张截图后 **Ctrl+V** → `[图片 #1]`;再写「这是什么」发送
|
|
472
|
+
5. Ctrl+V 在剪贴板只有文字时无提示、不插入 `v`
|
|
473
|
+
6. `/paste` 仍立刻发送剪贴板图片
|
|
474
|
+
7. 输入 `/help` 仍走命令
|
|
475
|
+
8. 只贴一张图不打字,Enter 能发送
|