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
6 changes: 5 additions & 1 deletion extensions/opencode/freshell.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@
"bypassPermissions": "{\"edit\":\"allow\",\"bash\":\"allow\"}"
},
"supportsPermissionMode": true,
"supportsModel": true
"supportsModel": true,
"terminalBehavior": {
"preferredRenderer": "canvas",
"scrollInputPolicy": "fallbackToCursorKeysWhenAltScreenMouseCapture"
}
},
"picker": {
"group": "agents"
Expand Down
1 change: 1 addition & 0 deletions server/extension-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ export class ExtensionManager extends EventEmitter {
supportsSandbox: manifest.cli.supportsSandbox,
supportsResume: !!manifest.cli.resumeArgs,
resumeCommandTemplate,
terminalBehavior: manifest.cli.terminalBehavior,
}
}

Expand Down
6 changes: 6 additions & 0 deletions server/extension-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ const ServerConfigSchema = z.strictObject({
singleton: z.boolean().optional().default(true),
})

const TerminalBehaviorConfigSchema = z.strictObject({
preferredRenderer: z.enum(['canvas']).optional(),
scrollInputPolicy: z.enum(['native', 'fallbackToCursorKeysWhenAltScreenMouseCapture']).optional(),
})

const CliConfigSchema = z.strictObject({
command: z.string().min(1),
args: z.array(z.string()).optional().default([]),
Expand All @@ -56,6 +61,7 @@ const CliConfigSchema = z.strictObject({
supportsPermissionMode: z.boolean().optional(),
supportsModel: z.boolean().optional(), // shows model field in SettingsView
supportsSandbox: z.boolean().optional(), // shows sandbox selector in SettingsView
terminalBehavior: TerminalBehaviorConfigSchema.optional(),
})

// ──────────────────────────────────────────────────────────────
Expand Down
4 changes: 4 additions & 0 deletions shared/extension-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,9 @@ export interface ClientExtensionEntry {
supportsSandbox?: boolean
supportsResume?: boolean
resumeCommandTemplate?: string[] // e.g., ["claude", "--resume", "{{sessionId}}"]
terminalBehavior?: {
preferredRenderer?: 'canvas'
scrollInputPolicy?: 'native' | 'fallbackToCursorKeysWhenAltScreenMouseCapture'
}
}
}
16 changes: 13 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -705,22 +705,24 @@ export default function App() {
if (!msg?.type) return
if (msg.type === 'ready') {
const ready = ReadyMessageSchema.safeParse(msg)
const nextServerInstanceId = ready.success ? ready.data.serverInstanceId : undefined
if (
ready.success &&
lastReadyServerInstanceId &&
lastReadyServerInstanceId !== ready.data.serverInstanceId
lastReadyServerInstanceId !== nextServerInstanceId
) {
platformCapabilitiesLoaded = false
dispatch(setRegistry([]))
}
if (ready.success) {
lastReadyServerInstanceId = ready.data.serverInstanceId
lastReadyServerInstanceId = nextServerInstanceId
}
// If the initial connect attempt failed before ready, WsClient may still auto-reconnect.
// Treat 'ready' as the source of truth for connection status.
resetCodexActivityOverlay()
dispatch(setError(undefined))
dispatch(setStatus('ready'))
dispatch(setServerInstanceId(ready.success ? ready.data.serverInstanceId : undefined))
dispatch(setServerInstanceId(nextServerInstanceId))
const newBootId = ready.success ? ready.data.bootId : undefined
const previousBootId = appStore.getState().connection.bootId
const serverRestarted = !!previousBootId && previousBootId !== newBootId
Expand Down Expand Up @@ -888,6 +890,14 @@ export default function App() {
// sidebar snapshot. The HTTP bootstrap window remains the source of truth.
if (ws.isReady) {
if (cancelled) return
const previousServerInstanceId = appStore.getState().connection.serverInstanceId
if (
previousServerInstanceId &&
ws.serverInstanceId &&
previousServerInstanceId !== ws.serverInstanceId
) {
dispatch(setRegistry([]))
}
lastReadyServerInstanceId = ws.serverInstanceId
resetCodexActivityOverlay()
dispatch(setError(undefined))
Expand Down
110 changes: 79 additions & 31 deletions src/components/TerminalView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
type AttachSeqState,
} from '@/lib/terminal-attach-seq-state'
import { useMobile } from '@/hooks/useMobile'
import { useEnsureExtensionsRegistry } from '@/hooks/useEnsureExtensionsRegistry'
import { findLocalFilePaths } from '@/lib/path-utils'
import { findUrls } from '@/lib/url-utils'
import { setHoveredUrl, clearHoveredUrl } from '@/lib/terminal-hovered-url'
Expand Down Expand Up @@ -77,6 +78,13 @@ import type { PaneContent, PaneContentInput, PaneRefreshRequest, TerminalPaneCon
import '@xterm/xterm/css/xterm.css'
import { getHydrationQueue } from '@/lib/hydration-queue'
import { createLogger } from '@/lib/client-logger'
import {
getProviderTerminalBehavior,
prefersCanvasRenderer,
providerUsesExtensionTerminalBehavior,
scrollLinesToCursorKeys,
shouldTranslateScrollToCursorKeys,
} from '@/lib/terminal-behavior'

const log = createLogger('TerminalView')

Expand Down Expand Up @@ -359,6 +367,14 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter
// Extract terminal-specific fields (safe because we check kind later)
const isTerminal = paneContent.kind === 'terminal'
const terminalContent = isTerminal ? paneContent : null
const extensions = useAppSelector((s) => s.extensions?.entries ?? [], shallowEqual)
const shouldResolveProviderBehavior = isTerminal && providerUsesExtensionTerminalBehavior(terminalContent?.mode)
const extensionRegistryReady = useEnsureExtensionsRegistry(shouldResolveProviderBehavior)
Comment on lines +371 to +372
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Load extension registry before resolving non-OpenCode behavior

useEnsureExtensionsRegistry is now gated by providerUsesExtensionTerminalBehavior, which only returns true for opencode. For any other CLI extension that sets cli.terminalBehavior (the new manifest field added in this commit), a terminal-only startup path never triggers /api/extensions, so providerBehavior stays at defaults and the declared renderer/scroll policy is ignored. This means restored or directly-opened third-party extension terminals can silently run with the wrong behavior until the user manually opens another view that happens to load the registry.

Useful? React with 👍 / 👎.

const providerBehavior = useMemo(
() => getProviderTerminalBehavior(terminalContent?.mode, extensions),
[terminalContent?.mode, extensions],
)
const shouldWaitForProviderBehavior = shouldResolveProviderBehavior && !extensionRegistryReady
const terminalSearchState = useAppSelector((state) => {
const terminalId = terminalContent?.terminalId
if (!terminalId) return null
Expand Down Expand Up @@ -402,6 +418,7 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter
pendingSinceSeq: 0,
})
const contentRef = useRef<TerminalPaneContent | null>(terminalContent)
const providerBehaviorRef = useRef(providerBehavior)
const refreshRequestRef = useRef<PaneRefreshRequest | null>(refreshRequest)
const handledRefreshRequestIdRef = useRef<string | null>(null)
const hasMountedRefreshEffectRef = useRef(false)
Expand Down Expand Up @@ -517,6 +534,7 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter
attentionDismissRef.current = settings.panes?.attentionDismiss ?? 'click'
debugRef.current = !!settings.logging?.debug
refreshRequestRef.current = refreshRequest
providerBehaviorRef.current = providerBehavior

const shouldFocusActiveTerminal = !hidden && activeTabId === tabId && activePaneId === paneId

Expand All @@ -537,6 +555,47 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter
lastSessionActivityAtRef.current = 0
}, [terminalContent?.resumeSessionId])

const sendInput = useCallback((data: string) => {
const tid = terminalIdRef.current
if (!tid) return
// In 'type' mode, clear attention when user sends input.
// In 'click' mode, attention is cleared by the notification hook on tab switch.
if (attentionDismissRef.current === 'type') {
if (hasAttentionRef.current) {
dispatch(clearTabAttention({ tabId }))
}
if (hasPaneAttentionRef.current) {
dispatch(clearPaneAttention({ paneId }))
}
}
if (contentRef.current?.mode === 'claude' && isClaudeTurnSubmit(data)) {
turnCompletedSinceLastInputRef.current = false
dispatch(setPaneRuntimeActivity({
paneId: paneIdRef.current,
source: 'terminal',
phase: 'pending',
}))
}
ws.send({ type: 'terminal.input', terminalId: tid, data })
}, [dispatch, tabId, paneId, ws])

const translateScrollLinesToInput = useCallback((term: Terminal, lines: number): boolean => {
if (!terminalIdRef.current || lines === 0) return false

const shouldTranslate = shouldTranslateScrollToCursorKeys({
scrollInputPolicy: providerBehaviorRef.current.scrollInputPolicy,
altBufferActive: term.buffer.active.type === 'alternate',
mouseTrackingMode: term.modes.mouseTrackingMode,
})
if (!shouldTranslate) return false

const sequence = scrollLinesToCursorKeys(lines, term.modes.applicationCursorKeysMode)
if (!sequence) return false

sendInput(sequence)
return true
}, [sendInput])

useEffect(() => {
if (!isMobile || typeof window === 'undefined' || !window.visualViewport) {
setKeyboardInsetPx(0)
Expand Down Expand Up @@ -654,6 +713,8 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter
if (!isMobile || !touchActiveRef.current) return
const touch = event.touches[0]
if (!touch) return
const term = termRef.current
if (!term) return

const deltaX = Math.abs(touch.clientX - touchStartXRef.current)
const deltaYFromStart = Math.abs(touch.clientY - touchStartYRef.current)
Expand All @@ -672,10 +733,13 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter
const rawLines = touchScrollAccumulatorRef.current / TOUCH_SCROLL_PIXELS_PER_LINE
const lines = rawLines > 0 ? Math.floor(rawLines) : Math.ceil(rawLines)
if (lines !== 0) {
termRef.current?.scrollLines(lines)
if (!translateScrollLinesToInput(term, lines)) {
term.scrollLines(lines)
}

touchScrollAccumulatorRef.current -= lines * TOUCH_SCROLL_PIXELS_PER_LINE
}
}, [clearLongPressTimer, isMobile])
}, [clearLongPressTimer, isMobile, translateScrollLinesToInput])

const handleMobileTouchEnd = useCallback((event: ReactTouchEvent<HTMLDivElement>) => {
if (!isMobile) return
Expand Down Expand Up @@ -888,30 +952,6 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter
setPendingOsc52Event(event)
}, [attemptOsc52ClipboardWrite])

const sendInput = useCallback((data: string) => {
const tid = terminalIdRef.current
if (!tid) return
// In 'type' mode, clear attention when user sends input.
// In 'click' mode, attention is cleared by the notification hook on tab switch.
if (attentionDismissRef.current === 'type') {
if (hasAttentionRef.current) {
dispatch(clearTabAttention({ tabId }))
}
if (hasPaneAttentionRef.current) {
dispatch(clearPaneAttention({ paneId }))
}
}
if (contentRef.current?.mode === 'claude' && isClaudeTurnSubmit(data)) {
turnCompletedSinceLastInputRef.current = false
dispatch(setPaneRuntimeActivity({
paneId: paneIdRef.current,
source: 'terminal',
phase: 'pending',
}))
}
ws.send({ type: 'terminal.input', terminalId: tid, data })
}, [dispatch, tabId, paneId, ws])

const resetStartupProbeParser = useCallback((opts?: { discardReplayRemainder?: boolean }) => {
const pendingProbe = startupProbeStateRef.current.pending
if (opts?.discardReplayRemainder) {
Expand Down Expand Up @@ -1111,6 +1151,7 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter
// Init xterm once
useEffect(() => {
if (!isTerminal) return
if (shouldWaitForProviderBehavior) return
if (!containerRef.current) return
if (mountedRef.current && termRef.current) return
mountedRef.current = true
Expand Down Expand Up @@ -1161,11 +1202,8 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter
},
})
const rendererMode = settings.terminal.renderer ?? 'auto'
// OpenCode paints a dense truecolor light surface that currently renders
// unreliably through xterm WebGL on Chrome/Windows. Keep auto mode on the
// safer canvas path for that provider unless the user explicitly forces WebGL.
const enableWebgl = rendererMode === 'webgl'
|| (rendererMode === 'auto' && paneContent.mode !== 'opencode')
|| (rendererMode === 'auto' && !prefersCanvasRenderer(paneContent.mode, extensions))
let runtime = createNoopRuntime()
try {
runtime = createTerminalRuntime({ terminal: term, enableWebgl })
Expand All @@ -1192,6 +1230,16 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter

term.open(containerRef.current)
const requestModeBypass = registerTerminalRequestModeBypass(term, sendInput)
term.attachCustomWheelEventHandler((event) => {
const lines = event.deltaY < 0 ? -1 : event.deltaY > 0 ? 1 : 0
if (!translateScrollLinesToInput(term, lines)) {
return true
}

event.preventDefault()
event.stopPropagation()
return false
})

// Register custom link provider for clickable local file paths
const filePathLinkDisposable = typeof term.registerLinkProvider === 'function'
Expand Down Expand Up @@ -1434,7 +1482,7 @@ export default function TerminalView({ tabId, paneId, paneContent, hidden }: Ter
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isTerminal])
}, [isTerminal, providerBehavior.preferredRenderer, shouldWaitForProviderBehavior])

// Ref for tab to avoid re-running effects when tab changes
const tabRef = useRef(tab)
Expand Down
Loading
Loading