Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
14 changes: 14 additions & 0 deletions .changeset/terminal-inline-image-unavailable-dedupe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@prover-coder-ai/docker-git-terminal": patch
---

Stop rendering "unavailable" placeholder boxes for inline image paths that
cannot be fetched, and render each image preview at most once per terminal
session.

Failed image fetches previously produced an "unavailable" placeholder plus
four spacer rows, and every re-mention or redraw of the same path rendered
another preview. Unavailable paths are now skipped entirely (the clickable
path link remains), rendered paths are tracked per session so duplicates are
suppressed, and the leading line break before previews is written lazily so
fully skipped segments leave the terminal output untouched.
99 changes: 91 additions & 8 deletions packages/terminal/src/web/terminal-inline-images-core.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { detectTerminalImagePaths } from "./terminal-image-paths.js"
import type { TerminalInlineImageEntry } from "./terminal-inline-images.js"

export type TerminalInlineImageOutputSegment = {
readonly endedWithLineBreak: boolean
Expand All @@ -9,8 +10,7 @@ export type TerminalInlineImageOutputSegment = {
export type TerminalInlineImagePreviewsEnabledRef = { readonly current: boolean }

export type TerminalOutputSegmentWriter = {
readonly writePreviewLineBreak: (segment: TerminalInlineImageOutputSegment, onComplete: () => void) => void
readonly writePreviews: (paths: ReadonlyArray<string>, onComplete: () => void) => void
readonly writePreviews: (segment: TerminalInlineImageOutputSegment, onComplete: () => void) => void
readonly writeText: (text: string, onComplete: () => void) => void
}

Expand Down Expand Up @@ -61,12 +61,11 @@ export const splitTerminalInlineImageOutput = (
* effects belong to the writer implementation.
*
* @pure false - invokes effectful writer callbacks.
* @effect writer callbacks: writeText, writePreviewLineBreak, writePreviews.
* @effect writer callbacks: writeText, writePreviews.
* @precondition segment is the next queued terminal output segment and
* onComplete belongs to the caller's active output queue drain.
* @postcondition writeText is requested exactly once; when previews are enabled
* and imagePaths is non-empty, the preview line break and preview writes are
* requested in order before onComplete.
* and imagePaths is non-empty, the preview write is requested before onComplete.
* @invariant segment.text is emitted before any preview callback, and preview
* callbacks never run when imagePaths is empty or previews are disabled.
* @complexity O(1) plus writer callback complexity; image paths are forwarded
Expand All @@ -83,8 +82,92 @@ export const writeTerminalOutputSegment = (
onComplete()
return
}
writer.writePreviewLineBreak(segment, () => {
writer.writePreviews(segment.imagePaths, onComplete)
})
writer.writePreviews(segment, onComplete)
})
}

export type TerminalInlineImagePreviewWriter = {
readonly loadEntry: (path: string, onComplete: (entry: TerminalInlineImageEntry | null) => void) => void
readonly renderPreview: (entry: TerminalInlineImageEntry, onComplete: () => void) => void
readonly writeLineBreak: (onComplete: () => void) => void
}

export type TerminalInlineImagePreviewWriteArgs = {
readonly needsLeadingLineBreak: boolean
readonly paths: ReadonlyArray<string>
readonly renderedPaths: Set<string>
readonly writer: TerminalInlineImagePreviewWriter
}

/**
* Sequences inline image preview writes for the image paths of one segment.
*
* Skips paths whose preview was already rendered in this terminal session and
* paths whose entry loads as `null` (unavailable image), so unavailable links
* never produce a placeholder and the same image is rendered at most once.
* The leading line break is written lazily before the first rendered preview
* only, so segments whose previews are all skipped leave the output untouched.
*
* @param args - Preview paths, session-rendered path set, and effectful writer callbacks.
* @param onComplete - Invoked exactly once after every path is processed.
* @returns Nothing; results are delivered through the writer callbacks.
* @pure false - invokes effectful writer callbacks and mutates renderedPaths.
* @effect writer callbacks: loadEntry, renderPreview, writeLineBreak.
* @precondition paths contains no duplicates (segment paths are deduplicated at detection).
* @postcondition ∀ path ∈ paths: rendered(path) → path ∈ renderedPaths, and
* renderPreview runs only for freshly loaded, previously unrendered entries.
* @invariant renderPreview is never invoked for a null entry or a path already
* in renderedPaths; writeLineBreak runs at most once and only before the first render.
* @complexity O(n) where n = |paths|.
* @throws Through writer callbacks or onComplete only.
*/
// CHANGE: skip unavailable inline images and deduplicate previews per terminal session
// WHY: rendering placeholders for unresolvable paths and repeating previews for the
// same image adds noise to terminal output without conveying information
// QUOTE(ТЗ): "не пытаться рендерить unavailable ссылки + сделать что бы одна и таже
// картинка не рендерилась несколько раз"
// REF: issue-445
// SOURCE: n/a
// FORMAT THEOREM: ∀ path: renders(path) ≤ 1 ∧ (entry(path) = null → renders(path) = 0)
// PURITY: CORE sequencing over injected SHELL callbacks
// EFFECT: mutates renderedPaths and drives writer callbacks
// INVARIANT: onComplete fires exactly once after all paths are processed in input order
// COMPLEXITY: O(n) where n = |paths|
export const writeTerminalInlineImagePreviews = (
{ needsLeadingLineBreak, paths, renderedPaths, writer }: TerminalInlineImagePreviewWriteArgs,
onComplete: () => void
): void => {
let lineBreakPending = needsLeadingLineBreak
let index = 0
const renderNext = (entry: TerminalInlineImageEntry, onRendered: () => void): void => {
if (!lineBreakPending) {
writer.renderPreview(entry, onRendered)
return
}
lineBreakPending = false
writer.writeLineBreak(() => {
writer.renderPreview(entry, onRendered)
})
}
const writeNext = (): void => {
const path = paths[index]
if (path === undefined) {
onComplete()
return
}
index += 1
if (renderedPaths.has(path)) {
writeNext()
return
}
writer.loadEntry(path, (entry) => {
if (entry === null) {
writeNext()
return
}
renderedPaths.add(path)
renderNext(entry, writeNext)
})
}
writeNext()
}
68 changes: 11 additions & 57 deletions packages/terminal/src/web/terminal-inline-images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,12 @@ const terminalInlineImagePreviewColumns = 16
const terminalInlineImagePreviewHeightPx = 56
const terminalInlineImagePreviewWidthPx = 96

