diff --git a/README.md b/README.md index 446e207f0..b4b10210e 100644 --- a/README.md +++ b/README.md @@ -298,7 +298,7 @@ During a fullscreen text drag, the native cursor follows the selected text's edg File paths in prose can open the file-action menu; automatic detection does not extract a path from inside a slash-delimited token such as `working/idle/needs-input` or a date such as `2024/01/15`. -**Pasting**: native and bracketed paste keeps ordinary text and newlines, and never submits itself on arrival. On Windows terminals that deliver a paste as win32-input-mode key records, a record stream leaked into the payload is decoded back into the characters its `Uc` field encodes — newlines included — so the composer's line count matches what was pasted; only records with no character meaning are stripped (a multi-line paste no longer leaves stray `_`), and a complete record is always consumed before an ESC-less tail, so no payload character is deleted along with an orphan escape. Pasted CRLF collapses to a single newline; genuine underscores and bracketed-paste text are untouched. +**Pasting**: the launchpad accepts clipboard images and pasted image paths as `[Image #N]` attachments; `Enter` sends them with the first message. Commands that do not accept images preserve the draft and its attachments. Image chips behave like chat: arrows step across them, Backspace at the end or Delete at the start removes the whole image, and Ctrl+W never splits a chip. Text pasted on the launchpad is folded to one line. In chat, native and bracketed paste keeps ordinary text and newlines, and never submits itself on arrival. On Windows terminals that deliver a paste as win32-input-mode key records, a record stream leaked into the payload is decoded back into the characters its `Uc` field encodes — newlines included — so the composer's line count matches what was pasted; only records with no character meaning are stripped (a multi-line paste no longer leaves stray `_`), and a complete record is always consumed before an ESC-less tail, so no payload character is deleted along with an orphan escape. Pasted CRLF collapses to a single newline; genuine underscores and bracketed-paste text are untouched. **Dropped files**: a native Windows desktop drop (Windows Terminal / OpenConsole) arrives as an OSC 8 hyperlink; the parser restores its `file://` URI to a decoded local path before paste hygiene runs, so the `]8;id=…;` parameter bytes never reach the draft. Image paths enter the existing image staging pipeline; other files are inserted as a referenceable path (a path containing whitespace arrives in the composer's quoted `"…"` single-token form). Only `file://` URIs are restored, and it is fail-closed: a remote authority/UNC, a payload carrying several distinct URIs, or a URI that carries several tokens is refused and stays literal text rather than guessed. diff --git a/README_ZH.md b/README_ZH.md index e29c0f569..b6c678adb 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -252,7 +252,7 @@ OpenAI 条款约束。完整操作与当前边界:[Codex 后端](docs/codex-ba 正文中的文件路径可打开文件操作菜单;自动识别不会从 `working/idle/needs-input` 这类斜杠分隔的词串或 `2024/01/15` 这类日期内部截出路径。 -**粘贴**:终端原生与 bracketed paste 保留普通文本与换行,粘贴内容到达时不会被误当 `Enter` 提交。Windows 终端以 win32-input-mode 键记录投递粘贴时,泄漏进载荷的记录流会按 `Uc` 还原成它编码的字符(含换行),因此输入框行数与粘贴内容一致;只有没有字符语义的记录才被剥离(多行粘贴不再留下零散 `_`),且完整记录总是先于无 ESC 尾部消费——不会连带删掉紧随的载荷字符。粘贴的 CRLF 折叠为单个换行;普通文本中的真实下划线与 bracketed paste 内容不受影响。 +**粘贴**:启动页支持将剪贴板图片和粘贴的图片路径暂存为 `[Image #N]` 附件,按 `Enter` 随首条消息发送;不接受图片的命令会保留草稿与附件。图片 chip 与聊天页一致:方向键跨过整张图片,末端 Backspace 或起点 Delete 整体删除,Ctrl+W 也不会拆开图片占位符。启动页粘贴的多行文字会折叠为一行。在聊天页,终端原生与 bracketed paste 保留普通文本与换行,粘贴内容到达时不会被误当 `Enter` 提交。Windows 终端以 win32-input-mode 键记录投递粘贴时,泄漏进载荷的记录流会按 `Uc` 还原成它编码的字符(含换行),因此输入框行数与粘贴内容一致;只有没有字符语义的记录才被剥离(多行粘贴不再留下零散 `_`),且完整记录总是先于无 ESC 尾部消费——不会连带删掉紧随的载荷字符。粘贴的 CRLF 折叠为单个换行;普通文本中的真实下划线与 bracketed paste 内容不受影响。 **拖放文件**:原生 Windows 的桌面拖放(Windows Terminal / OpenConsole)以 OSC 8 超链接到达;解析器在粘贴载荷卫生之前把其中的 `file://` URI 还原为解码后的本地路径,`]8;id=…;` 参数残渣不会进入草稿。图片路径进入既有图片 stage 管线;其他文件作为可引用路径插入(含空白路径以 composer 的单 token 引号形式 `"…"` 到达)。只还原 `file://` URI 且 fail-closed:远程 authority/UNC、一个载荷里带多个不同 URI、或带多个 token 的 URI 一律拒绝并保持字面文本,不做猜测。 diff --git a/scripts/verify-launchpad-onboarding-chat.tsx b/scripts/verify-launchpad-onboarding-chat.tsx index 951c08832..c60776e29 100644 --- a/scripts/verify-launchpad-onboarding-chat.tsx +++ b/scripts/verify-launchpad-onboarding-chat.tsx @@ -2,7 +2,7 @@ * verify-launchpad-onboarding-chat — launchpad and onboarding flows through Chat. * * Covers overlays, draft submission, onboarding persistence, model/effort/preset/ - * permission selection, command completion and recovery mounting. + * permission selection, command completion, recovery mounting and image paste. * * A. `/setup` 打开向导:落地页里敲 `/setup` 回车 → 向导盖在落地页之上; * Esc 跳过**回到落地页**(第七版:不再收掉落地页落到对话页)。 @@ -30,7 +30,9 @@ process.env.FORCE_COLOR = '3' process.env.DSH_TUI_THEME = 'dark' process.env.DSH_TUI_LANG = 'zh' -import { existsSync, mkdtempSync, readFileSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { pathToFileURL } from 'node:url' +import type { ComposerImageRef, StagedImageInput } from '../adapter/ports/channel-view.js' import { join } from 'node:path' import { PassThrough, Writable } from 'node:stream' import React from 'react' @@ -1498,6 +1500,334 @@ const AC4_CUT_PRESET = 'Standard (Git Bash' } } + +// ── Z. 启动页图片:真实粘贴输入、附件随首句提交与草稿生命周期 ── +{ + const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') + const imagePath = join(fakeHome, 'launchpad image.png') + writeFileSync(imagePath, png) + const pastePath = `\x1b[200~"${imagePath}"\x1b[201~` + const imageHarness = (waitForStage?: () => Promise) => { + const staged = new Set() + const discarded: string[] = [] + const inputs: StagedImageInput[] = [] + const submissions: { text: string; images: readonly ComposerImageRef[] }[] = [] + const patch = { + stagedImageGeneration: () => 0, + stagedImageLimits: () => ({ maxImageBytes: 4096, maxImagesPerMessage: 2 }), + async stageComposerImage(input: StagedImageInput) { + inputs.push(input) + await waitForStage?.() + const stageId = `launchpad-stage-${inputs.length}` + staged.add(stageId) + return { stageId } + }, + hasStagedImage: (stageId: string) => staged.has(stageId), + discardStagedImage(stageId: string) { staged.delete(stageId); discarded.push(stageId) }, + submit(text: string, images: readonly ComposerImageRef[] = []) { submissions.push({ text, images }) }, + } + return { patch, staged, discarded, inputs, submissions } + } + for (const fullscreen of [false, true]) { + for (const columns of [120, 48]) { + const fixture = imageHarness() + const chat = await mountChat({ launchpadOnBoot: true, columns }, fixture.patch, { fullscreen }) + try { + await settle(() => chat.screen().includes('说点什么')) + await chat.type('描述') + await chat.send(pastePath) + check(`Z1[${fullscreen ? 'fullscreen' : 'inline'} ${columns}] 图片路径在启动页暂存,粘贴不自动提交`, + await settled(() => chat.screen().includes('[Image #1]')) && fixture.submissions.length === 0 + && fixture.inputs.length === 1 && Buffer.from(fixture.inputs[0]!.data).equals(png), chat.screen()) + if (columns === 120) { + for (let i = 0; i < 5; i++) await chat.send('\x1b[B') + await chat.send('\r') + check('Z2 带图片草稿可打开会话管理', await settled(() => chat.screen().includes('新建会话')), chat.screen()) + await chat.send('\x1b') + check('Z3 返回启动页保留图片绑定', await settled(() => chat.screen().includes('[Image #1]')) && fixture.staged.size === 1, chat.screen()) + } + await chat.type('图') // 返回后将焦点交还输入框。 + await chat.send('\r') + check(`Z4[${fullscreen ? 'fullscreen' : 'inline'} ${columns}] 首句携带正确附件,仅提交一次`, + await settled(() => fixture.submissions.length === 1) + && fixture.submissions[0]!.text === '描述[Image #1] 图' + && fixture.submissions[0]!.images[0]?.stageId === 'launchpad-stage-1' + && fixture.submissions[0]!.images[0]?.token === '[Image #1]' + && fixture.staged.size === 1, JSON.stringify(fixture.submissions)) + } finally { await chat.unmount() } + } + } + { + const fixture = imageHarness() + const chat = await mountChat({ launchpadOnBoot: true }, fixture.patch) + await chat.send(pastePath) + await settle(() => chat.screen().includes('[Image #1]')) + await chat.send('\x1b') + check('Z5 Esc 清空图片草稿并释放附件', await settled(() => fixture.staged.size === 0 && chat.screen().includes('说点什么'))) + await chat.send(pastePath) + await settle(() => fixture.staged.size === 1) + await chat.unmount() + check('Z6 Chat 卸载释放未发送图片', fixture.staged.size === 0 && fixture.discarded.length === 2) + } + { + let releaseStage!: () => void + const stageWait = new Promise(resolve => { releaseStage = resolve }) + const fixture = imageHarness(() => stageWait) + const chat = await mountChat({ launchpadOnBoot: true }, fixture.patch) + await chat.type('待清空') + await chat.send(pastePath) + await settle(() => fixture.inputs.length === 1) + await chat.send('\x1b') + releaseStage() + check('Z7 在途图片不复活已清空草稿,完成后释放附件', + await settled(() => fixture.discarded.length === 1) && fixture.staged.size === 0 && !chat.screen().includes('[Image #1]')) + await chat.unmount() + } + + for (const action of ['submit', 'switch', 'typing']) { + let releaseStage!: () => void + const stageWait = new Promise(resolve => { releaseStage = resolve }) + const fixture = imageHarness(() => stageWait) + const chat = await mountChat({ launchpadOnBoot: true }, fixture.patch) + try { + await chat.type('原文') + await chat.send(pastePath) + await settle(() => fixture.inputs.length === 1) + if (action === 'submit') await chat.send('\r') + else if (action === 'switch') { + chat.channel.agentBindingGeneration = 1 + await (chat.channel.setEffort as (id: string) => Promise)('max') + } else await chat.type('继续输入') + releaseStage() + if (action === 'typing') { + check('Z11 异步暂存期间的新文字保留,图片落在最新光标处', + await settled(() => chat.screen().includes('原文继续输入[Image #1]'))) + } else { + check(`Z12[${action}] 提交或会话替换后,旧图片完成时释放且不插入`, + await settled(() => fixture.discarded.length === 1) && fixture.staged.size === 0 + && (action !== 'submit' || (fixture.submissions.length === 1 && fixture.submissions[0]!.images.length === 0))) + } + } finally { releaseStage(); await chat.unmount() } + } + { + const fixture = imageHarness() + const chat = await mountChat({ launchpadOnBoot: true }, fixture.patch) + try { + await chat.send(pastePath) + await settle(() => chat.screen().includes('[Image #1]')) + await chat.send(pastePath) + check('Z13 连续粘贴图片按顺序绑定到不同占位符', + await settled(() => chat.screen().includes('[Image #1] [Image #2]')) && fixture.inputs.length === 2) + await chat.send(pastePath) + check('Z14 累计附件数达到上限后拒绝继续暂存', + await settled(() => chat.screen().includes('图片数量超过当前配置的单条消息上限')) && fixture.inputs.length === 2, chat.screen()) + } finally { await chat.unmount() } + } + + { + let releaseStage!: () => void + const stageWait = new Promise(resolve => { releaseStage = resolve }) + let stages = 0 + const fixture = imageHarness(() => ++stages === 2 ? stageWait : Promise.resolve()) + const chat = await mountChat({ launchpadOnBoot: true }, fixture.patch) + try { + await chat.type('/help ') + await chat.send(pastePath) + await settle(() => chat.screen().includes('[Image #1]')) + await chat.send(pastePath) + await settle(() => fixture.inputs.length === 2) + await chat.send('\r') + await settle(() => chat.screen().includes('/help 不接受图片;草稿已保留')) + releaseStage() + check('Z18 命令拒绝不取消同一草稿正在暂存的图片', + await settled(() => chat.screen().includes('/help [Image #1] [Image #2]')) + && fixture.staged.size === 2 && fixture.discarded.length === 0 && fixture.submissions.length === 0, + chat.screen()) + } finally { releaseStage(); await chat.unmount() } + } + // 命令拒绝图片时,草稿仍可编辑并作为普通消息重新发送。 + for (const name of ['help', 'plaincmd', 'deepseek']) { + const fixture = imageHarness() + const commandCalls: string[] = [] + const chat = await mountChat({ launchpadOnBoot: true }, { + ...fixture.patch, + commandList: [...LOCAL_COMMANDS, { name: 'plaincmd', description: 'Fixture command without images', external: true }], + async runExternalCommandOutcome(command: string) { + commandCalls.push(command) + return { kind: 'success', text: '', consumeDraft: true } + }, + }) + try { + await chat.type(`/${name} `) + await chat.send(pastePath) + await settle(() => chat.screen().includes('[Image #1]')) + await chat.send('\r') + const refused = await settled(() => chat.screen().includes(`/${name} 不接受图片;草稿已保留`)) + check(`Z15[${name}] 不接受图片的命令显示提示,并保留草稿和附件`, + refused && chat.screen().includes(`/${name} [Image #1]`) + && fixture.staged.size === 1 && fixture.discarded.length === 0 + && fixture.submissions.length === 0 && commandCalls.length === 0 + && !chat.screen().includes(HELP_MARK), chat.screen()) + if (refused) { + await chat.send('\x1b[H') + for (let i = 0; i < name.length + 2; i++) await chat.send('\x1b[3~') + await chat.send('\r') + check(`Z16[${name}] 删除命令前缀后仍能提交原图片`, + await settled(() => fixture.submissions.length === 1) + && fixture.submissions[0]!.text === '[Image #1]' + && fixture.submissions[0]!.images[0]?.stageId === 'launchpad-stage-1', + JSON.stringify(fixture.submissions)) + } + } finally { await chat.unmount() } + } + for (const route of ['external', 'skill', 'unknown']) { + const fixture = imageHarness() + const commandCalls: { name: string; input: string; images: readonly ComposerImageRef[] }[] = [] + const descriptor = route === 'external' + ? { name: 'vision', description: 'Fixture image command', external: true, acceptsImages: true } + : { name: 'vision', description: 'Fixture model skill', skill: true } + const chat = await mountChat({ launchpadOnBoot: true }, { + ...fixture.patch, + commandList: route === 'unknown' ? LOCAL_COMMANDS : [...LOCAL_COMMANDS, descriptor], + async runExternalCommandOutcome(name: string, input: string, images: readonly ComposerImageRef[]) { + commandCalls.push({ name, input, images }) + return { kind: 'success', text: '', consumeDraft: true } + }, + }) + try { + await chat.type('/vision ') + await chat.send(pastePath) + await settle(() => chat.screen().includes('[Image #1]')) + await chat.send('\r') + const delivered = route === 'external' + ? await settled(() => commandCalls.length === 1) + && commandCalls[0]!.name === 'vision' && commandCalls[0]!.input.trim() === '[Image #1]' + && commandCalls[0]!.images[0]?.stageId === 'launchpad-stage-1' && fixture.submissions.length === 0 + : await settled(() => fixture.submissions.length === 1) + && fixture.submissions[0]!.text === '/vision [Image #1]' + && fixture.submissions[0]!.images[0]?.stageId === 'launchpad-stage-1' && commandCalls.length === 0 + check(`Z17[${route}] 支持图片的命令及模型路由携带原附件发送`, delivered, + JSON.stringify({ commandCalls, submissions: fixture.submissions })) + } finally { await chat.unmount() } + } + + for (const fullscreen of [false, true]) { + for (const columns of [120, 48]) { + const fixture = imageHarness() + const chat = await mountChat({ launchpadOnBoot: true, columns }, fixture.patch, { fullscreen }) + const mode = `${fullscreen ? 'fullscreen' : 'inline'} ${columns}` + const prefix = '中😀 ' + (columns === 48 ? '字 '.repeat(18) : '') + const token = '[Image #1]' + // Match actual cells: xterm's default emoji width differs from stringWidth. + const findToken = (): { col: number; row: number } | null => { + const buffer = chat.term.buffer.active + for (let row = 0; row < chat.term.rows; row++) { + const line = buffer.getLine(buffer.baseY + row) + for (let col = 0; col <= chat.term.cols - token.length; col++) { + if (Array.from(token).every((char, i) => line?.getCell(col + i)?.getChars() === char)) { + return { col: col + 1, row: row + 1 } + } + } + } + return null + } + const cellAt = (pos: { col: number; row: number }, offset = 0) => + chat.term.buffer.active.getLine(chat.term.buffer.active.baseY + pos.row - 1)?.getCell(pos.col - 1 + offset) + const caretAt = (pos: { col: number; row: number }, offset = 0) => + chat.term.buffer.active.cursorX === pos.col - 1 + offset && chat.term.buffer.active.cursorY === pos.row - 1 + const wholeInverse = (pos: { col: number; row: number }) => + Array.from({ length: token.length }, (_, i) => (cellAt(pos, i)?.isInverse() ?? 0) !== 0).every(Boolean) + const paste = async () => { + await chat.send(pastePath) + if (!await settled(() => chat.screen().includes(token))) throw new Error('atomic image fixture did not stage') + } + try { + await chat.type(prefix) + await paste() + await chat.type('尾') + const pos = findToken()! + check(`Z19[${mode}] 图片 token 使用聊天页相同的 chip 颜色`, + cellAt(pos)?.getFgColor() !== cellAt(pos, -1)?.getFgColor() && !cellAt(pos)?.isFgDefault(), chat.screen()) + await chat.send('\x1b[D') + await chat.send('\x1b[D') + check(`Z20[${mode}] 光标到图片末端时保持 token 完整`, + await settled(() => caretAt(findToken()!, token.length)), chat.screen()) + await chat.send('\x1b[D') + check(`Z21[${mode}] ← 一次跨过图片,整段高亮`, + await settled(() => { + const current = findToken() + return current !== null && caretAt(current) && wholeInverse(current) + }), chat.screen()) + await chat.send('\x1b[C') + check(`Z22[${mode}] → 一次跨到图片末端`, + await settled(() => caretAt(findToken()!, token.length)), chat.screen()) + await chat.send('\x7f') + check(`Z23[${mode}] Backspace 删除整张图片并释放附件`, + await settled(() => !chat.screen().includes('Image #') && fixture.staged.size === 0 && fixture.discarded.length === 1), chat.screen()) + await paste() + await chat.send('\x1b[D') + await chat.send('\x1b[D') + await chat.send('\x1b[3~') + check(`Z24[${mode}] Delete 删除整张图片并释放附件`, + await settled(() => !chat.screen().includes('Image #') && fixture.staged.size === 0 && fixture.discarded.length === 2), chat.screen()) + await paste() + await chat.send('\x17') + check(`Z25[${mode}] Ctrl+W 不拆开图片 token`, + await settled(() => !chat.screen().includes('Image #') && fixture.staged.size === 0 && fixture.discarded.length === 3), chat.screen()) + await chat.type('[Image #9]') + await chat.send('\x7f') + check(`Z26[${mode}] 没有附件绑定的字面 token 仍逐字编辑`, + await settled(() => chat.screen().includes('[Image #9') && !chat.screen().includes('[Image #9]')), chat.screen()) + await chat.send('\r') + check(`Z27[${mode}] 普通文字保留,删除的图片不会随消息提交`, + await settled(() => fixture.submissions.length === 1) + && fixture.submissions[0]!.text.startsWith(prefix) && fixture.submissions[0]!.text.endsWith('尾') + && fixture.submissions[0]!.images.length === 0, JSON.stringify(fixture.submissions)) + } finally { await chat.unmount() } + } + } + if (process.platform === 'linux') { + const stubDir = mkdtempSync(join(fakeHome, 'launchpad-clipboard-')) + const offerPath = join(stubDir, 'offer.json') + writeFileSync(join(stubDir, 'wl-paste'), `#!${process.execPath}\n` + ` +const fs = require('node:fs') +const offer = JSON.parse(fs.readFileSync(${JSON.stringify(offerPath)}, 'utf8')) +const mime = offer.kind === 'image' ? 'image/png' : 'text/uri-list' +if (process.argv.includes('--version')) process.stdout.write('stub') +else if (process.argv.includes('--list-types')) process.stdout.write(mime + '\\n') +else process.stdout.write(offer.kind === 'image' ? Buffer.from(offer.png, 'base64') : offer.uris) +`, { mode: 0o755 }) + const savedEnv = { PATH: process.env.PATH, WAYLAND_DISPLAY: process.env.WAYLAND_DISPLAY, DISPLAY: process.env.DISPLAY } + process.env.PATH = stubDir + process.env.WAYLAND_DISPLAY = 'launchpad-test' + delete process.env.DISPLAY + try { + for (const kind of ['image', 'files']) { + writeFileSync(offerPath, JSON.stringify({ kind, png: png.toString('base64'), uris: pathToFileURL(imagePath).href + '\n' })) + const fixture = imageHarness() + const chat = await mountChat({ launchpadOnBoot: true }, fixture.patch) + try { + await settle(() => chat.screen().includes('说点什么')) + await chat.send('\x16') + check(`Z8[${kind}] Ctrl+V 在启动页附加图片`, await settled(() => chat.screen().includes('[Image #1]')) && fixture.submissions.length === 0, chat.screen()) + if (kind === 'image') { + check('Z9 剪贴板位图暂存后删除临时文件', await settled(() => fixture.inputs.length === 1 && !existsSync(fixture.inputs[0]!.path!))) + } + await chat.send('\r') + check(`Z10[${kind}] 仅图片首句带附件提交`, await settled(() => fixture.submissions.length === 1) + && fixture.submissions[0]!.text === '[Image #1]' && fixture.submissions[0]!.images.length === 1, JSON.stringify(fixture.submissions)) + } finally { await chat.unmount() } + } + } finally { + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + rmSync(stubDir, { recursive: true, force: true }) + } + } +} + if (failures === 0) console.log(`\nverify-launchpad-onboarding-chat: ${checks} checks, all passed`) else console.error(`\nverify-launchpad-onboarding-chat: ${failures} of ${checks} checks FAILED`) process.exit(failures === 0 ? 0 : 1) diff --git a/src/components/PromptInput.tsx b/src/components/PromptInput.tsx index 7fbe7d37e..93e627e27 100644 --- a/src/components/PromptInput.tsx +++ b/src/components/PromptInput.tsx @@ -2,11 +2,11 @@ import React from 'react' // 粘贴/清洗语义已抽到 utils/inputPaste.ts(方案 B:PromptInput 与 Launchpad 共享; // 行为逐字节不变,只是搬了家)。 import { sanitizeEditableText, sanitizePastedText } from '../utils/inputPaste.js' -import { constants as fsConstants } from 'node:fs' -import { open, unlink } from 'node:fs/promises' +import { unlink } from 'node:fs/promises' import { basename, resolve } from 'node:path' import { t } from '../i18n.js' import { Box, Text, useInput, useTerminalSize, useTheme, type ScrollBoxHandle } from '../ui.js' +import { COMPOSER_IMAGE_TOKEN, imageTokenSpans, imageTokenAround, snapOffImageToken, expandImageTokenRange, type ImageTokenSpan } from './composerImageTokens.js' import { EffortChargeGlyph } from './EffortChargeGlyph.js' import { EffortInputBorder, type InputBorderLabel } from './EffortInputBorder.js' import { EffortTierBadge } from './EffortTierBadge.js' @@ -25,7 +25,7 @@ import { truncateToWidth } from '../ink/truncateToWidth.js' import { getGraphemeSegmenter } from '../utils/intl.js' import { draftWordRangeAt, isDraftWordBoundary } from '../utils/draftWordBoundary.js' import { formatClipboardInsert, readClipboard } from '../utils/clipboard.js' -import { imagePathMediaType, parsePastedImagePath, stageClipboardFilePaths } from '../utils/pastedImagePath.js' +import { imagePathMediaType, parsePastedImagePath, readBoundedRegularFile, stageClipboardFilePaths } from '../utils/pastedImagePath.js' import { editInExternalEditor } from '../utils/externalEditor.js' import { setPromptEditorNode, EditorButton } from './PromptEditor.js' import type { ChannelUi as Channel } from '../adapter/channel/ui-policy.js' @@ -225,61 +225,11 @@ const FOLD_MIN_CHARS = 600 const isBigInput = (text: string): boolean => text.split('\n').length >= FOLD_MIN_LINES || text.length >= FOLD_MIN_CHARS - -const COMPOSER_IMAGE_TOKEN = /\[Image #\d+\]/gu - /** Format label for one image media type, matching the image preview card's * title (`JPEG`, `PNG`, `WEBP`, `GIF`). */ const mediaTypeLabel = (mediaType: string): string => mediaType.replace(/^image\//u, '').replace(/\+xml$/u, '').toUpperCase() -/** One `[Image #N]` occurrence: [start, end) offsets into the draft. */ -interface ImageTokenSpan { - readonly start: number - readonly end: number - readonly token: string -} - -/** Every `[Image #N]` in `text`, in order. */ -function imageTokenSpans(text: string): ImageTokenSpan[] { - const spans: ImageTokenSpan[] = [] - for (const match of text.matchAll(COMPOSER_IMAGE_TOKEN)) { - const start = match.index ?? 0 - spans.push({ start, end: start + match[0].length, token: match[0] }) - } - return spans -} - -/** The span whose interior (exclusive of both edges) contains `offset`. */ -function imageTokenAround(spans: readonly ImageTokenSpan[], offset: number): ImageTokenSpan | undefined { - return spans.find(span => span.start < offset && offset < span.end) -} - -/** - * A caret never rests inside a staged token: an offset in a span's interior - * moves to the edge `prefer` names — `'start'` (the token becomes the caret - * cluster), `'end'`, or whichever is nearer. - */ -function snapOffImageToken( - spans: readonly ImageTokenSpan[], - offset: number, - prefer: 'start' | 'end' | 'nearest', -): number { - const span = imageTokenAround(spans, offset) - if (span === undefined) return offset - if (prefer === 'start') return span.start - if (prefer === 'end') return span.end - return offset - span.start < span.end - offset ? span.start : span.end -} - -/** Expand a deletion or selection to include every staged token it touches. */ -function expandImageTokenRange(spans: readonly ImageTokenSpan[], start: number, end: number) { - return { - start: snapOffImageToken(spans, start, 'start'), - end: snapOffImageToken(spans, end, 'end'), - } -} - /** Capabilities referenced by `text`, in first occurrence order. A raw token * restored from disk/history has no sidecar entry and therefore stays inert. */ export function composerImageRefsForText( @@ -298,32 +248,6 @@ export function composerImageRefsForText( return refs } -/** Read one regular file through one descriptor, bounded to `maxBytes + 1`. - * The extra byte detects a file that grows after fstat; a short read detects - * shrinkage. This avoids stat(path) → readFile(path)'s path-swap TOCTOU and - * never allocates from an untrusted size before the profile limit is checked. */ -async function readBoundedRegularFile(path: string, maxBytes: number): Promise { - // O_NONBLOCK keeps a pasted FIFO/device path from parking the UI before - // fstat can reject it; regular-file reads are unchanged. - const file = await open(path, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK) - try { - const info = await file.stat() - if (!info.isFile()) throw new Error(`${basename(path)} is not a regular file`) - if (info.size > maxBytes) throw new Error(`image exceeds this profile's per-image size limit`) - const data = Buffer.allocUnsafe(info.size + 1) - let offset = 0 - while (offset < data.byteLength) { - const { bytesRead } = await file.read(data, offset, data.byteLength - offset, offset) - if (bytesRead === 0) break - offset += bytesRead - } - if (offset !== info.size) throw new Error(`${basename(path)} changed while it was being read`) - return data.subarray(0, offset) - } finally { - await file.close() - } -} - /** Index of the word boundary at or before `cursor` (readline alt+b). */ function wordBoundaryLeft(text: string, cursor: number): number { let index = cursor diff --git a/src/components/SearchBox.tsx b/src/components/SearchBox.tsx index bc475546f..eb260cfc9 100644 --- a/src/components/SearchBox.tsx +++ b/src/components/SearchBox.tsx @@ -9,6 +9,7 @@ import measureElement from '../ink/measure-element.js' import { useDeclaredCursor, useNativeCursor } from '../ink/hooks/use-declared-cursor.js' import { useTerminalSize } from '../ink/hooks/use-terminal-size.js' import { stringWidth } from '../ink/stringWidth.js' +import { snapOffImageToken, type ImageTokenSpan } from './composerImageTokens.js' /** * Window a single-line query around the caret so the visible slice fits @@ -24,7 +25,8 @@ function windowQuery( query: string, offset: number, avail: number, -): { before: string; at: string; after: string; caretColumn: number } { + imageSpans: readonly ImageTokenSpan[], +): { before: string; at: string; after: string; caretColumn: number; start: number; caret: number } { const budget = Math.max(avail, 1) let caret = Math.max(0, Math.min(offset, query.length)) if ( @@ -37,8 +39,10 @@ function windowQuery( ) { caret-- // mid-surrogate: snap to the emoji's start } + caret = snapOffImageToken(imageSpans, caret, 'nearest') const beforeChars = [...query.slice(0, caret)] - const at = caret < query.length ? [...query.slice(caret)][0]! : ' ' + const tokenAtCaret = imageSpans.find(span => span.start === caret) + const at = tokenAtCaret !== undefined ? tokenAtCaret.token : caret < query.length ? [...query.slice(caret)][0]! : ' ' const atWidth = Math.max(1, stringWidth(at)) let caretColumn = 0 for (const ch of beforeChars) caretColumn += stringWidth(ch) @@ -47,6 +51,10 @@ function windowQuery( caretColumn -= stringWidth(beforeChars[start]!) start++ } + const before = beforeChars.slice(start).join('') + const rawStart = caret - before.length + const windowStart = snapOffImageToken(imageSpans, rawStart, 'end') + caretColumn -= stringWidth(query.slice(rawStart, windowStart)) let rest = budget - caretColumn - atWidth let after = '' for (const ch of [...query.slice(caret + at.length)]) { @@ -55,7 +63,8 @@ function windowQuery( after += ch rest -= w } - return { before: beforeChars.slice(start).join(''), at, after, caretColumn } + const end = snapOffImageToken(imageSpans, caret + at.length + after.length, 'start') + return { before: query.slice(windowStart, caret), at, after: query.slice(caret + at.length, end), caretColumn, start: windowStart, caret } } /** @@ -79,6 +88,7 @@ export function SearchBox({ borderless = false, caretBlink = true, placeholderAlign = 'right', + imageSpans = [], }: { query: string placeholder?: string @@ -88,6 +98,8 @@ export function SearchBox({ width?: number | string cursorOffset?: number borderless?: boolean + /** Capability-backed image chips, sharing the chat composer's atomic geometry. */ + imageSpans?: readonly ImageTokenSpan[] /** * 空输入 + 焦点态那一行里占位文案的对齐(2026-10 落地页第四版新增): * `right`(缺省,历史行为)贴框右缘;`left` 紧跟前缀。 @@ -105,7 +117,7 @@ export function SearchBox({ const cursorTheme = getTheme(themeName) const cursorColor = cursorTheme.cursor ?? '' const cursorGlyph = cursorGlyphColor(cursorTheme) - const caretCell = (text: string): React.ReactNode => nativeCursor || !caretBlink + const caretCell = (text: string, image = false): React.ReactNode => !image && (nativeCursor || !caretBlink) ? {text} : cursorColor === '' ? {text} @@ -129,7 +141,20 @@ export function SearchBox({ const prefixText = prefix === '' ? '' : `${prefix} ` const prefixWidth = stringWidth(prefixText) - const win = windowQuery(query, offset, contentWidth - prefixWidth) + const win = windowQuery(query, offset, contentWidth - prefixWidth, imageSpans) + const renderQueryPart = (text: string, start: number): React.ReactNode => { + if (imageSpans.length === 0) return {text} + const pieces: React.ReactNode[] = [] + let pos = start + for (const span of imageSpans) { + if (span.start < start || span.end > start + text.length) continue + if (pos < span.start) pieces.push({query.slice(pos, span.start)}) + pieces.push({span.token}) + pos = span.end + } + if (pos < start + text.length) pieces.push({query.slice(pos, start + text.length)}) + return pieces.length === 0 ? {text} : <>{pieces} + } // Park the native terminal cursor at the caret so IME preedit (pinyin) // renders inline at the input instead of the screen's bottom row (same @@ -184,14 +209,14 @@ export function SearchBox({ // the caret glyph's style as its blink phase advances. content = ( <> - {win.before} - {caretCell(win.at)} - {win.after !== '' && {win.after}} + {renderQueryPart(win.before, win.start)} + {caretCell(win.at, imageSpans.some(span => span.start === win.caret))} + {win.after !== '' && renderQueryPart(win.after, win.caret + win.at.length)} ) } } else { - content = query ? {query} : {placeholder} + content = query ? renderQueryPart(query, 0) : {placeholder} } return ( diff --git a/src/components/composerImageTokens.ts b/src/components/composerImageTokens.ts new file mode 100644 index 000000000..e557c2042 --- /dev/null +++ b/src/components/composerImageTokens.ts @@ -0,0 +1,49 @@ +/** Shared atomic geometry for capability-backed composer image tokens. */ +export const COMPOSER_IMAGE_TOKEN = /\[Image #\d+\]/gu + +/** One `[Image #N]` occurrence: [start, end) offsets into the draft. */ +export interface ImageTokenSpan { + readonly start: number + readonly end: number + readonly token: string +} + +/** Every `[Image #N]` in `text`, in order. */ +export function imageTokenSpans(text: string): ImageTokenSpan[] { + const spans: ImageTokenSpan[] = [] + for (const match of text.matchAll(COMPOSER_IMAGE_TOKEN)) { + const start = match.index ?? 0 + spans.push({ start, end: start + match[0].length, token: match[0] }) + } + return spans +} + +/** The span whose interior (exclusive of both edges) contains `offset`. */ +export function imageTokenAround(spans: readonly ImageTokenSpan[], offset: number): ImageTokenSpan | undefined { + return spans.find(span => span.start < offset && offset < span.end) +} + +/** + * A caret never rests inside a staged token: an offset in a span's interior + * moves to the edge `prefer` names — `'start'` (the token becomes the caret + * cluster), `'end'`, or whichever is nearer. + */ +export function snapOffImageToken( + spans: readonly ImageTokenSpan[], + offset: number, + prefer: 'start' | 'end' | 'nearest', +): number { + const span = imageTokenAround(spans, offset) + if (span === undefined) return offset + if (prefer === 'start') return span.start + if (prefer === 'end') return span.end + return offset - span.start < span.end - offset ? span.start : span.end +} + +/** Expand a deletion or selection to include every staged token it touches. */ +export function expandImageTokenRange(spans: readonly ImageTokenSpan[], start: number, end: number) { + return { + start: snapOffImageToken(spans, start, 'start'), + end: snapOffImageToken(spans, end, 'end'), + } +} diff --git a/src/screens/Chat.tsx b/src/screens/Chat.tsx index 89021b4be..9a8f33f9b 100644 --- a/src/screens/Chat.tsx +++ b/src/screens/Chat.tsx @@ -73,12 +73,12 @@ import type { TimelineSnapshot } from '../ink/timeline-rail.js' import { normalizeScrollGutter } from '../tuiDisplayPrefs.js' import { OverlayAbove } from '../components/OverlayAbove.js' import { TooltipLayer } from '../components/Tooltip.js' -import { PromptInput, type PromptController } from '../components/PromptInput.js' +import { PromptInput, composerImageRefsForText, type PromptController } from '../components/PromptInput.js' import { turnUsageParts } from '../components/TurnUsageRow.js' import { AgentTranscriptScene } from './AgentTranscriptScene.js' import { agentViewStore } from '../components/sidePanel/agentViewStore.js' import { agentComposeTargetOf, type AgentComposeTarget, type AgentMessageView, type AgentViewSource } from '../components/messages/agentTeam.js' -import type { PromptDraftCache } from '../components/promptDraftCache.js' +import { resolveBindingGeneration, type PromptDraftCache } from '../components/promptDraftCache.js' import type { InjectController } from '../dsh-adapter/inject-channel.js' import { PromptEditorLayer, usePromptEditorOpen } from '../components/PromptEditor.js' import { GoalTodoPanel } from '../components/GoalTodoPanel.js' @@ -758,6 +758,27 @@ export function Chat({ const [launchpadDraft, setLaunchpadDraft] = React.useState('') const [launchpadCaret, setLaunchpadCaret] = React.useState(0) const [launchpadFocus, setLaunchpadFocus] = React.useState(-1) + const launchpadImagesRef = React.useRef(new Map()) + const changeLaunchpadDraft = (text: string, cursor: number): void => { + const live = new Set(composerImageRefsForText(text, launchpadImagesRef.current).map(image => image.token)) + for (const [token, stageId] of launchpadImagesRef.current) { + if (live.has(token)) continue + launchpadImagesRef.current.delete(token) + channel.discardStagedImage(stageId) + } + setLaunchpadDraft(text) + setLaunchpadCaret(cursor) + } + const launchpadBindingGeneration = resolveBindingGeneration(channel) + React.useEffect(() => { + setLaunchpadDraft('') + setLaunchpadCaret(0) + const bindings = launchpadImagesRef.current + return () => { + for (const stageId of bindings.values()) channel.discardStagedImage(stageId) + bindings.clear() + } + }, [channel, channel.agentId, launchpadBindingGeneration]) /** * 「整屏盖启动页」的显式授权(第七版防御位,用户实测回归:启动页一闪而过 * 被顶掉)。整屏分支排在落地页**之前**,任何一处状态在开机后被异步置真 @@ -2455,7 +2476,8 @@ export function Chat({ * THREE cases, and they are genuinely different: * * - a slash command → `runCommand`, the same dispatch a typed command - * takes in the composer. The line is NOT submitted to the model. + * takes in the composer. Completion-only filesystem skills keep + * the composer's model route. * Recognition is the composer's OWN rule (第六版 BUG 1 修复): the merged * command list (locals + plugin/registry commands via channel.commandList) * decides whether the line is a command — isLocalCommandName alone missed @@ -2473,8 +2495,24 @@ export function Chat({ * History is appended for the two non-empty cases (matching what PromptInput * does on submit) so the launchpad's first line is reachable with ↑ later. */ - const submitLaunchpad = (submit: string): void => { + const submitLaunchpad = (submit: string): string | void => { const text = submit.trim() + const images = composerImageRefsForText(text, launchpadImagesRef.current) + const parsed = text.startsWith('/') ? parseCommandName(text) : undefined + const command = parsed === undefined ? undefined : channel.commandList.find(entry => entry.name === parsed.name) + const knownCommand = parsed !== undefined && ( + isLocalCommandName(parsed.name) || isHiddenCommandName(parsed.name) || command !== undefined + ) + const modelRoutedSkill = command?.skill === true && command.external !== true + // Match the composer's image admission before consuming the draft. + if (knownCommand && images.length > 0 && !modelRoutedSkill && command?.acceptsImages !== true) { + return t('command-images-unsupported', { name: parsed.name }) + } + const submitted = new Set(images.map(image => image.stageId)) + for (const stageId of launchpadImagesRef.current.values()) { + if (!submitted.has(stageId)) channel.discardStagedImage(stageId) + } + launchpadImagesRef.current.clear() // 首启时 openHomeOnBoot 与落地页同时为真:会话浏览器已经开着、只是被落地页盖住。 // 提交首句后必须把它收掉,否则用户落到浏览器而不是"草稿就在眼前的对话", // 与本函数 doc 承诺的落点直接矛盾。 @@ -2487,7 +2525,6 @@ export function Chat({ return } void appendHistory(text) - const parsed = text.startsWith('/') ? parseCommandName(text) : undefined // 第八版:/help 在落地页上也是盖屏浮层(补全面板被 Esc 收掉后直接 // Enter 的那条路)——不收落地页、不进对话页。聊天页里 /help 的行为 // 不变(那边不走这个回调)。 @@ -2497,25 +2534,21 @@ export function Chat({ } // 与 composer 的 tryRunCommand 同一条判定:合并命令表(LOCAL_COMMANDS + // channel.commandList 的插件/registry 命令)里有名字才是命令;hidden - // 命令照旧认。判定之外的 / 开头行才走 submit(与聊天页 Enter 行为一致)。 - if (parsed !== undefined && ( - isLocalCommandName(parsed.name) - || isHiddenCommandName(parsed.name) - || channel.commandList.some(entry => entry.name === parsed.name) - )) { + // 命令照旧认。模型路由技能与判定之外的 / 开头行走 submit,与聊天页一致。 + if (parsed !== undefined && knownCommand && !modelRoutedSkill) { if (launchpadScreenCommands.has(parsed.name)) { authorizeLaunchpadCover() } else if (!overlayCommandNames.has(parsed.name)) { setLaunchpadOpen(false) } - void runCommand(parsed.name, parsed.rawInput) + void runCommand(parsed.name, parsed.rawInput, images) return } // 直接发送:与 composer 回车同一条提交路径。发出去之后输入框是空的 // (内容已作为首轮发出,绝不"既发了又留在框里"),也没有交接提示—— // 没有草稿要交,一句"已放进输入框"的 toast 反而是假的。 setLaunchpadOpen(false) - channel.submit(text) + channel.submit(text, images) } /** @@ -5338,8 +5371,7 @@ export function Chat({ // 鼠标等价),浮层收起、人还在启动页(第八版:不许进对话页)。 dispatchOverlay({ type: 'close' }) const filled = '/' + name + ' ' - setLaunchpadDraft(filled) - setLaunchpadCaret(filled.length) + changeLaunchpadDraft(filled, filled.length) setLaunchpadFocus(-1) }} /> @@ -5841,8 +5873,7 @@ export function Chat({ // 不该跟过去)。 setLaunchpadOpen(false) setLaunchpadFocus(-1) - setLaunchpadDraft('') - setLaunchpadCaret(0) + changeLaunchpadDraft('', 0) repaintTranscript() return result }} @@ -5855,8 +5886,7 @@ export function Chat({ // 同 onOpenSession:新建/切工作区会话也是有意导航,落地页一并收。 setLaunchpadOpen(false) setLaunchpadFocus(-1) - setLaunchpadDraft('') - setLaunchpadCaret(0) + changeLaunchpadDraft('', 0) repaintTranscript() } return ok @@ -6105,6 +6135,7 @@ export function Chat({ const launchpad = ( { - setLaunchpadDraft(text) - setLaunchpadCaret(cursor) - }} + onQueryChange={changeLaunchpadDraft} onSubmit={submitLaunchpad} onFocusChange={setLaunchpadFocus} onAction={(action) => { diff --git a/src/screens/Launchpad.tsx b/src/screens/Launchpad.tsx index 5a1f5693a..3357bda4e 100644 --- a/src/screens/Launchpad.tsx +++ b/src/screens/Launchpad.tsx @@ -1,4 +1,6 @@ import React from 'react' +import { unlink } from 'node:fs/promises' +import { basename, resolve } from 'node:path' import { Box, Text, useInput, useTerminalSize, useNativeCursor } from '../ui.js' import { SearchBox } from '../components/SearchBox.js' import { OverlayAbove } from '../components/OverlayAbove.js' @@ -16,9 +18,13 @@ import { pickSplashEgg, type SplashEgg } from '../components/splashEggs.js' import { t } from '../i18n.js' import { isMinimalUiMode } from '../minimalUiMode.js' import { stringWidth } from '../ink/stringWidth.js' -import { isPlainReturn } from '../utils/modifiers.js' +import { isMod, isPlainReturn } from '../utils/modifiers.js' import { actionMatches } from '../utils/keymap.js' import { formatClipboardInsert, readClipboard, type ClipboardRead } from '../utils/clipboard.js' +import { imagePathMediaType, parsePastedImagePath, readBoundedRegularFile, stageClipboardFilePaths } from '../utils/pastedImagePath.js' +import type { ChannelUi } from '../adapter/channel/ui-policy.js' +import { resolveBindingGeneration } from '../components/promptDraftCache.js' +import { imageTokenSpans, snapOffImageToken, expandImageTokenRange } from '../components/composerImageTokens.js' import { collapseToSingleLine, insertSingleLineAt, @@ -420,6 +426,7 @@ export function Launchpad({ commands, onCommandPick, clipboardReader = readClipboard, + imageComposer, /** Tips 自动轮换间隔(第七版;测试缝:无头回归注入短间隔确定性驱动相位)。 */ tipRotateMs = TIP_ROTATE_MS, /** 左下角工作目录铭牌被点击/回车时交给 Chat(开既有的 /workspace 工作区菜单)。 */ @@ -505,15 +512,17 @@ export function Launchpad({ commands?: readonly CommandCompletion[] | undefined /** * 补全面板选中一条(Enter/点击)时交给 Chat 的**完整命令行** - * (如 /setup)。Chat 走 runCommand 执行——与聊天页选中命令 - * 同一条路径,绝不是 submit。 + * (如 /setup)。普通命令经 runCommand 执行,模型路由技能作为消息 + * 提交;拒绝时返回提示并保留草稿。 */ - onCommandPick?: ((commandLine: string) => void) | undefined + onCommandPick?: ((commandLine: string) => string | void) | undefined /** * 剪贴板读取缝(测试打桩用;生产走 `utils/clipboard` 的 `readClipboard`, * Chat 不传这一项)。签名与 `readClipboard` 一致。 */ clipboardReader?: () => Promise + /** Chat owns the bindings so attachments survive a full-screen round trip. */ + imageComposer?: { channel: ChannelUi; bindings: Map } /** Tips 自动轮换间隔(第七版;测试缝,生产用默认 10s)。 */ tipRotateMs?: number /** 左下角工作目录铭牌被点击/焦点环 Enter 时交给 Chat(开 /workspace 菜单)。 */ @@ -538,8 +547,8 @@ export function Launchpad({ onAction: (action: LaunchpadAction) => void /** 输入框内容变化(含光标位置)。 */ onQueryChange: (text: string, cursor: number) => void - /** 提交:整行原文交给 Chat,由它走命令表/模型两条既有的路。 */ - onSubmit: (text: string) => void + /** 提交:交给 Chat 分派;拒绝时返回提示并保留草稿。 */ + onSubmit: (text: string) => string | void /** 空输入时按 Esc / `Ctrl+C`:`sessions` 去看会话,`exit` 走双击退出漏斗。 */ onEscape: (intent: 'sessions' | 'exit') => void /** 点空白处:把焦点收回输入框(不是提交、不是关闭)。 */ @@ -551,7 +560,9 @@ export function Launchpad({ // 光标偏移只在"输入框有焦点"时才有意义;未给(或焦点在快捷入口行)时 // 一律按行尾算——这与 `SearchBox` 自己的 `cursorOffset ?? query.length` // 同一条约定,两处必须一致,否则退格会从"看不见的位置"删字。 - const caret = focusIndex === -1 ? (cursorOffset ?? query.length) : query.length + const boundImageSpans = (text: string) => imageTokenSpans(text).filter(span => imageComposer?.bindings.has(span.token) === true) + const imageSpans = boundImageSpans(query) + const caret = snapOffImageToken(imageSpans, focusIndex === -1 ? (cursorOffset ?? query.length) : query.length, 'nearest') /** 焦点是否在输入框上(-1);参数段(≤-2)与动作入口(≥0)都不算。 */ const inputFocused = focusIndex === -1 @@ -571,7 +582,9 @@ export function Launchpad({ const pickCommand = (commandLine: string): void => { setPaletteDismissedFor('') setPaletteIndex(0) - if (onCommandPick !== undefined) onCommandPick(commandLine) + const notice = onCommandPick?.(commandLine) + if (typeof notice === 'string') showPasteNotice(notice) + else pasteRevisionRef.current += 1 } // ── Tips 轮换(第六版设计 2)───────────────────────────────────────────── @@ -590,7 +603,40 @@ export function Launchpad({ queryRef.current = query const caretRef = React.useRef(caret) caretRef.current = caret + const changeQuery = (text: string, offset: number): void => { + const nextCaret = snapOffImageToken(boundImageSpans(text), offset, + offset < caretRef.current ? 'start' : offset > caretRef.current ? 'end' : 'nearest') + queryRef.current = text + caretRef.current = nextCaret + onQueryChange(text, nextCaret) + } + const deleteRange = (start: number, end: number): void => { + const text = queryRef.current + const range = expandImageTokenRange(boundImageSpans(text), start, end) + changeQuery(text.slice(0, range.start) + text.slice(range.end), range.start) + } const clipboardBusyRef = React.useRef(false) + const pasteRevisionRef = React.useRef(0) + const mountedRef = React.useRef(true) + const imageChainRef = React.useRef>(Promise.resolve()) + React.useEffect(() => { + mountedRef.current = true + return () => { mountedRef.current = false } + }, []) + const capturePasteLease = () => { + const revision = pasteRevisionRef.current + const channel = imageComposer?.channel + const agentId = channel?.agentId + const generation = channel === undefined ? 0 : resolveBindingGeneration(channel) + const imageGeneration = channel?.stagedImageGeneration?.() ?? 0 + return { + imageGeneration, + isCurrent: () => mountedRef.current && revision === pasteRevisionRef.current + && (channel === undefined || (channel.agentId === agentId + && resolveBindingGeneration(channel) === generation + && (channel.stagedImageGeneration?.() ?? 0) === imageGeneration)), + } + } /** 粘贴提示:落地页没有 toast 基础设施,借 Tips 行显示 4 秒(失败不能静默)。 */ const [pasteNotice, setPasteNotice] = React.useState(undefined) const noticeTimerRef = React.useRef | null>(null) @@ -614,9 +660,63 @@ export function Launchpad({ if (clean === '') return // 异步落点守则:读回那一刻的 query/caret 才算数(refs 每次渲染刷新)。 const next = insertSingleLineAt(queryRef.current, caretRef.current, clean) - onQueryChange(next.text, next.caret) + changeQuery(next.text, next.caret) onFocusChange(-1) } + const pasteImagePaths = (paths: readonly string[], lease: ReturnType, bitmap = false): Promise => { + const work = async (): Promise => { + if (!lease.isCurrent()) return + if (imageComposer === undefined) { + showPasteNotice(t('input-image-paste-failed', { err: 'image attachments are unavailable in this profile' })) + return + } + const { channel, bindings } = imageComposer + const limits = channel.stagedImageLimits?.() + const { parts, staged, failure, failureCode } = await stageClipboardFilePaths( + paths, + async path => { + if (!lease.isCurrent()) throw new Error('the draft changed while the image was being staged') + if (limits === undefined) throw new Error('image attachments are unavailable in this profile') + const data = await readBoundedRegularFile(path, limits.maxImageBytes) + if (!lease.isCurrent()) throw new Error('the draft changed while the image was being staged') + return channel.stageComposerImage({ + data, + mediaType: imagePathMediaType(path) ?? 'image/png', + name: basename(path), + path: resolve(path), + }, lease.imageGeneration) + }, + path => formatClipboardInsert({ kind: 'files', paths: [path] }), + Math.max(0, (limits?.maxImagesPerMessage ?? 0) - bindings.size), + ) + if (!lease.isCurrent() || staged.some(handle => channel.hasStagedImage(handle.stageId) !== true)) { + for (const handle of staged) channel.discardStagedImage(handle.stageId) + return + } + let number = 1 + const reserved = new Set(queryRef.current.match(/\[Image #\d+\]/gu) ?? []) + const text = parts.map(part => { + if (part.kind === 'text') return part.value + while (reserved.has(`[Image #${number}]`)) number += 1 + const token = `[Image #${number++}]` + bindings.set(token, part.value.stageId) + return token + }).join(' ') + // A failed bitmap export is temporary and must never become a path reference. + if (!bitmap || staged.length > 0) insertSingleLine(`${text} `) + if (failure !== '') { + showPasteNotice(t('input-image-paste-failed', { err: failureCode === 'image-limit' ? t('input-image-paste-limit') : failure })) + } else if (staged.length > 0) { + const adapted = staged.filter(handle => handle.adjustment !== undefined).length + showPasteNotice(adapted > 0 + ? t('input-images-staged-adapted', { count: staged.length, adapted }) + : t('input-images-staged', { count: staged.length })) + } + } + const queued = imageChainRef.current.then(work, work) + imageChainRef.current = queued.catch(() => undefined) + return queued + } // 光标闪烁相位(第四版修订:**自动呼吸**,不要求终端 focus 事件)。开关只看 // 「输入框是这一屏的焦点目标」(focusIndex < 0)——`isTerminalFocused` 依赖 @@ -727,7 +827,7 @@ export function Launchpad({ * * 编辑能力刻意只做单行编辑器该有的那几样:退格 / Delete / 左右移动 / * Home / End / 粘贴。首屏不是编辑器,用户在上面打的第一句通常就一两个 - * 词;多行、图片、`@` 补全都属于聊天页,敲 Enter 就过去了。 + * 词;多行与 `@` 补全属于聊天页,图片经同一 channel 暂存。 * * `↑/↓` 与 `Tab` 在键帽行上移动焦点;焦点在 `-1` 时这两组键无操作 * (首屏没有可滚的东西)。 @@ -736,12 +836,21 @@ export function Launchpad({ // 选择器盖在这一屏之上时键盘整块让位(Chat 的 overlay 分支处理;Esc 关 // 选择器回到这里)。没有这道闸,选择器分支没消费的键会漏进草稿。 if (inputPaused) return + const query = queryRef.current + const caret = caretRef.current const composing = key.ctrl || key.meta || key.super // 终端原生粘贴(bracketed paste:Ctrl+Shift+V / 右键 / Shift+Insert): // ink 把载荷标成 isPasted 交给 useInput;标记字节(\x1b[200~ / 201~)在 // 解析层已被剥掉,这里的 input 就是纯载荷。换行折叠成单行(见上)。 if (event?.isPasted === true && input.length > 0) { - insertSingleLine(stripBracketedPasteMarkers(input)) + const text = stripBracketedPasteMarkers(input) + const path = parsePastedImagePath(text) + if (path !== null && imageComposer !== undefined) { + const lease = capturePasteLease() + void pasteImagePaths([path], lease).catch(error => { + if (lease.isCurrent()) showPasteNotice(t('input-image-paste-failed', { err: String(error) })) + }) + } else insertSingleLine(text) event.stopImmediatePropagation() return } @@ -750,27 +859,43 @@ export function Launchpad({ // 曾经的 bug:组合键兜底把 Ctrl+V 一口吞掉,粘贴永远是死的。 if (matchesPasteShortcut(input, key)) { if (!clipboardBusyRef.current) { + const lease = capturePasteLease() clipboardBusyRef.current = true void clipboardReader() - .then(content => { - if (content === null) { - showPasteNotice(t('input-clipboard-empty' as never)) - return - } - if (content.kind === 'unavailable') { - showPasteNotice(t(content.wsl === true ? 'input-clipboard-unavailable-wsl' as never : 'input-clipboard-unavailable' as never)) - return - } - if (content.kind === 'image') { - // 单行编辑器不能暂存图片(staged image 是聊天页的能力); - // 插入临时文件路径只会留一条谁也读不懂的路径——提示而不是插入。 - showPasteNotice(t('input-clipboard-unavailable' as never)) - return + .then(async content => { + const temporaryPath = content?.kind === 'image' ? content.path : undefined + try { + if (!lease.isCurrent()) return + if (content === null) { + showPasteNotice(t('input-clipboard-empty' as never)) + return + } + if (content.kind === 'unavailable') { + showPasteNotice(t(content.wsl === true ? 'input-clipboard-unavailable-wsl' as never : 'input-clipboard-unavailable' as never)) + return + } + if (content.kind === 'image') { + if (imagePathMediaType(content.path) === undefined) { + showPasteNotice(t('input-image-format-unsupported')) + return + } + await pasteImagePaths([content.path], lease, true) + return + } + if (content.kind === 'files' && imageComposer !== undefined) { + await pasteImagePaths(content.paths, lease) + return + } + const text = formatClipboardInsert(content) + const path = parsePastedImagePath(text) + if (path !== null && imageComposer !== undefined) await pasteImagePaths([path], lease) + else insertSingleLine(text) + } finally { + if (temporaryPath !== undefined) await unlink(temporaryPath).catch(() => undefined) } - insertSingleLine(formatClipboardInsert(content)) }) .catch(() => { - showPasteNotice(t('input-clipboard-read-failed' as never)) + if (lease.isCurrent()) showPasteNotice(t('input-clipboard-read-failed' as never)) }) .finally(() => { clipboardBusyRef.current = false @@ -805,7 +930,7 @@ export function Launchpad({ if (!key.shift) { const replacement = paletteSelected.replacement setPaletteIndex(0) - onQueryChange(replacement, replacement.length) + changeQuery(replacement, replacement.length) } event.stopImmediatePropagation() return @@ -822,15 +947,17 @@ export function Launchpad({ } } if (key.escape) { + pasteRevisionRef.current += 1 // 空输入时 Esc 去看会话(首屏最常见的下一步);已经有字就只清空它, // 免得辛苦打的半句话被一次性丢掉。 - if (query !== '') onQueryChange('', 0) + if (query !== '') changeQuery('', 0) else onEscape('sessions') event.stopImmediatePropagation() return } if (key.ctrl && (input === 'c' || input === 'd')) { - if (query !== '') onQueryChange('', 0) + pasteRevisionRef.current += 1 + if (query !== '') changeQuery('', 0) else onEscape('exit') event.stopImmediatePropagation() return @@ -857,8 +984,11 @@ export function Launchpad({ onParamPick(activated) } else { const focused = focusIndex >= 0 ? actions[focusIndex] : undefined - if (focused === undefined) onSubmit(query) - else onAction(focused) + if (focused === undefined) { + const notice = onSubmit(query) + if (typeof notice === 'string') showPasteNotice(notice) + else pasteRevisionRef.current += 1 + } else onAction(focused) } event.stopImmediatePropagation() return @@ -898,7 +1028,7 @@ export function Launchpad({ : key.end ? query.length : key.leftArrow ? Math.max(0, prevBoundary(query, at)) : Math.min(query.length, nextBoundary(query, at)) - onQueryChange(query, next) + changeQuery(query, next) event.stopImmediatePropagation() return } @@ -907,21 +1037,29 @@ export function Launchpad({ const at = caret if (key.backspace) { if (at === 0) return - const cut = prevBoundary(query, at) - onQueryChange(query.slice(0, cut) + query.slice(at), cut) + deleteRange(prevBoundary(query, at), at) } else { if (at >= query.length) return - onQueryChange(query.slice(0, at) + query.slice(nextBoundary(query, at)), at) + deleteRange(at, nextBoundary(query, at)) } event.stopImmediatePropagation() return } + if (isMod(key) && input === 'w') { + if (!inputFocused) return + let start = caret + while (start > 0 && /\s/u.test(query[start - 1]!)) start-- + while (start > 0 && !/\s/u.test(query[start - 1]!)) start-- + deleteRange(start, caret) + event.stopImmediatePropagation() + return + } if (composing || key.return) return // 可打印输入(含粘贴整段):接到光标处,交给 SearchBox 的窗口化去滚。 const typed = input.replace(/[\r\n]+/gu, '') if (typed === '') return const at = caret - onQueryChange(query.slice(0, at) + typed + query.slice(at), at + typed.length) + changeQuery(query.slice(0, at) + typed + query.slice(at), at + typed.length) onFocusChange(-1) event.stopImmediatePropagation() }) @@ -979,6 +1117,7 @@ export function Launchpad({ prefix={query.startsWith('/') ? '⌘' : '❯'} width={cardWidth - 4} cursorOffset={caret} + imageSpans={imageSpans} caretBlink={inputFocused ? caretPhase : true} /> diff --git a/src/utils/inputPaste.ts b/src/utils/inputPaste.ts index 55449b346..828eddfce 100644 --- a/src/utils/inputPaste.ts +++ b/src/utils/inputPaste.ts @@ -8,13 +8,13 @@ * 谁都不许再硬编码 `ctrl+v` 字符串。 * * 明确**不**统一的语义(边界见 Launchpad/PromptInput 各自注释): - * - 多行/附件/图片暂存/历史/chip 折叠是聊天页编辑器的能力,落地页是单行 + * - 多行/历史/chip 折叠是聊天页编辑器的能力,落地页是单行 * 首屏输入——多行内容在落地页折叠成一行({@link collapseToSingleLine}), * PromptInput 保留换行; * - 异步落点守则**同一条**(读回时必须用当下最新的文本/光标,闭包旧值一律 * 不许用),实现各自落地:PromptInput 用 revision lease(与会话代次、图片 * 租约耦合,搬动会改变聊天页行为),Launchpad 用每次渲染刷新的 current-ref - * (受控组件,props 即最新值)。 + * (受控组件,props 即最新值)与粘贴租约。 */ import stripAnsi from 'strip-ansi' diff --git a/src/utils/pastedImagePath.ts b/src/utils/pastedImagePath.ts index 50a8eabc3..a914175ac 100644 --- a/src/utils/pastedImagePath.ts +++ b/src/utils/pastedImagePath.ts @@ -8,6 +8,9 @@ * Existence is the caller's async check; parse success alone stages nothing. */ +import { constants as fsConstants } from 'node:fs' +import { open } from 'node:fs/promises' +import { basename } from 'node:path' import { homedir } from 'node:os' import { fileURLToPath } from 'node:url' @@ -226,3 +229,29 @@ export async function stageClipboardFilePaths( } return { parts, staged, failure, ...(failureCode ? { failureCode } : {}) } } + +/** Read one regular file through one descriptor, bounded to `maxBytes + 1`. + * The extra byte detects a file that grows after fstat; a short read detects + * shrinkage. This avoids stat(path) → readFile(path)'s path-swap TOCTOU and + * never allocates from an untrusted size before the profile limit is checked. */ +export async function readBoundedRegularFile(path: string, maxBytes: number): Promise { + // O_NONBLOCK keeps a pasted FIFO/device path from parking the UI before + // fstat can reject it; regular-file reads are unchanged. + const file = await open(path, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK) + try { + const info = await file.stat() + if (!info.isFile()) throw new Error(`${basename(path)} is not a regular file`) + if (info.size > maxBytes) throw new Error(`image exceeds this profile's per-image size limit`) + const data = Buffer.allocUnsafe(info.size + 1) + let offset = 0 + while (offset < data.byteLength) { + const { bytesRead } = await file.read(data, offset, data.byteLength - offset, offset) + if (bytesRead === 0) break + offset += bytesRead + } + if (offset !== info.size) throw new Error(`${basename(path)} changed while it was being read`) + return data.subarray(0, offset) + } finally { + await file.close() + } +}