Skip to content

Commit 0a3f87e

Browse files
authored
[codex] Add terminal image preview toggle (#297)
* feat(app): add terminal image preview toggle * chore(app): address terminal preview review comments
1 parent 6354bd0 commit 0a3f87e

7 files changed

Lines changed: 314 additions & 54 deletions

packages/app/src/web/panel-terminal.tsx

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,16 +263,22 @@ const TerminalActionButton = (
263263
{
264264
children,
265265
compactTypingMode,
266-
onClick
266+
onClick,
267+
pressed,
268+
title
267269
}: {
268270
readonly children: string
269271
readonly compactTypingMode: boolean
270272
readonly onClick: () => void
273+
readonly pressed?: boolean
274+
readonly title?: string
271275
}
272276
): JSX.Element => (
273277
<button
278+
aria-pressed={pressed}
274279
onClick={onClick}
275280
style={compactTypingMode ? compactCloseButtonStyle : closeButtonStyle}
281+
title={title}
276282
type="button"
277283
>
278284
{children}
@@ -307,13 +313,15 @@ const OptionalTerminalActionButton = (
307313
const TerminalHeaderActions = (
308314
{
309315
compactHeaderMode,
316+
inlineImagePreviewsEnabled,
310317
onApplyProject,
311318
onDetach,
312319
onKill,
313320
onOpenBrowser,
314321
onOpenSkiller,
315322
onOpenTaskManager,
316323
onOpenTerminal,
324+
onToggleInlineImagePreviews,
317325
session
318326
}:
319327
& Pick<
@@ -329,9 +337,16 @@ const TerminalHeaderActions = (
329337
>
330338
& {
331339
readonly compactHeaderMode: boolean
340+
readonly inlineImagePreviewsEnabled: boolean
341+
readonly onToggleInlineImagePreviews: () => void
332342
}
333343
): JSX.Element => {
334344
const hasProjectActions = session.browserProjectId !== undefined
345+
const imageToggleLabel = inlineImagePreviewsEnabled ? "Images on" : "Images off"
346+
const compactImageToggleLabel = inlineImagePreviewsEnabled ? "Img on" : "Img off"
347+
const imageToggleTitle = inlineImagePreviewsEnabled
348+
? "Automatic image previews enabled"
349+
: "Automatic image previews disabled"
335350

336351
return (
337352
<div style={compactHeaderMode ? compactHeaderActionsStyle : headerActionsStyle}>
@@ -370,6 +385,14 @@ const TerminalHeaderActions = (
370385
label="New terminal"
371386
onClick={onOpenTerminal}
372387
/>
388+
<TerminalActionButton
389+
compactTypingMode={compactHeaderMode}
390+
onClick={onToggleInlineImagePreviews}
391+
pressed={inlineImagePreviewsEnabled}
392+
title={imageToggleTitle}
393+
>
394+
{compactHeaderMode ? compactImageToggleLabel : imageToggleLabel}
395+
</TerminalActionButton>
373396
<TerminalActionButton compactTypingMode={compactHeaderMode} onClick={onDetach}>
374397
Detach
375398
</TerminalActionButton>
@@ -383,13 +406,15 @@ const TerminalHeaderActions = (
383406
const TerminalHeader = (
384407
{
385408
compactHeaderMode,
409+
inlineImagePreviewsEnabled,
386410
onApplyProject,
387411
onDetach,
388412
onKill,
389413
onOpenBrowser,
390414
onOpenSkiller,
391415
onOpenTaskManager,
392416
onOpenTerminal,
417+
onToggleInlineImagePreviews,
393418
session,
394419
status
395420
}:
@@ -406,20 +431,24 @@ const TerminalHeader = (
406431
>
407432
& {
408433
readonly compactHeaderMode: boolean
434+
readonly inlineImagePreviewsEnabled: boolean
435+
readonly onToggleInlineImagePreviews: () => void
409436
readonly status: TerminalStatus
410437
}
411438
): JSX.Element => (
412439
<div style={compactHeaderMode ? compactHeaderStyle : headerStyle}>
413440
<TerminalHeaderTitle compactHeaderMode={compactHeaderMode} session={session} status={status} />
414441
<TerminalHeaderActions
415442
compactHeaderMode={compactHeaderMode}
443+
inlineImagePreviewsEnabled={inlineImagePreviewsEnabled}
416444
onApplyProject={onApplyProject}
417445
onDetach={onDetach}
418446
onKill={onKill}
419447
onOpenBrowser={onOpenBrowser}
420448
onOpenSkiller={onOpenSkiller}
421449
onOpenTaskManager={onOpenTaskManager}
422450
onOpenTerminal={onOpenTerminal}
451+
onToggleInlineImagePreviews={onToggleInlineImagePreviews}
423452
session={session}
424453
/>
425454
</div>
@@ -575,10 +604,13 @@ export const TerminalPanel = (
575604
): JSX.Element => {
576605
const connectionRef = useRef<TerminalConnectionState>({ closing: false, opened: false })
577606
const hostRef = useRef<HTMLDivElement | null>(null)
607+
const inlineImagePreviewsEnabledRef = useRef(true)
578608
const runtimeRef = useRef<TerminalInputController | null>(null)
579609
const [status, setStatus] = useState<TerminalStatus>(() => resolveInitialTerminalStatus(session))
610+
const [inlineImagePreviewsEnabled, setInlineImagePreviewsEnabled] = useState(true)
580611
const [mobileControlsCollapsed, setMobileControlsCollapsed] = useState(false)
581612
const [mobileCtrlArmed, setMobileCtrlArmed] = useState(false)
613+
const terminalSessionId = session.session.id
582614
const onAttachFailureRef = useRef(onAttachFailure)
583615
const onMessageRef = useRef(onMessage)
584616
useEffect(() => {
@@ -590,12 +622,24 @@ export const TerminalPanel = (
590622
useEffect(() => {
591623
setStatus(resolveInitialTerminalStatus(session))
592624
}, [session])
625+
useEffect(() => {
626+
inlineImagePreviewsEnabledRef.current = true
627+
setInlineImagePreviewsEnabled(true)
628+
}, [terminalSessionId])
593629
const notifyAttachFailure = useCallback(() => {
594630
onAttachFailureRef.current()
595631
}, [])
596632
const notifyMessage = useCallback((message: string) => {
597633
onMessageRef.current(message)
598634
}, [])
635+
const toggleInlineImagePreviews = useCallback(() => {
636+
setInlineImagePreviewsEnabled((current) => {
637+
const next = !current
638+
inlineImagePreviewsEnabledRef.current = next
639+
return next
640+
})
641+
retainTerminalFocus(runtimeRef.current)
642+
}, [])
599643
const compactHeaderMode = resolveTerminalCompactHeaderMode(mobileMode)
600644
const compactTypingMode = resolveTerminalTypingMode(mobileMode, keyboardOpen)
601645
const hasBodyContent = bodyContent !== undefined
@@ -644,6 +688,7 @@ export const TerminalPanel = (
644688
useTerminalSessionLifecycle({
645689
connectionRef,
646690
hostRef,
691+
inlineImagePreviewsEnabledRef,
647692
notifyMessage,
648693
onAttachFailure: notifyAttachFailure,
649694
runtimeRef,
@@ -655,6 +700,7 @@ export const TerminalPanel = (
655700
<div style={terminalPanelStyle(mobileMode, keyboardOpen)}>
656701
<TerminalHeader
657702
compactHeaderMode={compactHeaderMode}
703+
inlineImagePreviewsEnabled={inlineImagePreviewsEnabled}
658704
onApplyProject={onApplyProject}
659705
onDetach={() => {
660706
connectionRef.current.closing = true
@@ -668,6 +714,7 @@ export const TerminalPanel = (
668714
onOpenSkiller={onOpenSkiller}
669715
onOpenTaskManager={onOpenTaskManager}
670716
onOpenTerminal={onOpenTerminal}
717+
onToggleInlineImagePreviews={toggleInlineImagePreviews}
671718
session={session}
672719
status={status}
673720
/>

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,20 @@ export type TerminalInlineImageOutputSegment = {
66
readonly text: string
77
}
88

9+
export type TerminalInlineImagePreviewsEnabledRef = { readonly current: boolean }
10+
11+
export type TerminalOutputSegmentWriter = {
12+
readonly writePreviewLineBreak: (segment: TerminalInlineImageOutputSegment, onComplete: () => void) => void
13+
readonly writePreviews: (paths: ReadonlyArray<string>, onComplete: () => void) => void
14+
readonly writeText: (text: string, onComplete: () => void) => void
15+
}
16+
17+
export type TerminalOutputSegmentWriteArgs = {
18+
readonly inlineImagePreviewsEnabledRef: TerminalInlineImagePreviewsEnabledRef
19+
readonly segment: TerminalInlineImageOutputSegment
20+
readonly writer: TerminalOutputSegmentWriter
21+
}
22+
923
const lineBreakPattern = /\r\n|\r|\n/gu
1024

1125
const endsWithLineBreak = (text: string): boolean => /\r\n$|\r$|\n$/u.test(text)
@@ -38,3 +52,39 @@ export const splitTerminalInlineImageOutput = (
3852
}
3953
return segments
4054
}
55+
56+
/**
57+
* Coordinates terminal output writes for one parsed segment.
58+
*
59+
* This function only sequences the supplied writer callbacks. It does not fetch
60+
* image data, allocate decorations, or mutate terminal state directly; those
61+
* effects belong to the writer implementation.
62+
*
63+
* @pure false - invokes effectful writer callbacks.
64+
* @effect writer callbacks: writeText, writePreviewLineBreak, writePreviews.
65+
* @precondition segment is the next queued terminal output segment and
66+
* onComplete belongs to the caller's active output queue drain.
67+
* @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.
70+
* @invariant segment.text is emitted before any preview callback, and preview
71+
* callbacks never run when imagePaths is empty or previews are disabled.
72+
* @complexity O(1) plus writer callback complexity; image paths are forwarded
73+
* without iteration.
74+
* @throws Through writer callbacks or onComplete only; this function has no
75+
* explicit throw path.
76+
*/
77+
export const writeTerminalOutputSegment = (
78+
{ inlineImagePreviewsEnabledRef, segment, writer }: TerminalOutputSegmentWriteArgs,
79+
onComplete: () => void
80+
): void => {
81+
writer.writeText(segment.text, () => {
82+
if (segment.imagePaths.length === 0 || !inlineImagePreviewsEnabledRef.current) {
83+
onComplete()
84+
return
85+
}
86+
writer.writePreviewLineBreak(segment, () => {
87+
writer.writePreviews(segment.imagePaths, onComplete)
88+
})
89+
})
90+
}

packages/app/src/web/terminal-panel-runtime-core.ts

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import { Terminal } from "xterm"
44
import { FitAddon } from "xterm-addon-fit"
55

66
import { resolveTerminalImageFetchUrl } from "./terminal-image-url.js"
7-
import { splitTerminalInlineImageOutput, type TerminalInlineImageOutputSegment } from "./terminal-inline-images-core.js"
7+
import {
8+
splitTerminalInlineImageOutput,
9+
type TerminalInlineImageOutputSegment,
10+
writeTerminalOutputSegment
11+
} from "./terminal-inline-images-core.js"
812
import {
913
appendTerminalInlineImagePreview,
1014
cachedTerminalInlineImageEntry,
@@ -353,18 +357,23 @@ const flushTerminalOutputQueue = (handlers: TerminalMessageHandlers): void => {
353357
}
354358

355359
handlers.lifecycle.outputWriting = true
356-
handlers.terminal.write(segment.text, () => {
357-
if (segment.imagePaths.length === 0) {
358-
handlers.lifecycle.outputWriting = false
359-
flushTerminalOutputQueue(handlers)
360-
return
360+
writeTerminalOutputSegment({
361+
inlineImagePreviewsEnabledRef: handlers.inlineImagePreviewsEnabledRef,
362+
segment,
363+
writer: {
364+
writePreviewLineBreak: (outputSegment, onComplete) => {
365+
writeLineBreakBeforePreview(handlers, outputSegment, onComplete)
366+
},
367+
writePreviews: (paths, onComplete) => {
368+
writeInlineImagePreviews(handlers, paths, onComplete)
369+
},
370+
writeText: (text, onComplete) => {
371+
handlers.terminal.write(text, onComplete)
372+
}
361373
}
362-
writeLineBreakBeforePreview(handlers, segment, () => {
363-
writeInlineImagePreviews(handlers, segment.imagePaths, () => {
364-
handlers.lifecycle.outputWriting = false
365-
flushTerminalOutputQueue(handlers)
366-
})
367-
})
374+
}, () => {
375+
handlers.lifecycle.outputWriting = false
376+
flushTerminalOutputQueue(handlers)
368377
})
369378
}
370379

packages/app/src/web/terminal-panel-runtime-types.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import type { IDisposable, Terminal } from "xterm"
22
import type { FitAddon } from "xterm-addon-fit"
33

4-
import type { TerminalInlineImageOutputSegment } from "./terminal-inline-images-core.js"
4+
import type {
5+
TerminalInlineImageOutputSegment,
6+
TerminalInlineImagePreviewsEnabledRef
7+
} from "./terminal-inline-images-core.js"
58
import type { ActiveTerminalSession } from "./terminal.js"
69

710
export type TerminalStatus = "attached" | "connecting" | "error" | "exited" | "reconnecting"
@@ -38,6 +41,7 @@ export type TerminalPasteGuard = {
3841

3942
export type TerminalMessageHandlers = {
4043
readonly connectionRef: { current: TerminalConnectionState }
44+
readonly inlineImagePreviewsEnabledRef: TerminalInlineImagePreviewsEnabledRef
4145
readonly lifecycle: TerminalLifecycleState
4246
readonly notifyMessage: (message: string) => void
4347
readonly session: ActiveTerminalSession
@@ -63,6 +67,7 @@ export type TerminalCleanupArgs = {
6367
export type TerminalLifecycleArgs = {
6468
readonly connectionRef: { current: TerminalConnectionState }
6569
readonly hostRef: { readonly current: HTMLDivElement | null }
70+
readonly inlineImagePreviewsEnabledRef: TerminalInlineImagePreviewsEnabledRef
6671
readonly notifyMessage: (message: string) => void
6772
readonly onAttachFailure: () => void
6873
readonly runtimeRef: { current: TerminalInputController | null }

0 commit comments

Comments
 (0)