export type TerminalInlineImageEntry =
| {
readonly _tag: "AvailableTerminalInlineImage"
readonly displayUrl: string
readonly fetchUrl: string
readonly path: string
}
| {
readonly _tag: "UnavailableTerminalInlineImage"
readonly fetchUrl: string
readonly path: string
}
export type TerminalInlineImageEntry = {
readonly _tag: "AvailableTerminalInlineImage"
readonly displayUrl: string
readonly fetchUrl: string
readonly path: string
}

type TerminalInlineImageObjectUrlCache = Map<string, string>

Expand All @@ -40,15 +34,6 @@ const availableTerminalInlineImageEntry = (
path
})

export const unavailableTerminalInlineImageEntry = (
path: string,
fetchUrl: string
): TerminalInlineImageEntry => ({
_tag: "UnavailableTerminalInlineImage",
fetchUrl,
path
})

export const cachedTerminalInlineImageEntry = (
cache: TerminalInlineImageObjectUrlCache,
path: string,
Expand Down Expand Up @@ -101,18 +86,12 @@ export const revokeTerminalInlineImageObjectUrlCache = (
cache.clear()
}

const terminalInlineImageLinkUrl = (entry: TerminalInlineImageEntry): string =>
entry._tag === "AvailableTerminalInlineImage" ? entry.displayUrl : entry.fetchUrl

const terminalInlineImageTitle = (entry: TerminalInlineImageEntry): string =>
entry._tag === "AvailableTerminalInlineImage" ? entry.path : `${entry.path} unavailable`

const createTerminalInlineImageLink = (entry: TerminalInlineImageEntry): HTMLAnchorElement => {
const link = document.createElement("a")
link.href = terminalInlineImageLinkUrl(entry)
link.href = entry.displayUrl
link.rel = "noreferrer"
link.target = "_blank"
link.title = terminalInlineImageTitle(entry)
link.title = entry.path
link.style.alignItems = "center"
link.style.background = "#0d1218"
link.style.border = "1px solid #3a4652"
Expand All @@ -129,9 +108,9 @@ const createTerminalInlineImageLink = (entry: TerminalInlineImageEntry): HTMLAnc
return link
}

const appendAvailableTerminalInlineImage = (
const appendTerminalInlineImageContent = (
link: HTMLAnchorElement,
entry: Extract<TerminalInlineImageEntry, { readonly _tag: "AvailableTerminalInlineImage" }>
entry: TerminalInlineImageEntry
): void => {
const image = document.createElement("img")
image.alt = entry.path
Expand All @@ -144,30 +123,6 @@ const appendAvailableTerminalInlineImage = (
link.append(image)
}

const appendUnavailableTerminalInlineImage = (link: HTMLAnchorElement): void => {
const label = document.createElement("span")
label.textContent = "unavailable"
label.style.color = "#9aa8b6"
label.style.fontFamily = "'IBM Plex Mono', ui-monospace, monospace"
label.style.fontSize = "11px"
label.style.lineHeight = "1"
label.style.overflow = "hidden"
label.style.textOverflow = "ellipsis"
label.style.whiteSpace = "nowrap"
link.append(label)
}

const appendTerminalInlineImageContent = (
link: HTMLAnchorElement,
entry: TerminalInlineImageEntry
): void => {
if (entry._tag === "AvailableTerminalInlineImage") {
appendAvailableTerminalInlineImage(link, entry)
return
}
appendUnavailableTerminalInlineImage(link)
}

const openImage = (fetchUrl: string): void => {
const imageWindow = window.open(fetchUrl, "_blank", "noopener,noreferrer")
if (imageWindow === null) {
Expand All @@ -191,15 +146,14 @@ const renderInlineImageElement = (
element: HTMLElement,
entry: TerminalInlineImageEntry
): void => {
if (element.dataset["path"] === entry.path && element.dataset["tag"] === entry._tag) {
if (element.dataset["path"] === entry.path) {
return
}

const link = createTerminalInlineImageLink(entry)
appendTerminalInlineImageContent(link, entry)

element.dataset["path"] = entry.path
element.dataset["tag"] = entry._tag
element.style.pointerEvents = "none"
element.replaceChildren(link)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const cleanupTerminalResources = (
}
args.lifecycle.inlineImageDisposables = []
revokeTerminalInlineImageObjectUrlCache(args.lifecycle.inlineImageObjectUrls)
args.lifecycle.inlineImageRenderedPaths.clear()
args.lifecycle.outputQueue = []
args.lifecycle.outputWriting = false
args.removeImageLinks()
Expand Down
Loading
Loading