Skip to content

Commit 52d0d10

Browse files
committed
fix(terminal): skip unavailable inline image previews and dedupe repeated renders
Failed inline image fetches rendered an "unavailable" placeholder box plus four spacer rows, and every re-mention or redraw of the same image path rendered another preview, flooding the web terminal with duplicate boxes. Fetch failures now resolve to null and the preview loop skips them entirely (the clickable path link is unaffected, and failures are not cached so a path printed again after the file appears can still preview). Successfully rendered paths are tracked in a session-level set so each image renders at most once, and the leading line break before previews is written lazily only before the first preview that actually renders, so fully skipped segments leave the output untouched. Refs #445
1 parent 9481167 commit 52d0d10

8 files changed

Lines changed: 307 additions & 137 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@prover-coder-ai/docker-git-terminal": patch
3+
---
4+
5+
Stop rendering "unavailable" placeholder boxes for inline image paths that
6+
cannot be fetched, and render each image preview at most once per terminal
7+
session.
8+
9+
Failed image fetches previously produced an "unavailable" placeholder plus
10+
four spacer rows, and every re-mention or redraw of the same path rendered
11+
another preview. Unavailable paths are now skipped entirely (the clickable
12+
path link remains), rendered paths are tracked per session so duplicates are
13+
suppressed, and the leading line break before previews is written lazily so
14+
fully skipped segments leave the terminal output untouched.

packages/terminal/src/web/terminal-inline-images-core.ts

Lines changed: 91 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { detectTerminalImagePaths } from "./terminal-image-paths.js"
2+
import type { TerminalInlineImageEntry } from "./terminal-inline-images.js"
23

