Compare commits

...

2 Commits

Author SHA1 Message Date
Jedrzej Kosinski
ad6adf39e5 feat(terminal): add open-in-new-window for Terminal and Logs tabs
Mirror the launcher's popout affordance natively: surface an
"open in new window" button on the Terminal and Logs bottom-panel tabs,
shown only when the ComfyUI Desktop 2.0 host provides a popout.

- useTerminalBridge: expose `openPopout` on the bridge (Desktop 2.0 only,
  null for legacy) and add `getLogsPopout()` for the read-only logs window.
- BottomPanel: render the popout button in the tab-strip actions, gated to
  the active terminal/logs tab and host availability.

Amp-Thread-ID: https://ampcode.com/threads/T-019eaa38-9fe8-764e-859f-a68e5c3bcffe
Co-authored-by: Amp <amp@ampcode.com>
2026-06-08 21:52:04 -07:00
Jedrzej Kosinski
443b414fd2 feat(terminal): gate interactive console on supports_terminal flag
Amp-Thread-ID: https://ampcode.com/threads/T-019e9464-f097-7430-a762-405ffb717dae
Co-authored-by: Amp <amp@ampcode.com>
2026-06-04 15:17:23 -07:00
9 changed files with 506 additions and 27 deletions

View File

@@ -42,6 +42,15 @@
</Tab>
</div>
<div class="flex items-center gap-2">
<Button
v-if="popoutAction"
variant="muted-textonly"
size="sm"
:aria-label="t('terminal.openInNewWindow')"
@click="openInNewWindow"
>
<i class="pi pi-external-link" />
</Button>
<Button
v-if="isShortcutsTabActive"
variant="muted-textonly"
@@ -87,6 +96,10 @@ import { useI18n } from 'vue-i18n'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import Button from '@/components/ui/button/Button.vue'
import {
getLogsPopout,
getTerminalBridge
} from '@/composables/bottomPanelTabs/useTerminalBridge'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import type { BottomPanelExtension } from '@/types/extensionTypes'
@@ -103,6 +116,21 @@ const isShortcutsTabActive = computed(() => {
)
})
// The host (ComfyUI Desktop 2.0) can open the live Terminal/Logs in a separate
// window. Surface the action only on those tabs and only when the host offers it.
const popoutAction = computed<(() => Promise<void>) | null>(() => {
const activeTabId = bottomPanelStore.activeBottomPanelTabId
if (activeTabId === 'command-terminal') {
return getTerminalBridge()?.openPopout ?? null
}
if (activeTabId === 'logs-terminal') return getLogsPopout()
return null
})
const openInNewWindow = async () => {
await popoutAction.value?.()
}
const shouldCapitalizeTab = (tabId: string): boolean => {
return tabId !== 'shortcuts-essentials' && tabId !== 'shortcuts-view-controls'
}

View File

