feat(tui): support pasting clipboard images (#5563)
* feat(tui): support pasting clipboard images * fix(tui): keep image placeholders atomic * fix(tui): reconcile duplicate image placeholders * fix(tui): retain highlighted image placeholders * fix(tui): preserve image placeholder layout * fix(tui): keep image display state local * fix(tui): reject images in commands
This commit is contained in:
+155
-8
@@ -3,14 +3,21 @@ import {
|
||||
MarkdownRenderable,
|
||||
RGBA,
|
||||
ScrollBoxRenderable,
|
||||
StyledText,
|
||||
SyntaxStyle,
|
||||
TextAttributes,
|
||||
TextRenderable,
|
||||
type CliRenderer,
|
||||
type TextChunk,
|
||||
type TreeSitterClient,
|
||||
} from "@opentui/core"
|
||||
|
||||
import type { FileEditEvent, HistoryMessage, ToolProgressEvent } from "./protocol"
|
||||
import type {
|
||||
FileEditEvent,
|
||||
HistoryMessage,
|
||||
MediaAttachment,
|
||||
ToolProgressEvent,
|
||||
} from "./protocol"
|
||||
import { renderLatexAsUnicode } from "./latex"
|
||||
import { hideScrollbars } from "./scrollbox"
|
||||
import { mergeToolEvent, renderToolEvent } from "./tool-renderers"
|
||||
@@ -58,6 +65,54 @@ const ACTIVITY_PREVIEW_LINES = 4
|
||||
// subsequent deltas to the renderer cadence.
|
||||
const STREAM_FLUSH_MS = 32
|
||||
|
||||
export interface UserMessageMedia {
|
||||
kind?: MediaAttachment["kind"]
|
||||
name?: string
|
||||
}
|
||||
|
||||
interface UserMessageProjection {
|
||||
imageLabels: string[]
|
||||
attachmentNames: string[]
|
||||
}
|
||||
|
||||
function projectUserMessage(media: readonly UserMessageMedia[]): UserMessageProjection {
|
||||
const imageNames: Array<string | undefined> = []
|
||||
const attachmentNames: string[] = []
|
||||
for (const item of media) {
|
||||
// Outbound TUI media has no explicit kind because this path currently only
|
||||
// sends clipboard images. Gateway and history media carry the kind.
|
||||
if (item.kind === undefined || item.kind === "image") imageNames.push(item.name)
|
||||
else if (item.name) attachmentNames.push(item.name)
|
||||
}
|
||||
|
||||
const used = new Set<number>()
|
||||
let next = 1
|
||||
const imageLabels = imageNames.map((name) => {
|
||||
const match = name?.match(/^clipboard-image-(\d+)\.[^.]+$/iu)
|
||||
const preferred = match ? Number(match[1]) : 0
|
||||
let index = Number.isSafeInteger(preferred) && preferred > 0 && !used.has(preferred)
|
||||
? preferred
|
||||
: next
|
||||
while (used.has(index)) index += 1
|
||||
used.add(index)
|
||||
while (used.has(next)) next += 1
|
||||
return `[Image #${index}]`
|
||||
})
|
||||
return { imageLabels, attachmentNames }
|
||||
}
|
||||
|
||||
export function userMessageText(
|
||||
content: string,
|
||||
media: readonly UserMessageMedia[] = [],
|
||||
displayContent?: string,
|
||||
): string {
|
||||
const { imageLabels, attachmentNames } = projectUserMessage(media)
|
||||
return [
|
||||
displayContent ?? [content, imageLabels.join(" ")].filter(Boolean).join(" "),
|
||||
attachmentNames.length ? `Attachments: ${attachmentNames.join(", ")}` : "",
|
||||
].filter(Boolean).join("\n")
|
||||
}
|
||||
|
||||
/** Projects gateway events into retained, reflowable conversation cells. */
|
||||
export class Transcript {
|
||||
readonly root: ScrollBoxRenderable
|
||||
@@ -71,6 +126,12 @@ export class Transcript {
|
||||
private readonly activities = new Set<Activity>()
|
||||
private readonly frames = new Set<BoxRenderable>()
|
||||
private readonly userRows = new Set<BoxRenderable>()
|
||||
private readonly userMessages = new Set<{
|
||||
renderable: TextRenderable
|
||||
content: string
|
||||
media: UserMessageMedia[]
|
||||
displayContent?: string
|
||||
}>()
|
||||
private readonly userTurnIds = new Set<string>()
|
||||
private wrote = false
|
||||
private nextId = 0
|
||||
@@ -116,6 +177,13 @@ export class Transcript {
|
||||
const previousSyntax = this.theme.syntax
|
||||
this.theme = theme
|
||||
for (const { renderable, tone } of this.styledText) renderable.fg = theme[tone]
|
||||
for (const message of this.userMessages) {
|
||||
message.renderable.content = this.userMessageContent(
|
||||
message.content,
|
||||
message.media,
|
||||
message.displayContent,
|
||||
)
|
||||
}
|
||||
for (const renderable of this.markdown) renderable.syntaxStyle = theme.syntax
|
||||
for (const frame of this.frames) frame.borderColor = theme.border
|
||||
for (const row of this.userRows) {
|
||||
@@ -171,6 +239,7 @@ export class Transcript {
|
||||
this.activities.clear()
|
||||
this.frames.clear()
|
||||
this.userRows.clear()
|
||||
this.userMessages.clear()
|
||||
this.userTurnIds.clear()
|
||||
this.wrote = false
|
||||
this.nextId = 0
|
||||
@@ -182,7 +251,9 @@ export class Transcript {
|
||||
|
||||
history(messages: HistoryMessage[]): void {
|
||||
for (const message of messages) {
|
||||
if (message.role === "user") this.user(message.content, message.turnId)
|
||||
if (message.role === "user") {
|
||||
this.user(message.content, message.turnId, message.media)
|
||||
}
|
||||
else if (message.role === "assistant") this.assistant(message.content)
|
||||
else if (message.fileEdits?.length) this.fileEdits(message.fileEdits)
|
||||
else this.progress(message.content, message.toolEvents)
|
||||
@@ -198,7 +269,7 @@ export class Transcript {
|
||||
for (const message of messages) {
|
||||
if (message.role === "user") {
|
||||
if (message.turnId && this.userTurnIds.has(message.turnId)) continue
|
||||
this.writeRole("›", message.content, "user", index++)
|
||||
this.writeUser(message.content, message.media, index++)
|
||||
if (message.turnId) this.userTurnIds.add(message.turnId)
|
||||
} else if (message.role === "assistant") {
|
||||
this.writeMarkdown(message.content, false, index++)
|
||||
@@ -224,11 +295,16 @@ export class Transcript {
|
||||
return this.root.scrollTop <= 0
|
||||
}
|
||||
|
||||
user(content: string, turnId?: string): boolean {
|
||||
user(
|
||||
content: string,
|
||||
turnId?: string,
|
||||
media: readonly UserMessageMedia[] = [],
|
||||
displayContent?: string,
|
||||
): boolean {
|
||||
if (turnId && this.userTurnIds.has(turnId)) return false
|
||||
this.noteOutput()
|
||||
this.finishActivity()
|
||||
this.writeRole("›", content, "user")
|
||||
this.writeUser(content, media, undefined, displayContent)
|
||||
if (turnId) this.userTurnIds.add(turnId)
|
||||
return true
|
||||
}
|
||||
@@ -336,6 +412,7 @@ export class Transcript {
|
||||
this.activity = null
|
||||
this.frames.clear()
|
||||
this.userRows.clear()
|
||||
this.userMessages.clear()
|
||||
this.theme.syntax.destroy()
|
||||
}
|
||||
|
||||
@@ -468,7 +545,7 @@ export class Transcript {
|
||||
}
|
||||
|
||||
private createText(
|
||||
content: string,
|
||||
content: string | StyledText,
|
||||
tone: "text" | "muted" | "error" | "user",
|
||||
bold = false,
|
||||
id = "text",
|
||||
@@ -487,10 +564,10 @@ export class Transcript {
|
||||
|
||||
private writeRole(
|
||||
marker: string,
|
||||
content: string,
|
||||
content: string | StyledText,
|
||||
tone: "muted" | "error" | "user",
|
||||
index?: number,
|
||||
): void {
|
||||
): TextRenderable {
|
||||
const row = this.createRow(tone === "user" ? "user" : "notice", "row")
|
||||
if (tone === "user") {
|
||||
row.backgroundColor = this.theme.userBackground
|
||||
@@ -509,6 +586,76 @@ export class Transcript {
|
||||
row.add(text)
|
||||
this.root.add(row, index)
|
||||
this.wrote = true
|
||||
return text
|
||||
}
|
||||
|
||||
private writeUser(
|
||||
content: string,
|
||||
media: readonly UserMessageMedia[] = [],
|
||||
index?: number,
|
||||
displayContent?: string,
|
||||
): void {
|
||||
const retainedMedia = [...media]
|
||||
const renderable = this.writeRole(
|
||||
"›",
|
||||
this.userMessageContent(content, retainedMedia, displayContent),
|
||||
"user",
|
||||
index,
|
||||
)
|
||||
this.userMessages.add({ renderable, content, media: retainedMedia, displayContent })
|
||||
}
|
||||
|
||||
private userMessageContent(
|
||||
content: string,
|
||||
media: readonly UserMessageMedia[],
|
||||
displayContent?: string,
|
||||
): StyledText {
|
||||
const { imageLabels, attachmentNames } = projectUserMessage(media)
|
||||
const chunks: TextChunk[] = []
|
||||
const append = (text: string) => {
|
||||
if (text) chunks.push({ __isChunk: true, text })
|
||||
}
|
||||
const nextLine = () => {
|
||||
if (chunks.length) append("\n")
|
||||
}
|
||||
|
||||
if (displayContent !== undefined) {
|
||||
const ranges = imageLabels
|
||||
.map((label) => ({ label, start: displayContent.indexOf(label) }))
|
||||
.filter(({ start }) => start >= 0)
|
||||
.sort((left, right) => left.start - right.start)
|
||||
let cursor = 0
|
||||
for (const { label, start } of ranges) {
|
||||
append(displayContent.slice(cursor, start))
|
||||
chunks.push({
|
||||
__isChunk: true,
|
||||
text: label,
|
||||
fg: RGBA.fromHex(this.theme.user),
|
||||
attributes: TextAttributes.BOLD,
|
||||
})
|
||||
cursor = start + label.length
|
||||
}
|
||||
append(displayContent.slice(cursor))
|
||||
} else {
|
||||
append(content)
|
||||
}
|
||||
if (displayContent === undefined && imageLabels.length) {
|
||||
if (chunks.length) append(" ")
|
||||
for (const [index, label] of imageLabels.entries()) {
|
||||
if (index > 0) append(" ")
|
||||
chunks.push({
|
||||
__isChunk: true,
|
||||
text: label,
|
||||
fg: RGBA.fromHex(this.theme.user),
|
||||
attributes: TextAttributes.BOLD,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (attachmentNames.length) {
|
||||
nextLine()
|
||||
append(`Attachments: ${attachmentNames.join(", ")}`)
|
||||
}
|
||||
return new StyledText(chunks)
|
||||
}
|
||||
|
||||
private createMarkdown(content: string, streaming: boolean, id = "markdown"): MarkdownRenderable {
|
||||
|
||||
Reference in New Issue
Block a user