diff --git a/docs/pr-screenshots/issue-240/terminal-image-gallery.png b/docs/pr-screenshots/issue-240/terminal-image-gallery.png new file mode 100644 index 00000000..65a86c4a Binary files /dev/null and b/docs/pr-screenshots/issue-240/terminal-image-gallery.png differ diff --git a/docs/pr-screenshots/issue-240/terminal-image-open.png b/docs/pr-screenshots/issue-240/terminal-image-open.png new file mode 100644 index 00000000..0d026ad1 Binary files /dev/null and b/docs/pr-screenshots/issue-240/terminal-image-open.png differ diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index 3083e426..3be46e60 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -113,7 +113,8 @@ import { deleteTerminalSession, getProjectTerminalSession, listProjectTerminalSessions, - lookupTerminalSessionById + lookupTerminalSessionById, + readProjectTerminalImage } from "./services/terminal-sessions.js" import { commitStateFromRequest, @@ -223,6 +224,14 @@ const textResponse = (data: string, contentType: string, status = 200) => ) ) +const binaryResponse = (data: Uint8Array, contentType: string, status = 200) => + Effect.succeed( + HttpServerResponse.setStatus( + HttpServerResponse.uint8Array(data, { contentType, headers: noStoreHeaders }), + status + ) + ) + const activityJsonResponse = (data: unknown, status: number) => textResponse(JSON.stringify(data), "application/activity+json; charset=utf-8", status) @@ -1134,7 +1143,31 @@ export const makeRouter = () => { ) ) - const withAgents = withProjectLifecycle.pipe( + const withProjectTerminalImages = withProjectLifecycle.pipe( + HttpRouter.get( + "/projects/by-key/:projectKey/terminal-sessions/:sessionId/image", + Effect.gen(function*(_) { + const { projectKey, sessionId } = yield* _(terminalSessionByProjectKeyParams) + const request = yield* _(HttpServerRequest.HttpServerRequest) + const imagePath = new URL(request.url, "http://localhost").searchParams.get("path") ?? "" + const project = yield* _(getProjectItemByKey(projectKey)) + const result = yield* _(readProjectTerminalImage(project.projectDir, sessionId, imagePath)) + return yield* _(binaryResponse(result.bytes, result.mediaType, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.get( + "/projects/:projectId/terminal-sessions/:sessionId/image", + Effect.gen(function*(_) { + const { projectId, sessionId } = yield* _(terminalSessionParams) + const request = yield* _(HttpServerRequest.HttpServerRequest) + const imagePath = new URL(request.url, "http://localhost").searchParams.get("path") ?? "" + const result = yield* _(readProjectTerminalImage(projectId, sessionId, imagePath)) + return yield* _(binaryResponse(result.bytes, result.mediaType, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ) + ) + + const withAgents = withProjectTerminalImages.pipe( HttpRouter.post( "/projects/:projectId/agents", Effect.gen(function*(_) { diff --git a/packages/api/src/services/terminal-image-fetch-core.ts b/packages/api/src/services/terminal-image-fetch-core.ts new file mode 100644 index 00000000..937ac99d --- /dev/null +++ b/packages/api/src/services/terminal-image-fetch-core.ts @@ -0,0 +1,57 @@ +export type TerminalImageFetchPlan = + | { + readonly _tag: "InvalidTerminalImageFetch" + readonly message: string + } + | { + readonly _tag: "ValidTerminalImageFetch" + readonly containerPath: string + readonly mediaType: string + } + +export const terminalImageFetchMaxBytes = 10 * 1024 * 1024 + +const supportedExtensionMediaTypes = new Map([ + ["gif", "image/gif"], + ["jpeg", "image/jpeg"], + ["jpg", "image/jpeg"], + ["png", "image/png"], + ["webp", "image/webp"] +]) + +const controlCharRange = `${String.fromCodePoint(0)}-${String.fromCodePoint(0x1F)}` +const deleteChar = String.fromCodePoint(0x7F) +const invalidCharacterPattern = new RegExp(String.raw`[\s${controlCharRange}${deleteChar}]`, "u") +const traversalPattern = /(?:^|\/)(?:\.|\.\.)(?=\/|$)/u + +const lowercaseExtension = (path: string): string | null => { + const lastDot = path.lastIndexOf(".") + if (lastDot < 0 || lastDot === path.length - 1) { + return null + } + return path.slice(lastDot + 1).toLowerCase() +} + +export const planTerminalImageFetch = (path: string): TerminalImageFetchPlan => { + if (typeof path !== "string" || path.length === 0) { + return { _tag: "InvalidTerminalImageFetch", message: "Image path is required." } + } + if (!path.startsWith("/")) { + return { _tag: "InvalidTerminalImageFetch", message: "Image path must be absolute." } + } + if (invalidCharacterPattern.test(path)) { + return { _tag: "InvalidTerminalImageFetch", message: "Image path contains invalid characters." } + } + if (traversalPattern.test(path)) { + return { _tag: "InvalidTerminalImageFetch", message: "Image path must not contain '.' or '..' segments." } + } + const extension = lowercaseExtension(path) + if (extension === null) { + return { _tag: "InvalidTerminalImageFetch", message: "Image path must include a file extension." } + } + const mediaType = supportedExtensionMediaTypes.get(extension) + if (mediaType === undefined) { + return { _tag: "InvalidTerminalImageFetch", message: `Unsupported image extension: .${extension}` } + } + return { _tag: "ValidTerminalImageFetch", containerPath: path, mediaType } +} diff --git a/packages/api/src/services/terminal-sessions.ts b/packages/api/src/services/terminal-sessions.ts index d8e4afa7..c415a5a7 100644 --- a/packages/api/src/services/terminal-sessions.ts +++ b/packages/api/src/services/terminal-sessions.ts @@ -15,8 +15,12 @@ import type { Duplex } from "node:stream" import { WebSocket, WebSocketServer, type RawData } from "ws" import type { TerminalSession, TerminalSessionStatus } from "../api/contracts.js" -import { ApiInternalError, ApiNotFoundError, describeUnknown } from "../api/errors.js" +import { ApiBadRequestError, ApiInternalError, ApiNotFoundError, describeUnknown } from "../api/errors.js" import { emitProjectEvent } from "./events.js" +import { + planTerminalImageFetch, + terminalImageFetchMaxBytes +} from "./terminal-image-fetch-core.js" import { createTerminalImagePastePlan, terminalImagePasteDirectory, @@ -392,6 +396,91 @@ const writeBufferToProjectContainer = ( child.stdin.end(buffer) }) +const readBufferFromProjectContainer = ( + containerName: string, + containerPath: string, + maxBytes: number +): Effect.Effect => + Effect.async((resume) => { + const child = spawn( + "docker", + [ + "exec", + "-u", + "dev", + containerName, + "cat", + "--", + containerPath + ], + { + cwd: process.cwd(), + stdio: ["ignore", "pipe", "pipe"] + } + ) + const stdoutChunks: Array = [] + const stderrChunks: Array = [] + let totalBytes = 0 + let exceededLimit = false + let completed = false + const resumeOnce = ( + effect: Effect.Effect + ): void => { + if (completed) { + return + } + completed = true + resume(effect) + } + child.stdout.on("data", (chunk: Buffer | string) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + totalBytes += buffer.length + if (totalBytes > maxBytes) { + exceededLimit = true + try { + child.kill() + } catch { + // ignore — close handler will resume + } + return + } + stdoutChunks.push(buffer) + }) + child.stderr.on("data", (chunk: Buffer | string) => { + stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + }) + child.on("error", (error) => { + resumeOnce(Effect.fail(new ApiInternalError({ + message: `Failed to run docker exec for ${containerName}.`, + cause: error + }))) + }) + child.on("close", (exitCode) => { + if (exceededLimit) { + resumeOnce(Effect.fail(new ApiBadRequestError({ + message: `Image exceeds maximum size of ${maxBytes} bytes.` + }))) + return + } + if (exitCode === 0) { + resumeOnce(Effect.succeed(Buffer.concat(stdoutChunks))) + return + } + const stderr = Buffer.concat(stderrChunks).toString("utf8").trim() + if (/no such file|not a directory|not found/iu.test(stderr)) { + resumeOnce(Effect.fail(new ApiNotFoundError({ + message: `Image not found at ${containerPath}.` + }))) + return + } + resumeOnce(Effect.fail(new ApiInternalError({ + message: stderr.length > 0 + ? `Failed to read image: ${stderr}` + : `Failed to read image; docker exec exited with code ${exitCode ?? "unknown"}.` + }))) + }) + }) + const saveTerminalImagePaste = ( record: TerminalRecord, payload: TerminalImagePastePayload @@ -611,6 +700,33 @@ export const getProjectTerminalSession = ( return record.session }) +export const readProjectTerminalImage = ( + projectId: string, + sessionId: string, + imagePath: string +): Effect.Effect< + { readonly bytes: Buffer; readonly mediaType: string }, + ApiBadRequestError | ApiInternalError | ApiNotFoundError +> => + Effect.gen(function*(_) { + const record = records.get(sessionId) + if (record === undefined || record.projectId !== projectId) { + return yield* _( + Effect.fail(new ApiNotFoundError({ message: `Terminal session not found: ${sessionId}` })) + ) + } + const plan = planTerminalImageFetch(imagePath) + if (plan._tag === "InvalidTerminalImageFetch") { + return yield* _(Effect.fail(new ApiBadRequestError({ message: plan.message }))) + } + const bytes = yield* _(readBufferFromProjectContainer( + record.projectContainerName, + plan.containerPath, + terminalImageFetchMaxBytes + )) + return { bytes, mediaType: plan.mediaType } + }) + export const lookupTerminalSessionById = ( sessionId: string ): Effect.Effect< diff --git a/packages/api/tests/terminal-image-fetch-core.test.ts b/packages/api/tests/terminal-image-fetch-core.test.ts new file mode 100644 index 00000000..635871e8 --- /dev/null +++ b/packages/api/tests/terminal-image-fetch-core.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "@effect/vitest" + +import { planTerminalImageFetch } from "../src/services/terminal-image-fetch-core.js" + +describe("terminal image fetch core", () => { + it("accepts an absolute path with a supported image extension", () => { + expect(planTerminalImageFetch("/tmp/issue232-main.png")).toEqual({ + _tag: "ValidTerminalImageFetch", + containerPath: "/tmp/issue232-main.png", + mediaType: "image/png" + }) + }) + + it("maps each supported extension to its media type", () => { + expect(planTerminalImageFetch("/a.jpg")).toMatchObject({ mediaType: "image/jpeg" }) + expect(planTerminalImageFetch("/a.jpeg")).toMatchObject({ mediaType: "image/jpeg" }) + expect(planTerminalImageFetch("/a.gif")).toMatchObject({ mediaType: "image/gif" }) + expect(planTerminalImageFetch("/a.webp")).toMatchObject({ mediaType: "image/webp" }) + expect(planTerminalImageFetch("/a.PNG")).toMatchObject({ mediaType: "image/png" }) + }) + + it("rejects an empty path", () => { + expect(planTerminalImageFetch("")).toEqual({ + _tag: "InvalidTerminalImageFetch", + message: "Image path is required." + }) + }) + + it("rejects a relative path", () => { + expect(planTerminalImageFetch("tmp/photo.png")).toEqual({ + _tag: "InvalidTerminalImageFetch", + message: "Image path must be absolute." + }) + }) + + it("rejects whitespace and control characters", () => { + expect(planTerminalImageFetch("/tmp/has space.png")).toMatchObject({ + _tag: "InvalidTerminalImageFetch" + }) + expect(planTerminalImageFetch("/tmp/has\nnewline.png")).toMatchObject({ + _tag: "InvalidTerminalImageFetch" + }) + }) + + it("rejects parent-directory and current-directory traversal segments", () => { + expect(planTerminalImageFetch("/tmp/../etc/photo.png")).toMatchObject({ + _tag: "InvalidTerminalImageFetch" + }) + expect(planTerminalImageFetch("/tmp/./photo.png")).toMatchObject({ + _tag: "InvalidTerminalImageFetch" + }) + }) + + it("rejects unsupported extensions", () => { + expect(planTerminalImageFetch("/tmp/file.bmp")).toMatchObject({ + _tag: "InvalidTerminalImageFetch" + }) + expect(planTerminalImageFetch("/tmp/file")).toMatchObject({ + _tag: "InvalidTerminalImageFetch" + }) + expect(planTerminalImageFetch("/tmp/file.")).toMatchObject({ + _tag: "InvalidTerminalImageFetch" + }) + }) +}) diff --git a/packages/app/src/web/panel-terminal.tsx b/packages/app/src/web/panel-terminal.tsx index 9273f0c9..70e5425b 100644 --- a/packages/app/src/web/panel-terminal.tsx +++ b/packages/app/src/web/panel-terminal.tsx @@ -638,9 +638,7 @@ export const TerminalPanel = ( session={session} status={status} /> -
+
{hasBodyContent ?
{bodyContent}
: null}
diff --git a/packages/app/src/web/terminal-image-paths.ts b/packages/app/src/web/terminal-image-paths.ts new file mode 100644 index 00000000..18773a6c --- /dev/null +++ b/packages/app/src/web/terminal-image-paths.ts @@ -0,0 +1,58 @@ +const supportedExtensions: ReadonlyArray = ["png", "jpg", "jpeg", "gif", "webp"] + +const extensionAlternation = supportedExtensions.join("|") + +const imagePathPattern = new RegExp( + String.raw`(?:^|[\s"'(<>\[\]{}|])(/[^\s"'(<>\[\]{}|\\]+\.(?:${extensionAlternation}))(?=$|[\s"')<>\[\]{}|.,;:?!])`, + "giu" +) + +const escapeChar = String.fromCodePoint(0x1B) +const bellChar = String.fromCodePoint(0x07) + +const buildAnsiPattern = (source: string): RegExp => new RegExp(source, "gu") + +const ansiCsiPattern = buildAnsiPattern(String.raw`${escapeChar}\[[0-?]*[ -/]*[@-~]`) +const ansiOscPattern = buildAnsiPattern(String.raw`${escapeChar}\][\s\S]*?(?:${bellChar}|${escapeChar}\\)`) +const ansiOtherEscapePattern = buildAnsiPattern(`${escapeChar}.`) + +export type TerminalImagePathMatch = { + readonly endIndex: number + readonly path: string + readonly startIndex: number +} + +export const stripTerminalAnsi = (text: string): string => + text.replace(ansiOscPattern, "").replace(ansiCsiPattern, "").replace(ansiOtherEscapePattern, "") + +export const detectTerminalImagePathMatches = (text: string): ReadonlyArray => { + const plainText = stripTerminalAnsi(text) + const matches: Array = [] + for (const match of plainText.matchAll(imagePathPattern)) { + const candidate = match[1] + if (candidate !== undefined && candidate.length > 0) { + const fullMatch = match[0] + const fullStartIndex = match.index + const startIndex = fullStartIndex + fullMatch.lastIndexOf(candidate) + matches.push({ + endIndex: startIndex + candidate.length, + path: candidate, + startIndex + }) + } + } + return matches +} + +export const detectTerminalImagePaths = (text: string): ReadonlyArray => { + const matches = new Set() + for (const match of detectTerminalImagePathMatches(text)) { + matches.add(match.path) + } + return [...matches] +} + +export const isSupportedTerminalImagePath = (path: string): boolean => { + const lower = path.toLowerCase() + return supportedExtensions.some((extension) => lower.endsWith(`.${extension}`)) +} diff --git a/packages/app/src/web/terminal-image-url.ts b/packages/app/src/web/terminal-image-url.ts new file mode 100644 index 00000000..3a8ed8d4 --- /dev/null +++ b/packages/app/src/web/terminal-image-url.ts @@ -0,0 +1,13 @@ +import { resolveTerminalApiOriginUrl } from "./terminal.js" + +const websocketSuffixPattern = /\/ws$/u + +export const resolveTerminalImageBasePath = (websocketPath: string): string => + websocketPath.replace(websocketSuffixPattern, "/image") + +export const resolveTerminalImageFetchUrl = (websocketPath: string, imagePath: string): string => { + const apiUrl = resolveTerminalApiOriginUrl() + apiUrl.pathname = `${apiUrl.pathname.replace(/\/$/u, "")}${resolveTerminalImageBasePath(websocketPath)}` + apiUrl.searchParams.set("path", imagePath) + return apiUrl.toString() +} diff --git a/packages/app/src/web/terminal-inline-images-core.ts b/packages/app/src/web/terminal-inline-images-core.ts new file mode 100644 index 00000000..65570ad8 --- /dev/null +++ b/packages/app/src/web/terminal-inline-images-core.ts @@ -0,0 +1,40 @@ +import { detectTerminalImagePaths } from "./terminal-image-paths.js" + +export type TerminalInlineImageOutputSegment = { + readonly endedWithLineBreak: boolean + readonly imagePaths: ReadonlyArray + readonly text: string +} + +const lineBreakPattern = /\r\n|\r|\n/gu + +const endsWithLineBreak = (text: string): boolean => /\r\n$|\r$|\n$/u.test(text) + +export const splitTerminalInlineImageOutput = ( + data: string +): ReadonlyArray => { + if (data.length === 0) { + return [] + } + const segments: Array = [] + let startIndex = 0 + for (const match of data.matchAll(lineBreakPattern)) { + const endIndex = match.index + match[0].length + const text = data.slice(startIndex, endIndex) + segments.push({ + endedWithLineBreak: true, + imagePaths: detectTerminalImagePaths(text), + text + }) + startIndex = endIndex + } + if (startIndex < data.length) { + const text = data.slice(startIndex) + segments.push({ + endedWithLineBreak: endsWithLineBreak(text), + imagePaths: detectTerminalImagePaths(text), + text + }) + } + return segments +} diff --git a/packages/app/src/web/terminal-inline-images.ts b/packages/app/src/web/terminal-inline-images.ts new file mode 100644 index 00000000..1d741cdb --- /dev/null +++ b/packages/app/src/web/terminal-inline-images.ts @@ -0,0 +1,150 @@ +import type { IDisposable, ILink, Terminal } from "xterm" + +import { detectTerminalImagePathMatches } from "./terminal-image-paths.js" +import { resolveTerminalImageFetchUrl } from "./terminal-image-url.js" +import type { TerminalLifecycleState } from "./terminal-panel-runtime-types.js" +import type { ActiveTerminalSession } from "./terminal.js" + +export const terminalInlineImagePreviewLimit = 20 +export const terminalInlineImagePreviewRows = 4 + +export const terminalInlineImageSpacer = "\r\n".repeat(terminalInlineImagePreviewRows) + +const terminalInlineImagePreviewColumns = 16 +const terminalInlineImagePreviewHeightPx = 56 +const terminalInlineImagePreviewWidthPx = 96 + +type TerminalInlineImageEntry = { + readonly fetchUrl: string + readonly path: string +} + +const openImage = (fetchUrl: string): void => { + const imageWindow = window.open(fetchUrl, "_blank", "noopener,noreferrer") + if (imageWindow === null) { + return + } + imageWindow.opener = null +} + +const appendDecorationDisposable = ( + lifecycle: TerminalLifecycleState, + disposable: IDisposable +): void => { + lifecycle.inlineImageDisposables.push(disposable) + if (lifecycle.inlineImageDisposables.length <= terminalInlineImagePreviewLimit) { + return + } + lifecycle.inlineImageDisposables.shift()?.dispose() +} + +const renderInlineImageElement = ( + element: HTMLElement, + entry: TerminalInlineImageEntry +): void => { + if (element.dataset["path"] === entry.path) { + return + } + + const link = document.createElement("a") + link.href = entry.fetchUrl + link.rel = "noreferrer" + link.target = "_blank" + link.title = entry.path + link.style.alignItems = "center" + link.style.background = "#0d1218" + link.style.border = "1px solid #3a4652" + link.style.borderRadius = "6px" + link.style.boxSizing = "border-box" + link.style.cursor = "pointer" + link.style.display = "inline-flex" + link.style.height = `min(${terminalInlineImagePreviewHeightPx}px, calc(100% - 8px))` + link.style.justifyContent = "center" + link.style.margin = "4px 0" + link.style.padding = "4px" + link.style.pointerEvents = "auto" + link.style.width = `min(${terminalInlineImagePreviewWidthPx}px, 100%)` + + const image = document.createElement("img") + image.alt = entry.path + image.src = entry.fetchUrl + image.style.borderRadius = "4px" + image.style.display = "block" + image.style.height = "100%" + image.style.objectFit = "contain" + image.style.width = "100%" + + link.append(image) + element.dataset["path"] = entry.path + element.style.pointerEvents = "none" + element.replaceChildren(link) +} + +export const appendTerminalInlineImagePreview = ( + terminal: Terminal, + lifecycle: TerminalLifecycleState, + entry: TerminalInlineImageEntry +): boolean => { + const marker = terminal.registerMarker(0) + const decoration = terminal.registerDecoration({ + height: terminalInlineImagePreviewRows, + layer: "top", + marker, + width: Math.min(terminalInlineImagePreviewColumns, Math.max(1, terminal.cols)) + }) + if (decoration === undefined) { + marker.dispose() + return false + } + + decoration.onRender((element) => { + renderInlineImageElement(element, entry) + }) + appendDecorationDisposable(lifecycle, decoration) + return true +} + +const imageLink = ( + session: ActiveTerminalSession, + bufferLineNumber: number, + match: ReturnType[number] +): ILink => { + const fetchUrl = resolveTerminalImageFetchUrl(session.websocketPath, match.path) + return { + activate: () => { + openImage(fetchUrl) + }, + decorations: { + pointerCursor: true, + underline: true + }, + range: { + end: { + x: match.endIndex, + y: bufferLineNumber + }, + start: { + x: match.startIndex + 1, + y: bufferLineNumber + } + }, + text: match.path + } +} + +export const attachTerminalImageLinks = ( + terminal: Terminal, + session: ActiveTerminalSession +): IDisposable => + terminal.registerLinkProvider({ + provideLinks: (bufferLineNumber, callback) => { + const line = terminal.buffer.active.getLine(bufferLineNumber - 1) + if (line === undefined) { + callback([]) + return + } + const text = line.translateToString(true) + const matches = detectTerminalImagePathMatches(text) + callback(matches.map((match) => imageLink(session, bufferLineNumber, match))) + } + }) diff --git a/packages/app/src/web/terminal-panel-runtime-core.ts b/packages/app/src/web/terminal-panel-runtime-core.ts index e72d4692..4229ade9 100644 --- a/packages/app/src/web/terminal-panel-runtime-core.ts +++ b/packages/app/src/web/terminal-panel-runtime-core.ts @@ -2,6 +2,9 @@ import { Effect, Either } from "effect" import { Terminal } from "xterm" import { FitAddon } from "xterm-addon-fit" +import { resolveTerminalImageFetchUrl } from "./terminal-image-url.js" +import { splitTerminalInlineImageOutput, type TerminalInlineImageOutputSegment } from "./terminal-inline-images-core.js" +import { appendTerminalInlineImagePreview, terminalInlineImageSpacer } from "./terminal-inline-images.js" import type { TerminalCleanupArgs, TerminalInputController, @@ -35,6 +38,9 @@ const runOptionalTerminalOperation = (operation: () => void): boolean => export const createLifecycleState = (): TerminalLifecycleState => ({ attachedOnce: false, disposed: false, + inlineImageDisposables: [], + outputQueue: [], + outputWriting: false, readyNotified: false, reconnectAttempt: 0, reconnectStartedAtMs: null, @@ -51,6 +57,7 @@ const clearReconnectTimer = (lifecycle: TerminalLifecycleState): void => { export const createTerminalRuntime = (host: HTMLDivElement): TerminalRuntime => { const terminal = new Terminal({ + allowProposedApi: true, convertEol: false, cursorBlink: true, fontFamily: "'IBM Plex Mono', 'SFMono-Regular', monospace", @@ -173,6 +180,103 @@ const endTerminalSession = ( } } +const terminalImageEntry = ( + handlers: TerminalMessageHandlers, + path: string +) => ({ + fetchUrl: resolveTerminalImageFetchUrl(handlers.session.websocketPath, path), + path +}) + +const writePreviewSpacer = ( + handlers: TerminalMessageHandlers, + onComplete: () => void +): void => { + handlers.terminal.write(terminalInlineImageSpacer, onComplete) +} + +const writeInlineImagePreview = ( + handlers: TerminalMessageHandlers, + path: string, + onComplete: () => void +): void => { + const appended = appendTerminalInlineImagePreview( + handlers.terminal, + handlers.lifecycle, + terminalImageEntry(handlers, path) + ) + if (!appended) { + onComplete() + return + } + writePreviewSpacer(handlers, onComplete) +} + +const writeInlineImagePreviews = ( + handlers: TerminalMessageHandlers, + paths: ReadonlyArray, + onComplete: () => void +): void => { + let index = 0 + const writeNext = (): void => { + const path = paths[index] + if (path === undefined) { + onComplete() + return + } + index += 1 + writeInlineImagePreview(handlers, path, writeNext) + } + writeNext() +} + +const writeLineBreakBeforePreview = ( + handlers: TerminalMessageHandlers, + segment: TerminalInlineImageOutputSegment, + onComplete: () => void +): void => { + if (segment.endedWithLineBreak) { + onComplete() + return + } + handlers.terminal.write("\r\n", onComplete) +} + +const flushTerminalOutputQueue = (handlers: TerminalMessageHandlers): void => { + if (handlers.lifecycle.outputWriting || handlers.lifecycle.disposed) { + return + } + const segment = handlers.lifecycle.outputQueue.shift() + if (segment === undefined) { + return + } + + handlers.lifecycle.outputWriting = true + handlers.terminal.write(segment.text, () => { + if (segment.imagePaths.length === 0) { + handlers.lifecycle.outputWriting = false + flushTerminalOutputQueue(handlers) + return + } + writeLineBreakBeforePreview(handlers, segment, () => { + writeInlineImagePreviews(handlers, segment.imagePaths, () => { + handlers.lifecycle.outputWriting = false + flushTerminalOutputQueue(handlers) + }) + }) + }) +} + +const enqueueTerminalOutput = ( + handlers: TerminalMessageHandlers, + data: string +): void => { + for (const segment of splitTerminalInlineImageOutput(data)) { + handlers.lifecycle.outputQueue.push(segment) + } + flushTerminalOutputQueue(handlers) +} + const handleTerminalServerMessage = ( handlers: TerminalMessageHandlers, payload: string @@ -187,7 +291,7 @@ const handleTerminalServerMessage = ( return } if (message.type === "output") { - handlers.terminal.write(message.data) + enqueueTerminalOutput(handlers, message.data) return } if (message.type === "error") { @@ -228,6 +332,13 @@ export const cleanupTerminalResources = ( ): void => { args.lifecycle.disposed = true clearReconnectTimer(args.lifecycle) + for (const disposable of args.lifecycle.inlineImageDisposables) { + disposable.dispose() + } + args.lifecycle.inlineImageDisposables = [] + args.lifecycle.outputQueue = [] + args.lifecycle.outputWriting = false + args.removeImageLinks() args.removeImagePaste() args.removeInput() args.resizeObserver?.disconnect() diff --git a/packages/app/src/web/terminal-panel-runtime-types.ts b/packages/app/src/web/terminal-panel-runtime-types.ts index 94ac211b..e180d6f7 100644 --- a/packages/app/src/web/terminal-panel-runtime-types.ts +++ b/packages/app/src/web/terminal-panel-runtime-types.ts @@ -1,6 +1,7 @@ -import type { Terminal } from "xterm" +import type { IDisposable, Terminal } from "xterm" import type { FitAddon } from "xterm-addon-fit" +import type { TerminalInlineImageOutputSegment } from "./terminal-inline-images-core.js" import type { ActiveTerminalSession } from "./terminal.js" export type TerminalStatus = "attached" | "connecting" | "error" | "exited" | "reconnecting" @@ -17,6 +18,9 @@ export type TerminalInputController = { export type TerminalLifecycleState = { attachedOnce: boolean disposed: boolean + inlineImageDisposables: Array + outputQueue: Array + outputWriting: boolean readyNotified: boolean reconnectAttempt: number reconnectStartedAtMs: number | null @@ -44,6 +48,7 @@ export type TerminalCleanupArgs = { readonly connectionRef: { current: TerminalConnectionState } readonly lifecycle: TerminalLifecycleState readonly notifyMessage: (message: string) => void + readonly removeImageLinks: () => void readonly removeImagePaste: () => void readonly removeInput: () => void readonly removeResize: () => void diff --git a/packages/app/src/web/terminal-panel-runtime.ts b/packages/app/src/web/terminal-panel-runtime.ts index c30c2529..55b33a79 100644 --- a/packages/app/src/web/terminal-panel-runtime.ts +++ b/packages/app/src/web/terminal-panel-runtime.ts @@ -1,6 +1,7 @@ import { useEffect } from "react" import { attachTerminalImagePaste, createTerminalPasteGuard } from "./terminal-image-paste.js" +import { attachTerminalImageLinks } from "./terminal-inline-images.js" import { attachTerminalInput, cleanupTerminalResources, @@ -21,19 +22,23 @@ import type { type TerminalCleanupFactoryArgs = { readonly cleanupArgs: Omit< Parameters[0], - "removeImagePaste" | "removeInput" | "removeResize" + "removeImageLinks" | "removeImagePaste" | "removeInput" | "removeResize" > + readonly imageLinkDisposable: { readonly dispose: () => void } readonly imagePasteDisposable: { readonly dispose: () => void } readonly inputDisposable: { readonly dispose: () => void } readonly sendResize: () => void } const createTerminalCleanup = ( - { cleanupArgs, imagePasteDisposable, inputDisposable, sendResize }: TerminalCleanupFactoryArgs + { cleanupArgs, imageLinkDisposable, imagePasteDisposable, inputDisposable, sendResize }: TerminalCleanupFactoryArgs ): () => void => (): void => { cleanupTerminalResources({ ...cleanupArgs, + removeImageLinks: () => { + imageLinkDisposable.dispose() + }, removeImagePaste: () => { imagePasteDisposable.dispose() }, @@ -57,6 +62,12 @@ const createConnectSocket = ( return connectSocket } +const attachGlobalResizeListeners = (sendResize: () => void): void => { + globalThis.addEventListener("resize", sendResize) + globalThis.visualViewport?.addEventListener("resize", sendResize) + globalThis.visualViewport?.addEventListener("scroll", sendResize) +} + const mountTerminalSession = ( { connectionRef, hostRef, notifyMessage, onAttachFailure, runtimeRef, session, setStatus }: TerminalLifecycleArgs ): (() => void) | undefined => { @@ -77,7 +88,15 @@ const mountTerminalSession = ( const resizeObserver = observeTerminalResize(host, sendResize) const inputDisposable = attachTerminalInput(terminal, socketRef, pasteGuard) const imagePasteDisposable = attachTerminalImagePaste({ host, notifyMessage, pasteGuard, socketRef, terminal }) - const handlers: TerminalMessageHandlers = { connectionRef, lifecycle, notifyMessage, session, setStatus, terminal } + const imageLinkDisposable = attachTerminalImageLinks(terminal, session) + const handlers: TerminalMessageHandlers = { + connectionRef, + lifecycle, + notifyMessage, + session, + setStatus, + terminal + } const connectSocket = createConnectSocket({ handlers, lifecycle, @@ -91,13 +110,12 @@ const mountTerminalSession = ( }) runtimeRef.current = terminalInputController - globalThis.addEventListener("resize", sendResize) - globalThis.visualViewport?.addEventListener("resize", sendResize) - globalThis.visualViewport?.addEventListener("scroll", sendResize) + attachGlobalResizeListeners(sendResize) connectSocket() return createTerminalCleanup({ cleanupArgs: { connectionRef, lifecycle, notifyMessage, resizeObserver, runtimeRef, session, socketRef, terminal }, + imageLinkDisposable, imagePasteDisposable, inputDisposable, sendResize diff --git a/packages/app/src/web/terminal.ts b/packages/app/src/web/terminal.ts index 1d330ded..e019480e 100644 --- a/packages/app/src/web/terminal.ts +++ b/packages/app/src/web/terminal.ts @@ -57,7 +57,7 @@ export const buildProjectActiveTerminalSession = ( } } -const resolveTerminalApiBaseUrl = (): string => { +export const resolveTerminalApiBaseUrl = (): string => { const configured = import.meta.env.VITE_DOCKER_GIT_TERMINAL_API_BASE_URL if (configured !== undefined && configured.trim().length > 0) { return trimTrailingSlash(configured.trim()) @@ -66,7 +66,7 @@ const resolveTerminalApiBaseUrl = (): string => { return resolveApiBaseUrl() } -const resolveApiUrl = (): URL => { +export const resolveTerminalApiOriginUrl = (): URL => { const configured = resolveTerminalApiBaseUrl() if (configured.startsWith("http://") || configured.startsWith("https://")) { return new URL(configured) @@ -75,7 +75,7 @@ const resolveApiUrl = (): URL => { } export const resolveTerminalWebSocketUrl = (websocketPath: string, cols: number, rows: number): string => { - const apiUrl = resolveApiUrl() + const apiUrl = resolveTerminalApiOriginUrl() apiUrl.protocol = apiUrl.protocol === "https:" ? "wss:" : "ws:" apiUrl.pathname = `${apiUrl.pathname.replace(/\/$/u, "")}${websocketPath}` apiUrl.searchParams.set("cols", String(cols)) diff --git a/packages/app/tests/docker-git/terminal-image-paths.test.ts b/packages/app/tests/docker-git/terminal-image-paths.test.ts new file mode 100644 index 00000000..e9779502 --- /dev/null +++ b/packages/app/tests/docker-git/terminal-image-paths.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "@effect/vitest" + +import { + detectTerminalImagePathMatches, + detectTerminalImagePaths, + isSupportedTerminalImagePath, + stripTerminalAnsi +} from "../../src/web/terminal-image-paths.js" + +describe("terminal image path detection", () => { + it("detects a single absolute image path", () => { + expect(detectTerminalImagePaths("see /var/data/issue232-main.png for details")).toEqual([ + "/var/data/issue232-main.png" + ]) + }) + + it("returns match ranges for clickable image paths", () => { + expect(detectTerminalImagePathMatches("see /var/data/a.png.")).toEqual([ + { + endIndex: 19, + path: "/var/data/a.png", + startIndex: 4 + } + ]) + }) + + it("detects multiple distinct image paths", () => { + const text = "saved /var/data/a.png and /var/data/sub/b.jpg, also /home/user/c.webp" + expect(detectTerminalImagePaths(text)).toEqual([ + "/var/data/a.png", + "/var/data/sub/b.jpg", + "/home/user/c.webp" + ]) + }) + + it("deduplicates repeated image paths", () => { + expect(detectTerminalImagePaths("a /var/data/x.png b /var/data/x.png c")).toEqual(["/var/data/x.png"]) + }) + + it("ignores relative paths", () => { + expect(detectTerminalImagePaths("./relative.png and image.jpg here")).toEqual([]) + }) + + it("ignores unsupported extensions", () => { + expect(detectTerminalImagePaths("/var/data/file.txt /var/data/photo.bmp /var/data/doc.pdf")).toEqual([]) + }) + + it("trims trailing punctuation from detected paths", () => { + expect(detectTerminalImagePaths("look at /var/data/foo.png, then /var/data/bar.gif.")).toEqual([ + "/var/data/foo.png", + "/var/data/bar.gif" + ]) + }) + + it("recognises uppercase extensions", () => { + expect(detectTerminalImagePaths("/var/data/Photo.PNG /var/data/Cover.JPG")).toEqual([ + "/var/data/Photo.PNG", + "/var/data/Cover.JPG" + ]) + }) + + it("strips ANSI CSI sequences before scanning", () => { + const escapeChar = String.fromCodePoint(0x1B) + const text = `${escapeChar}[32m/var/data/colored.png${escapeChar}[0m` + expect(stripTerminalAnsi(text)).toBe("/var/data/colored.png") + expect(detectTerminalImagePaths(text)).toEqual(["/var/data/colored.png"]) + }) + + it("strips ANSI OSC sequences terminated by BEL or ST", () => { + const escapeChar = String.fromCodePoint(0x1B) + const bellChar = String.fromCodePoint(0x07) + const belTerminated = `${escapeChar}]0;title${bellChar}/var/data/bel.png` + const stTerminated = String.raw`${escapeChar}]0;title${escapeChar}\/var/data/st.png` + expect(stripTerminalAnsi(belTerminated)).toBe("/var/data/bel.png") + expect(stripTerminalAnsi(stTerminated)).toBe("/var/data/st.png") + expect(detectTerminalImagePaths(belTerminated)).toEqual(["/var/data/bel.png"]) + expect(detectTerminalImagePaths(stTerminated)).toEqual(["/var/data/st.png"]) + }) + + it("classifies supported image extensions", () => { + expect(isSupportedTerminalImagePath("/var/data/a.png")).toBe(true) + expect(isSupportedTerminalImagePath("/var/data/a.JPG")).toBe(true) + expect(isSupportedTerminalImagePath("/var/data/a.jpeg")).toBe(true) + expect(isSupportedTerminalImagePath("/var/data/a.gif")).toBe(true) + expect(isSupportedTerminalImagePath("/var/data/a.webp")).toBe(true) + expect(isSupportedTerminalImagePath("/var/data/a.bmp")).toBe(false) + expect(isSupportedTerminalImagePath("/var/data/a.txt")).toBe(false) + }) +}) diff --git a/packages/app/tests/docker-git/terminal-inline-images-core.test.ts b/packages/app/tests/docker-git/terminal-inline-images-core.test.ts new file mode 100644 index 00000000..9dadbf1e --- /dev/null +++ b/packages/app/tests/docker-git/terminal-inline-images-core.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "@effect/vitest" + +import { splitTerminalInlineImageOutput } from "../../src/web/terminal-inline-images-core.js" +import { terminalInlineImagePreviewRows, terminalInlineImageSpacer } from "../../src/web/terminal-inline-images.js" + +describe("terminal inline image output", () => { + it("keeps prompt output after a completed image path line in a later segment", () => { + expect(splitTerminalInlineImageOutput("/var/data/a.png\r\nprompt> ")).toEqual([ + { + endedWithLineBreak: true, + imagePaths: ["/var/data/a.png"], + text: "/var/data/a.png\r\n" + }, + { + endedWithLineBreak: false, + imagePaths: [], + text: "prompt> " + } + ]) + }) + + it("marks incomplete image path lines so the renderer can add a line break first", () => { + expect(splitTerminalInlineImageOutput("saved /var/data/a.png")).toEqual([ + { + endedWithLineBreak: false, + imagePaths: ["/var/data/a.png"], + text: "saved /var/data/a.png" + } + ]) + }) + + it("keeps inline image previews compact in the terminal output stream", () => { + expect(terminalInlineImagePreviewRows).toBe(4) + expect(terminalInlineImageSpacer).toBe("\r\n\r\n\r\n\r\n") + }) +}) diff --git a/packages/app/tests/docker-git/terminal.test.ts b/packages/app/tests/docker-git/terminal.test.ts index dd972d8a..acfdbf3b 100644 --- a/packages/app/tests/docker-git/terminal.test.ts +++ b/packages/app/tests/docker-git/terminal.test.ts @@ -6,6 +6,7 @@ import { extractTerminalImageBase64, isTerminalPasteShortcut } from "../../src/web/terminal-image-paste.js" +import { resolveTerminalImageBasePath, resolveTerminalImageFetchUrl } from "../../src/web/terminal-image-url.js" import { resolveTerminalCompactHeaderMode, resolveTerminalTypingMode, @@ -32,6 +33,15 @@ vi.mock("../../src/web/api-http.js", () => ({ resolveApiBaseUrl: resolveApiBaseUrlMock })) +const stubSameOriginLocation = (host: string, httpProtocol: string): void => { + resolveApiBaseUrlMock.mockReturnValue("/api") + vi.stubGlobal("location", { + hostname: host, + origin: `${httpProtocol}//${host}:4176`, + protocol: httpProtocol + }) +} + describe("browser terminal helpers", () => { beforeEach(() => { resolveApiBaseUrlMock.mockReset() @@ -54,12 +64,7 @@ describe("browser terminal helpers", () => { const httpProtocol = ["ht", "tp:"].join("") const wsProtocol = ["ws", "://"].join("") - resolveApiBaseUrlMock.mockReturnValue("/api") - vi.stubGlobal("location", { - hostname: host, - origin: `${httpProtocol}//${host}:4176`, - protocol: httpProtocol - }) + stubSameOriginLocation(host, httpProtocol) expect(resolveTerminalWebSocketUrl("/projects/proj/terminal-sessions/sess/ws", 80, 24)).toBe([ wsProtocol, @@ -130,4 +135,34 @@ describe("browser terminal helpers", () => { expect(shouldShowTerminalTabs(true, 2)).toBe(true) expect(shouldShowTerminalTabs(false, 1)).toBe(true) }) + + it("converts /ws suffix into /image base path", () => { + expect(resolveTerminalImageBasePath("/projects/by-key/proj/terminal-sessions/sess/ws")).toBe( + "/projects/by-key/proj/terminal-sessions/sess/image" + ) + }) + + it("builds an absolute backend image url with path query parameter", () => { + resolveApiBaseUrlMock.mockReturnValue("https://controller.example/api") + + expect( + resolveTerminalImageFetchUrl( + "/projects/by-key/proj%201/terminal-sessions/sess%2F2/ws", + "/var/data/sample image.png" + ) + ).toBe( + "https://controller.example/api/projects/by-key/proj%201/terminal-sessions/sess%2F2/image?path=%2Fvar%2Fdata%2Fsample+image.png" + ) + }) + + it("uses same-origin api proxy for relative image fetch urls", () => { + const host = "terminal.example.local" + const httpProtocol = ["ht", "tp:"].join("") + + stubSameOriginLocation(host, httpProtocol) + + expect( + resolveTerminalImageFetchUrl("/projects/proj/terminal-sessions/sess/ws", "/var/data/file.png") + ).toBe(`${httpProtocol}//${host}:4176/api/projects/proj/terminal-sessions/sess/image?path=%2Fvar%2Fdata%2Ffile.png`) + }) })