@@ -0,0 +1,142 @@
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { defineComponent, h, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import CommandTerminal from './CommandTerminal.vue'
const { fakeTerminal, autoSize, getTerminalBridge } = vi.hoisted(() => {
const term = {
onData: vi.fn(() => ({ dispose: vi.fn() })),
write: vi.fn(),
resize: vi.fn(),
reset: vi.fn(),
element: { offsetParent: {} },
cols: 80,
rows: 30
}
return {
fakeTerminal: term,
autoSize: vi.fn(),
getTerminalBridge: vi.fn()
}
})
vi.mock('@/composables/bottomPanelTabs/useTerminalBridge', () => ({
getTerminalBridge
}))
// Stub BaseTerminal: synchronously hand the component a fake xterm terminal.
vi.mock('./BaseTerminal.vue', () => ({
default: defineComponent({
emits: ['created'],
setup(_props, { emit }) {
emit(
'created',
{ terminal: fakeTerminal, useAutoSize: autoSize },
ref(undefined)
)
return () => h('div', { 'data-testid': 'base-terminal-stub' })
}
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: { terminal: { sessionEnded: 'ended', restart: 'restart' } }
}
})
function renderTerminal() {
return render(CommandTerminal, { global: { plugins: [i18n] } })
}
function makeLauncherBridge() {
let exitCb: (() => void) | undefined
return {
subscribe: vi.fn().mockResolvedValue({
buffer: [],
size: { cols: 80, rows: 30 },
exited: false
}),
write: vi.fn().mockResolvedValue(undefined),
resize: vi.fn().mockResolvedValue(undefined),
restart: vi.fn().mockResolvedValue({
buffer: [],
size: { cols: 80, rows: 30 },
exited: false
}),
onOutput: vi.fn(() => () => {}),
onExited: vi.fn((cb: () => void) => {
exitCb = cb
return () => {}
}),
fireExit: () => exitCb?.()
}
}
describe('CommandTerminal', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.clearAllMocks()
})
it('shows the restart banner when the session exits and clears it on restart', async () => {
const bridge = makeLauncherBridge()
getTerminalBridge.mockReturnValue(bridge)
renderTerminal()
await waitFor(() => expect(bridge.subscribe).toHaveBeenCalled())
expect(screen.queryByTestId('terminal-session-ended')).toBeNull()
bridge.fireExit()
expect(await screen.findByTestId('terminal-session-ended')).toBeTruthy()
await userEvent.click(screen.getByTestId('terminal-restart-button'))
expect(bridge.restart).toHaveBeenCalled()
await waitFor(() =>
expect(screen.queryByTestId('terminal-session-ended')).toBeNull()
)
})
it('auto-restarts when re-opened after the user killed the session', async () => {
const bridge = makeLauncherBridge()
bridge.subscribe.mockResolvedValue({
buffer: [],
size: { cols: 80, rows: 30 },
exited: true
})
getTerminalBridge.mockReturnValue(bridge)
renderTerminal()
await waitFor(() => expect(bridge.restart).toHaveBeenCalled())
})
it('does not offer restart on a legacy host that cannot restart', async () => {
const legacy = {
subscribe: vi
.fn()
.mockResolvedValue({ buffer: [], size: { cols: 80, rows: 30 } }),
write: vi.fn().mockResolvedValue(undefined),
resize: vi.fn().mockResolvedValue(undefined),
restart: null,
onOutput: vi.fn(() => () => {}),
onExited: null
}
getTerminalBridge.mockReturnValue(legacy)
renderTerminal()
await waitFor(() => expect(legacy.subscribe).toHaveBeenCalled())
// No exit notifications on legacy, so the banner never shows.
expect(screen.queryByTestId('terminal-session-ended')).toBeNull()
})
})

View File

