Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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.

Expand Down
2 changes: 1 addition & 1 deletion README_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 随首条消息发送;启动页粘贴的多行文字会折叠为一行。在聊天页,终端原生与 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 一律拒绝并保持字面文本,不做猜测。

Expand Down
171 changes: 169 additions & 2 deletions scripts/verify-launchpad-onboarding-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 跳过**回到落地页**(第七版:不再收掉落地页落到对话页)。
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -1498,6 +1500,171 @@ 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<void>) => {
const staged = new Set<string>()
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<void>(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<void>(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<boolean>)('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() }
}
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)
31 changes: 2 additions & 29 deletions src/components/PromptInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ 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'
Expand All @@ -25,7 +24,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'
Expand Down Expand Up @@ -298,32 +297,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<Uint8Array> {
// 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
Expand Down
Loading
Loading