34
export type TerminalInlineImageOutputSegment = {
45
readonly endedWithLineBreak: boolean
@@ -9,8 +10,7 @@ export type TerminalInlineImageOutputSegment = {
910
export type TerminalInlineImagePreviewsEnabledRef = { readonly current: boolean }
1011

1112
export type TerminalOutputSegmentWriter = {
12-
readonly writePreviewLineBreak: (segment: TerminalInlineImageOutputSegment, onComplete: () => void) => void
13-
readonly writePreviews: (paths: ReadonlyArray<string>, onComplete: () => void) => void
13+
readonly writePreviews: (segment: TerminalInlineImageOutputSegment, onComplete: () => void) => void
1414
readonly writeText: (text: string, onComplete: () => void) => void
1515
}
1616

@@ -61,12 +61,11 @@ export const splitTerminalInlineImageOutput = (
6161
* effects belong to the writer implementation.
6262
*
6363
* @pure false - invokes effectful writer callbacks.
64-
* @effect writer callbacks: writeText, writePreviewLineBreak, writePreviews.
64+
* @effect writer callbacks: writeText, writePreviews.
6565
* @precondition segment is the next queued terminal output segment and
6666
* onComplete belongs to the caller's active output queue drain.
6767
* @postcondition writeText is requested exactly once; when previews are enabled
68-
* and imagePaths is non-empty, the preview line break and preview writes are
69-
* requested in order before onComplete.
68+
* and imagePaths is non-empty, the preview write is requested before onComplete.
7069
* @invariant segment.text is emitted before any preview callback, and preview
7170
* callbacks never run when imagePaths is empty or previews are disabled.
7271
* @complexity O(1) plus writer callback complexity; image paths are forwarded
@@ -83,8 +82,92 @@ export const writeTerminalOutputSegment = (
8382
onComplete()
8483
return
8584
}
86-
writer.writePreviewLineBreak(segment, () => {
87-
writer.writePreviews(segment.imagePaths, onComplete)
88-
})
85+
writer.writePreviews(segment, onComplete)
8986
})
9087
}
88+
89+
export type TerminalInlineImagePreviewWriter = {
90+
readonly loadEntry: (path: string, onComplete: (entry: TerminalInlineImageEntry | null) => void) => void
91+
readonly renderPreview: (entry: TerminalInlineImageEntry, onComplete: () => void) => void
92+
readonly writeLineBreak: (onComplete: () => void) => void
93+
}
94+
95+
export type TerminalInlineImagePreviewWriteArgs = {
96+
readonly needsLeadingLineBreak: boolean
97+
readonly paths: ReadonlyArray<string>
98+
readonly renderedPaths: Set<string>
99+
readonly writer: TerminalInlineImagePreviewWriter
100+
}
101+
102+
/**
103+
* Sequences inline image preview writes for the image paths of one segment.
104+
*
105+
* Skips paths whose preview was already rendered in this terminal session and
106+
* paths whose entry loads as `null` (unavailable image), so unavailable links
107+
* never produce a placeholder and the same image is rendered at most once.
108+
* The leading line break is written lazily before the first rendered preview
109+
* only, so segments whose previews are all skipped leave the output untouched.
110+
*
111+
* @param args - Preview paths, session-rendered path set, and effectful writer callbacks.
112+
* @param onComplete - Invoked exactly once after every path is processed.
113+
* @returns Nothing; results are delivered through the writer callbacks.
114+
* @pure false - invokes effectful writer callbacks and mutates renderedPaths.
115+
* @effect writer callbacks: loadEntry, renderPreview, writeLineBreak.
116+
* @precondition paths contains no duplicates (segment paths are deduplicated at detection).
117+
* @postcondition ∀ path ∈ paths: rendered(path) → path ∈ renderedPaths, and
118+
* renderPreview runs only for freshly loaded, previously unrendered entries.
119+
* @invariant renderPreview is never invoked for a null entry or a path already
120+
* in renderedPaths; writeLineBreak runs at most once and only before the first render.
121+
* @complexity O(n) where n = |paths|.
122+
* @throws Through writer callbacks or onComplete only.
123+
*/
124+
// CHANGE: skip unavailable inline images and deduplicate previews per terminal session
125+
// WHY: rendering placeholders for unresolvable paths and repeating previews for the
126+
// same image adds noise to terminal output without conveying information
127+
// QUOTE(ТЗ): "не пытаться рендерить unavailable ссылки + сделать что бы одна и таже
128+
// картинка не рендерилась несколько раз"
129+
// REF: issue-445
130+
// SOURCE: n/a
131+
// FORMAT THEOREM: ∀ path: renders(path) ≤ 1 ∧ (entry(path) = null → renders(path) = 0)
132+
// PURITY: CORE sequencing over injected SHELL callbacks
133+
// EFFECT: mutates renderedPaths and drives writer callbacks
134+
// INVARIANT: onComplete fires exactly once after all paths are processed in input order
135+
// COMPLEXITY: O(n) where n = |paths|
136+
export const writeTerminalInlineImagePreviews = (
137+
{ needsLeadingLineBreak, paths, renderedPaths, writer }: TerminalInlineImagePreviewWriteArgs,
138+
onComplete: () => void
139+
): void => {
140+
let lineBreakPending = needsLeadingLineBreak
141+
let index = 0
142+
const renderNext = (entry: TerminalInlineImageEntry, onRendered: () => void): void => {
143+
if (!lineBreakPending) {
144+
writer.renderPreview(entry, onRendered)
145+
return
146+
}
147+
lineBreakPending = false
148+
writer.writeLineBreak(() => {
149+
writer.renderPreview(entry, onRendered)
150+
})
151+
}
152+
const writeNext = (): void => {
153+
const path = paths[index]
154+
if (path === undefined) {
155+
onComplete()
156+
return
157+
}
158+
index += 1
159+
if (renderedPaths.has(path)) {
160+
writeNext()
161+
return
162+
}
163+
writer.loadEntry(path, (entry) => {
164+
if (entry === null) {
165+
writeNext()
166+
return
167+
}
168+
renderedPaths.add(path)
169+
renderNext(entry, writeNext)
170+
})
171+
}
172+
writeNext()
173+
}

packages/terminal/src/web/terminal-inline-images.ts

Lines changed: 11 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,12 @@ const terminalInlineImagePreviewColumns = 16
1414
const terminalInlineImagePreviewHeightPx = 56
1515
const terminalInlineImagePreviewWidthPx = 96
1616

17-
export type TerminalInlineImageEntry =
18-
| {
19-
readonly _tag: "AvailableTerminalInlineImage"
20-
readonly displayUrl: string
21-
readonly fetchUrl: string
22-
readonly path: string
23-
}
24-
| {
25-
readonly _tag: "UnavailableTerminalInlineImage"
26-
readonly fetchUrl: string
27-
readonly path: string
28-
}
17+
export type TerminalInlineImageEntry = {
18+
readonly _tag: "AvailableTerminalInlineImage"
19+
readonly displayUrl: string
20+
readonly fetchUrl: string
21+
readonly path: string
22+
}
2923

3024
type TerminalInlineImageObjectUrlCache = Map<string, string>
3125

@@ -40,15 +34,6 @@ const availableTerminalInlineImageEntry = (
4034
path
4135
})
4236