@@ -1,25 +1,75 @@
<template>
<BaseTerminal @created="terminalCreated" />
<div class="relative size-full">
<BaseTerminal @created="terminalCreated" />
<div
v-if="exited"
data-testid="terminal-session-ended"
class="absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 bg-neutral-800 px-3 py-2 text-sm text-neutral-200"
>
<span>{{ $t('terminal.sessionEnded') }}</span>
<Button
v-if="canRestart"
size="sm"
variant="secondary"
data-testid="terminal-restart-button"
@click="restart"
>
{{ $t('terminal.restart') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import type { IDisposable } from '@xterm/xterm'
import type { IDisposable, Terminal } from '@xterm/xterm'
import type { Ref } from 'vue'
import { onMounted, onUnmounted } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import type { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
import { electronAPI } from '@/utils/envUtil'
import { getTerminalBridge } from '@/composables/bottomPanelTabs/useTerminalBridge'
import type {
TerminalBridge,
TerminalRestore
} from '@/composables/bottomPanelTabs/useTerminalBridge'
import BaseTerminal from './BaseTerminal.vue'
const exited = ref(false)
const canRestart = ref(false)
let bridge: TerminalBridge | null = null
let terminal: Terminal | null = null
const applyRestore = (restore: TerminalRestore) => {
if (!terminal) return
if (restore.buffer.length) {
terminal.resize(restore.size.cols, restore.size.rows)
terminal.write(restore.buffer.join(''))
}
exited.value = restore.exited ?? false
}
const restart = async () => {
if (!bridge?.restart || !terminal) return
terminal.reset()
exited.value = false
applyRestore(await bridge.restart())
}
const terminalCreated = (
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
{ terminal: term, useAutoSize }: ReturnType<typeof useTerminal>,
root: Ref<HTMLElement | undefined>
) => {
const terminalApi = electronAPI().Terminal
bridge = getTerminalBridge()
if (!bridge) return
const activeBridge = bridge
terminal = term
canRestart.value = activeBridge.restart !== null
let offData: IDisposable
let offOutput: () => void
let offExited: (() => void) | undefined
useAutoSize({
root,
@@ -27,33 +77,36 @@ const terminalCreated = (
autoCols: true,
onResize: async () => {
// If we aren't visible, don't resize
if (!terminal.element?.offsetParent) return
await terminalApi.resize(terminal.cols, terminal.rows)
if (!term.element?.offsetParent) return
await activeBridge.resize(term.cols, term.rows)
}
})
onMounted(async () => {
offData = terminal.onData(async (message: string) => {
await terminalApi.write(message)
offData = term.onData(async (message: string) => {
await activeBridge.write(message)
})
offOutput = activeBridge.onOutput((message) => {
term.write(message)
})
offExited = activeBridge.onExited?.(() => {
exited.value = true
})
offOutput = terminalApi.onOutput((message) => {
terminal.write(message)
})
const restore = await terminalApi.restore()
setTimeout(() => {
if (restore.buffer.length) {
terminal.resize(restore.size.cols, restore.size.rows)
terminal.write(restore.buffer.join(''))
}
}, 500)
const restore = await activeBridge.subscribe()
// "Pulling it up again restarts it": if the user had killed the session,
// re-opening the tab silently brings it back.
if (restore.exited && activeBridge.restart) {
await restart()
} else {
setTimeout(() => applyRestore(restore), 500)
}
})
onUnmounted(() => {
offData?.dispose()
offOutput?.()
offExited?.()
})
}
</script>

View File

@@ -0,0 +1,113 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
getLogsPopout,
getTerminalBridge,
isTerminalHostAvailable
} from './useTerminalBridge'
const electronAPIMock = vi.hoisted(() => vi.fn())
vi.mock('@/utils/envUtil', () => ({ electronAPI: electronAPIMock }))
describe('useTerminalBridge', () => {
beforeEach(() => {
electronAPIMock.mockReturnValue(undefined)
delete window.__comfyDesktop2
})
afterEach(() => {
delete window.__comfyDesktop2
vi.clearAllMocks()
})
it('returns null when no terminal host is present (e.g. a browser tab)', () => {
expect(getTerminalBridge()).toBeNull()
expect(isTerminalHostAvailable()).toBe(false)
})
it('prefers the Desktop 2.0 host and exposes restart + exit + popout', () => {
const launcher = {
subscribe: vi.fn(),
unsubscribe: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
restart: vi.fn(),
onOutput: vi.fn(),
onExited: vi.fn(),
openPopout: vi.fn()
}
window.__comfyDesktop2 = { Terminal: launcher }
const bridge = getTerminalBridge()
expect(bridge).not.toBeNull()
expect(bridge?.restart).not.toBeNull()
expect(bridge?.onExited).not.toBeNull()
expect(bridge?.openPopout).not.toBeNull()
void bridge?.write('ls\r')
expect(launcher.write).toHaveBeenCalledWith('ls\r')
void bridge?.openPopout?.()
expect(launcher.openPopout).toHaveBeenCalled()
expect(isTerminalHostAvailable()).toBe(true)
})
it('exposes the logs popout only under the Desktop 2.0 host', () => {
expect(getLogsPopout()).toBeNull()
const openPopout = vi.fn()
window.__comfyDesktop2 = { Logs: { openPopout } }
const logsPopout = getLogsPopout()
expect(logsPopout).not.toBeNull()
void logsPopout?.()
expect(openPopout).toHaveBeenCalled()
})
it('falls back to the legacy desktop host without restart/exit support', () => {
const legacy = {
write: vi.fn(),
resize: vi.fn(),
restore: vi
.fn()
.mockResolvedValue({ buffer: [], size: { cols: 80, rows: 30 } }),
onOutput: vi.fn()
}
electronAPIMock.mockReturnValue({ Terminal: legacy })
const bridge = getTerminalBridge()
expect(bridge).not.toBeNull()
// Legacy can't restart or report exits.
expect(bridge?.restart).toBeNull()
expect(bridge?.onExited).toBeNull()
// subscribe() maps to the legacy restore().
void bridge?.subscribe()
expect(legacy.restore).toHaveBeenCalled()
})
it('uses Desktop 2.0 even when the legacy API is also present', () => {
const launcher = {
subscribe: vi.fn(),
unsubscribe: vi.fn(),
write: vi.fn(),
resize: vi.fn(),
restart: vi.fn(),
onOutput: vi.fn(),
onExited: vi.fn(),
openPopout: vi.fn()
}
const legacy = {
write: vi.fn(),
resize: vi.fn(),
restore: vi.fn(),
onOutput: vi.fn()
}
window.__comfyDesktop2 = { Terminal: launcher }
electronAPIMock.mockReturnValue({ Terminal: legacy })
const bridge = getTerminalBridge()
void bridge?.write('x')
expect(launcher.write).toHaveBeenCalledWith('x')
expect(legacy.write).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,95 @@
import { electronAPI } from '@/utils/envUtil'
/** Scrollback + size + liveness snapshot used to repaint a terminal. */
export interface TerminalRestore {
buffer: string[]
size: { cols: number; rows: number }
exited?: boolean
}
/**
* Host-agnostic interactive terminal transport.
*
* Two hosts can provide one: the legacy ComfyUI Desktop (`electronAPI().Terminal`)
* and ComfyUI Desktop 2.0 (`window.__comfyDesktop2.Terminal`). The launcher host
* additionally reports when the user kills the shell and can restart it; the
* legacy host can't, so those members are nullable.
*/
export interface TerminalBridge {
subscribe(): Promise<TerminalRestore>
write(data: string): Promise<void>
resize(cols: number, rows: number): Promise<void>
/** Kill and respawn the shell. `null` when the host can't restart. */
restart: (() => Promise<TerminalRestore>) | null
onOutput(callback: (data: string) => void): () => void
/** Notified when the shell exits. `null` when the host doesn't report it. */
onExited: ((callback: () => void) => () => void) | null
/** Open the shell in a separate host window. `null` when unsupported. */
openPopout: (() => Promise<void>) | null
}
interface Desktop2Terminal {
subscribe(): Promise<TerminalRestore>
unsubscribe(): Promise<void>
write(data: string): Promise<void>
resize(cols: number, rows: number): Promise<void>
restart(): Promise<TerminalRestore>
onOutput(callback: (data: string) => void): () => void
onExited(callback: () => void): () => void
openPopout(): Promise<void>
}
interface Desktop2Logs {
openPopout(): Promise<void>
}
declare global {
interface Window {
__comfyDesktop2?: { Terminal?: Desktop2Terminal; Logs?: Desktop2Logs }
}
}
/** Resolve whichever terminal host is present, or `null` (e.g. a browser tab). */
export function getTerminalBridge(): TerminalBridge | null {
const launcher = window.__comfyDesktop2?.Terminal
if (launcher) {
return {
subscribe: () => launcher.subscribe(),
write: (data) => launcher.write(data),
resize: (cols, rows) => launcher.resize(cols, rows),
restart: () => launcher.restart(),
onOutput: (callback) => launcher.onOutput(callback),
onExited: (callback) => launcher.onExited(callback),
openPopout: () => launcher.openPopout()
}
}
const legacy = electronAPI()?.Terminal
if (legacy) {
return {
subscribe: () => legacy.restore(),
write: (data) => legacy.write(data),
resize: (cols, rows) => legacy.resize(cols, rows),
restart: null,
onOutput: (callback) => legacy.onOutput(callback),
onExited: null,
openPopout: null
}
}
return null
}
/** Whether any interactive terminal host is reachable from this surface. */
export function isTerminalHostAvailable(): boolean {
return getTerminalBridge() !== null
}
/**
* Resolve the read-only logs popout, or `null`. Only ComfyUI Desktop 2.0
* provides a separate logs window; legacy desktop and browser tabs don't.
*/
export function getLogsPopout(): (() => Promise<void>) | null {
const logs = window.__comfyDesktop2?.Logs
return logs ? () => logs.openPopout() : null
}

View File

@@ -28,7 +28,8 @@ export enum ServerFeatureFlag {
WORKFLOW_SHARING_ENABLED = 'workflow_sharing_enabled',
COMFYHUB_UPLOAD_ENABLED = 'comfyhub_upload_enabled',
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
SHOW_SIGNIN_BUTTON = 'show_signin_button'
SHOW_SIGNIN_BUTTON = 'show_signin_button',
SUPPORTS_TERMINAL = 'supports_terminal'
}
/**
@@ -165,6 +166,10 @@ export function useFeatureFlags() {
ServerFeatureFlag.SHOW_SIGNIN_BUTTON,
undefined
)
},
/** A terminal host (e.g. ComfyUI Desktop) is available for this server. */
get supportsTerminal(): boolean {
return api.serverSupportsFeature(ServerFeatureFlag.SUPPORTS_TERMINAL)
}
})

View File

@@ -640,6 +640,11 @@
"briefcase": "Briefcase",
"exclamation-triangle": "Warning"
},
"terminal": {
"sessionEnded": "This terminal session ended. Restart it to keep using the console.",
"restart": "Restart",
"openInNewWindow": "Open in a new window"
},
"welcome": {
"title": "Welcome to ComfyUI",
"getStarted": "Get Started"

View File

@@ -56,6 +56,17 @@ vi.mock('@/platform/distribution/types', () => ({
}
}))
// The store now gates the interactive terminal tab on a terminal host + the
// server feature flag. These tests don't exercise the gate, so keep the
// dependencies inert.
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({ flags: { supportsTerminal: false } })
}))
vi.mock('@/composables/bottomPanelTabs/useTerminalBridge', () => ({
isTerminalHostAvailable: vi.fn(() => false)
}))
describe('useBottomPanelStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))

View File

@@ -1,8 +1,10 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useShortcutsTab } from '@/composables/bottomPanelTabs/useShortcutsTab'
import { isTerminalHostAvailable } from '@/composables/bottomPanelTabs/useTerminalBridge'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isDesktop } from '@/platform/distribution/types'
import { useCommandStore } from '@/stores/commandStore'
import type { ComfyExtension } from '@/types/comfy'
@@ -128,6 +130,33 @@ export const useBottomPanelStore = defineStore('bottomPanel', () => {
})
}
// The interactive terminal tab needs a host that provides one
// (`isTerminalHostAvailable`) AND permission to surface it: legacy desktop
// is gated at compile time via `isDesktop`, while ComfyUI Desktop 2.0 gates
// on the server-reported `supports_terminal` flag, which arrives over the
// websocket handshake — so register reactively once it does.
const registerCommandTerminalTab = (tab: BottomPanelExtension) => {
if (!isTerminalHostAvailable()) return
if (isDesktop) {
registerBottomPanelTab(tab)
return
}
const { flags } = useFeatureFlags()
if (flags.supportsTerminal) {
registerBottomPanelTab(tab)
return
}
const stop = watch(
() => flags.supportsTerminal,
(supported) => {
if (supported) {
registerBottomPanelTab(tab)
stop()
}
}
)
}
const registerCoreBottomPanelTabs = async () => {
// Register shortcuts tabs first (synchronous, always available)
useShortcutsTab().forEach(registerBottomPanelTab)
@@ -138,9 +167,7 @@ export const useBottomPanelStore = defineStore('bottomPanel', () => {
const { useLogsTerminalTab, useCommandTerminalTab } =
await import('@/composables/bottomPanelTabs/useTerminalTabs')
registerBottomPanelTab(useLogsTerminalTab())
if (isDesktop) {
registerBottomPanelTab(useCommandTerminalTab())
}
registerCommandTerminalTab(useCommandTerminalTab())
} catch (error) {
console.error('Failed to load terminal tabs:', error)
}