Skip to content
Merged
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
49 changes: 48 additions & 1 deletion packages/app/src/web/panel-terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -263,16 +263,22 @@ const TerminalActionButton = (
{
children,
compactTypingMode,
onClick
onClick,
pressed,
title
}: {
readonly children: string
readonly compactTypingMode: boolean
readonly onClick: () => void
readonly pressed?: boolean
readonly title?: string
}
): JSX.Element => (
<button
aria-pressed={pressed}
onClick={onClick}
style={compactTypingMode ? compactCloseButtonStyle : closeButtonStyle}
title={title}
type="button"
>
{children}
Expand Down Expand Up @@ -307,13 +313,15 @@ const OptionalTerminalActionButton = (
const TerminalHeaderActions = (
{
compactHeaderMode,
inlineImagePreviewsEnabled,
onApplyProject,
onDetach,
onKill,
onOpenBrowser,
onOpenSkiller,
onOpenTaskManager,
onOpenTerminal,
onToggleInlineImagePreviews,
session
}:
& Pick<
Expand All @@ -329,9 +337,16 @@ const TerminalHeaderActions = (
>
& {
readonly compactHeaderMode: boolean
readonly inlineImagePreviewsEnabled: boolean
readonly onToggleInlineImagePreviews: () => void
}
): JSX.Element => {
const hasProjectActions = session.browserProjectId !== undefined
const imageToggleLabel = inlineImagePreviewsEnabled ? "Images on" : "Images off"
const compactImageToggleLabel = inlineImagePreviewsEnabled ? "Img on" : "Img off"
const imageToggleTitle = inlineImagePreviewsEnabled
? "Automatic image previews enabled"
: "Automatic image previews disabled"

return (
<div style={compactHeaderMode ? compactHeaderActionsStyle : headerActionsStyle}>
Expand Down Expand Up @@ -370,6 +385,14 @@ const TerminalHeaderActions = (
label="New terminal"
onClick={onOpenTerminal}
/>
<TerminalActionButton
compactTypingMode={compactHeaderMode}
onClick={onToggleInlineImagePreviews}
pressed={inlineImagePreviewsEnabled}
title={imageToggleTitle}
>
{compactHeaderMode ? compactImageToggleLabel : imageToggleLabel}
</TerminalActionButton>
<TerminalActionButton compactTypingMode={compactHeaderMode} onClick={onDetach}>
Detach
</TerminalActionButton>
Expand All @@ -383,13 +406,15 @@ const TerminalHeaderActions = (
const TerminalHeader = (
{
compactHeaderMode,
inlineImagePreviewsEnabled,
onApplyProject,
onDetach,
onKill,
onOpenBrowser,
onOpenSkiller,
onOpenTaskManager,
onOpenTerminal,
onToggleInlineImagePreviews,
session,
status
}:
Expand All @@ -406,20 +431,24 @@ const TerminalHeader = (
>
& {
readonly compactHeaderMode: boolean
readonly inlineImagePreviewsEnabled: boolean
readonly onToggleInlineImagePreviews: () => void
readonly status: TerminalStatus
}
): JSX.Element => (
<div style={compactHeaderMode ? compactHeaderStyle : headerStyle}>
<TerminalHeaderTitle compactHeaderMode={compactHeaderMode} session={session} status={status} />
<TerminalHeaderActions
compactHeaderMode={compactHeaderMode}
inlineImagePreviewsEnabled={inlineImagePreviewsEnabled}
onApplyProject={onApplyProject}
onDetach={onDetach}
onKill={onKill}
onOpenBrowser={onOpenBrowser}
onOpenSkiller={onOpenSkiller}
onOpenTaskManager={onOpenTaskManager}
onOpenTerminal={onOpenTerminal}
onToggleInlineImagePreviews={onToggleInlineImagePreviews}
session={session}
/>
</div>
Expand Down Expand Up @@ -575,10 +604,13 @@ export const TerminalPanel = (
): JSX.Element => {
const connectionRef = useRef<TerminalConnectionState>({ closing: false, opened: false })
const hostRef = useRef<HTMLDivElement | null>(null)
const inlineImagePreviewsEnabledRef = useRef(true)
const runtimeRef = useRef<TerminalInputController | null>(null)
const [status, setStatus] = useState<TerminalStatus>(() => resolveInitialTerminalStatus(session))
const [inlineImagePreviewsEnabled, setInlineImagePreviewsEnabled] = useState(true)
const [mobileControlsCollapsed, setMobileControlsCollapsed] = useState(false)
const [mobileCtrlArmed, setMobileCtrlArmed] = useState(false)
const terminalSessionId = session.session.id
const onAttachFailureRef = useRef(onAttachFailure)
const onMessageRef = useRef(onMessage)
useEffect(() => {
Expand All @@ -590,12 +622,24 @@ export const TerminalPanel = (
useEffect(() => {
setStatus(resolveInitialTerminalStatus(session))
}, [session])
useEffect(() => {
inlineImagePreviewsEnabledRef.current = true
setInlineImagePreviewsEnabled(true)
}, [terminalSessionId])
const notifyAttachFailure = useCallback(() => {
onAttachFailureRef.current()
}, [])
const notifyMessage = useCallback((message: string) => {
onMessageRef.current(message)
}, [])
const toggleInlineImagePreviews = useCallback(() => {
setInlineImagePreviewsEnabled((current) => {
const next = !current
inlineImagePreviewsEnabledRef.current = next
return next
})
retainTerminalFocus(runtimeRef.current)
}, [])
const compactHeaderMode = resolveTerminalCompactHeaderMode(mobileMode)
const compactTypingMode = resolveTerminalTypingMode(mobileMode, keyboardOpen)
const hasBodyContent = bodyContent !== undefined
Expand Down Expand Up @@ -644,6 +688,7 @@ export const TerminalPanel = (
useTerminalSessionLifecycle({
connectionRef,
hostRef,
inlineImagePreviewsEnabledRef,
notifyMessage,
onAttachFailure: notifyAttachFailure,
runtimeRef,
Expand All @@ -655,6 +700,7 @@ export const TerminalPanel = (
<div style={terminalPanelStyle(mobileMode, keyboardOpen)}>
<TerminalHeader
compactHeaderMode={compactHeaderMode}
inlineImagePreviewsEnabled={inlineImagePreviewsEnabled}
onApplyProject={onApplyProject}
onDetach={() => {
connectionRef.current.closing = true
Expand All @@ -668,6 +714,7 @@ export const TerminalPanel = (
onOpenSkiller={onOpenSkiller}
onOpenTaskManager={onOpenTaskManager}
onOpenTerminal={onOpenTerminal}
onToggleInlineImagePreviews={toggleInlineImagePreviews}
session={session}
status={status}
/>
Expand Down
50 changes: 50 additions & 0 deletions packages/app/src/web/terminal-inline-images-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ export type TerminalInlineImageOutputSegment = {
readonly text: string
}

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 writeText: (text: string, onComplete: () => void) => void
}

export type TerminalOutputSegmentWriteArgs = {
readonly inlineImagePreviewsEnabledRef: TerminalInlineImagePreviewsEnabledRef
readonly segment: TerminalInlineImageOutputSegment
readonly writer: TerminalOutputSegmentWriter
}

const lineBreakPattern = /\r\n|\r|\n/gu

const endsWithLineBreak = (text: string): boolean => /\r\n$|\r$|\n$/u.test(text)
Expand Down Expand Up @@ -38,3 +52,39 @@ export const splitTerminalInlineImageOutput = (
}
return segments
}

/**
* Coordinates terminal output writes for one parsed segment.
*
* This function only sequences the supplied writer callbacks. It does not fetch
* image data, allocate decorations, or mutate terminal state directly; those
* effects belong to the writer implementation.
*
* @pure false - invokes effectful writer callbacks.
* @effect writer callbacks: writeText, writePreviewLineBreak, 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.
* @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
* without iteration.
* @throws Through writer callbacks or onComplete only; this function has no
* explicit throw path.
*/
export const writeTerminalOutputSegment = (
{ inlineImagePreviewsEnabledRef, segment, writer }: TerminalOutputSegmentWriteArgs,
onComplete: () => void
): void => {
writer.writeText(segment.text, () => {
if (segment.imagePaths.length === 0 || !inlineImagePreviewsEnabledRef.current) {
onComplete()
return
}
writer.writePreviewLineBreak(segment, () => {
writer.writePreviews(segment.imagePaths, onComplete)
})
})
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
33 changes: 21 additions & 12 deletions packages/app/src/web/terminal-panel-runtime-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ 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 {
splitTerminalInlineImageOutput,
type TerminalInlineImageOutputSegment,
writeTerminalOutputSegment
} from "./terminal-inline-images-core.js"
import {
appendTerminalInlineImagePreview,
cachedTerminalInlineImageEntry,
Expand Down Expand Up @@ -353,18 +357,23 @@ const flushTerminalOutputQueue = (handlers: TerminalMessageHandlers): void => {
}

handlers.lifecycle.outputWriting = true
handlers.terminal.write(segment.text, () => {
if (segment.imagePaths.length === 0) {
handlers.lifecycle.outputWriting = false
flushTerminalOutputQueue(handlers)
return
writeTerminalOutputSegment({
inlineImagePreviewsEnabledRef: handlers.inlineImagePreviewsEnabledRef,
segment,
writer: {
writePreviewLineBreak: (outputSegment, onComplete) => {
writeLineBreakBeforePreview(handlers, outputSegment, onComplete)
},
writePreviews: (paths, onComplete) => {
writeInlineImagePreviews(handlers, paths, onComplete)
},
writeText: (text, onComplete) => {
handlers.terminal.write(text, onComplete)
}
}
writeLineBreakBeforePreview(handlers, segment, () => {
writeInlineImagePreviews(handlers, segment.imagePaths, () => {
handlers.lifecycle.outputWriting = false
flushTerminalOutputQueue(handlers)
})
})
}, () => {
handlers.lifecycle.outputWriting = false
flushTerminalOutputQueue(handlers)
})
}

Expand Down
7 changes: 6 additions & 1 deletion packages/app/src/web/terminal-panel-runtime-types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import type { IDisposable, Terminal } from "xterm"
import type { FitAddon } from "xterm-addon-fit"

import type { TerminalInlineImageOutputSegment } from "./terminal-inline-images-core.js"
import type {
TerminalInlineImageOutputSegment,
TerminalInlineImagePreviewsEnabledRef
} from "./terminal-inline-images-core.js"
import type { ActiveTerminalSession } from "./terminal.js"

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

export type TerminalMessageHandlers = {
readonly connectionRef: { current: TerminalConnectionState }
readonly inlineImagePreviewsEnabledRef: TerminalInlineImagePreviewsEnabledRef
readonly lifecycle: TerminalLifecycleState
readonly notifyMessage: (message: string) => void
readonly session: ActiveTerminalSession
Expand All @@ -63,6 +67,7 @@ export type TerminalCleanupArgs = {
export type TerminalLifecycleArgs = {
readonly connectionRef: { current: TerminalConnectionState }
readonly hostRef: { readonly current: HTMLDivElement | null }
readonly inlineImagePreviewsEnabledRef: TerminalInlineImagePreviewsEnabledRef
readonly notifyMessage: (message: string) => void
readonly onAttachFailure: () => void
readonly runtimeRef: { current: TerminalInputController | null }
Expand Down
Loading
Loading