43-
export const unavailableTerminalInlineImageEntry = (
44-
path: string,
45-
fetchUrl: string
46-
): TerminalInlineImageEntry => ({
47-
_tag: "UnavailableTerminalInlineImage",
48-
fetchUrl,
49-
path
50-
})
51-
5237
export const cachedTerminalInlineImageEntry = (
5338
cache: TerminalInlineImageObjectUrlCache,
5439
path: string,
@@ -101,18 +86,12 @@ export const revokeTerminalInlineImageObjectUrlCache = (
10186
cache.clear()
10287
}
10388

104-
const terminalInlineImageLinkUrl = (entry: TerminalInlineImageEntry): string =>
105-
entry._tag === "AvailableTerminalInlineImage" ? entry.displayUrl : entry.fetchUrl
106-
107-
const terminalInlineImageTitle = (entry: TerminalInlineImageEntry): string =>
108-
entry._tag === "AvailableTerminalInlineImage" ? entry.path : `${entry.path} unavailable`
109-
11089
const createTerminalInlineImageLink = (entry: TerminalInlineImageEntry): HTMLAnchorElement => {
11190
const link = document.createElement("a")
112-
link.href = terminalInlineImageLinkUrl(entry)
91+
link.href = entry.displayUrl
11392
link.rel = "noreferrer"
11493
link.target = "_blank"
115-
link.title = terminalInlineImageTitle(entry)
94+
link.title = entry.path
11695
link.style.alignItems = "center"
11796
link.style.background = "#0d1218"
11897
link.style.border = "1px solid #3a4652"
@@ -129,9 +108,9 @@ const createTerminalInlineImageLink = (entry: TerminalInlineImageEntry): HTMLAnc
129108
return link
130109
}
131110

132-
const appendAvailableTerminalInlineImage = (
111+
const appendTerminalInlineImageContent = (
133112
link: HTMLAnchorElement,
134-
entry: Extract<TerminalInlineImageEntry, { readonly _tag: "AvailableTerminalInlineImage" }>
113+
entry: TerminalInlineImageEntry
135114
): void => {
136115
const image = document.createElement("img")
137116
image.alt = entry.path
@@ -144,30 +123,6 @@ const appendAvailableTerminalInlineImage = (
144123
link.append(image)
145124
}
146125

147-
const appendUnavailableTerminalInlineImage = (link: HTMLAnchorElement): void => {
148-
const label = document.createElement("span")
149-
label.textContent = "unavailable"
150-
label.style.color = "#9aa8b6"
151-
label.style.fontFamily = "'IBM Plex Mono', ui-monospace, monospace"
152-
label.style.fontSize = "11px"
153-
label.style.lineHeight = "1"
154-
label.style.overflow = "hidden"
155-
label.style.textOverflow = "ellipsis"
156-
label.style.whiteSpace = "nowrap"
157-
link.append(label)
158-
}
159-
160-
const appendTerminalInlineImageContent = (
161-
link: HTMLAnchorElement,
162-
entry: TerminalInlineImageEntry
163-
): void => {
164-
if (entry._tag === "AvailableTerminalInlineImage") {
165-
appendAvailableTerminalInlineImage(link, entry)
166-
return
167-
}
168-
appendUnavailableTerminalInlineImage(link)
169-
}
170-
171126
const openImage = (fetchUrl: string): void => {
172127
const imageWindow = window.open(fetchUrl, "_blank", "noopener,noreferrer")
173128
if (imageWindow === null) {
@@ -191,15 +146,14 @@ const renderInlineImageElement = (
191146
element: HTMLElement,
192147
entry: TerminalInlineImageEntry
193148
): void => {
194-
if (element.dataset["path"] === entry.path && element.dataset["tag"] === entry._tag) {
149+
if (element.dataset["path"] === entry.path) {
195150
return
196151
}
197152

198153
const link = createTerminalInlineImageLink(entry)
199154
appendTerminalInlineImageContent(link, entry)
200155

201156
element.dataset["path"] = entry.path
202-
element.dataset["tag"] = entry._tag
203157
element.style.pointerEvents = "none"
204158
element.replaceChildren(link)
205159
}

packages/terminal/src/web/terminal-panel-cleanup-runtime.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export const cleanupTerminalResources = (
3232
}
3333
args.lifecycle.inlineImageDisposables = []
3434
revokeTerminalInlineImageObjectUrlCache(args.lifecycle.inlineImageObjectUrls)
35+
args.lifecycle.inlineImageRenderedPaths.clear()
3536
args.lifecycle.outputQueue = []
3637
args.lifecycle.outputWriting = false
3738
args.removeImageLinks()

0 commit comments

Comments
 (0)