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
14 changes: 6 additions & 8 deletions apps/desktop/src/app/chat/composer/status-stack/status-row.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { Fragment, memo, type ReactNode, useState } from 'react'
import { Fragment, memo, type ReactNode } from 'react'

import { openAgentTerminal } from '@/app/right-sidebar/terminal/terminals'
import { StatusRow } from '@/components/chat/status-row'
import { TerminalOutput } from '@/components/chat/terminal-output'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { Tip } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
Expand Down Expand Up @@ -82,7 +81,6 @@ interface StatusItemRowProps {
export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOpen, onStop }: StatusItemRowProps) {
const { t } = useI18n()
const s = t.statusStack
const [outputOpen, setOutputOpen] = useState(false)
const failed = item.state === 'failed'
const running = item.state === 'running'

Expand All @@ -94,8 +92,10 @@ export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOp
: null

const canOpen = item.type === 'subagent' && !!onOpen
const hasOutput = item.type === 'background' && !!item.output
const onActivate = canOpen ? onOpen : hasOutput ? () => setOutputOpen(open => !open) : undefined

// Background rows link to their read-only terminal tab; subagents open their session.
const onActivate =
item.type === 'background' ? () => openAgentTerminal(item.id, item.title) : canOpen ? onOpen : undefined

return (
<Fragment>
Expand Down Expand Up @@ -146,9 +146,7 @@ export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOp
{s.exit(item.exitCode)}
</span>
)}
{hasOutput && <DisclosureCaret className="shrink-0 text-muted-foreground/45" open={outputOpen} size="0.8em" />}
</StatusRow>
{hasOutput && outputOpen && <TerminalOutput className="mx-auto mb-1 max-w-[90%]" text={item.output!} />}
</Fragment>
)
})
35 changes: 27 additions & 8 deletions apps/desktop/src/app/desktop-controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overla
import { Pane, PaneMain } from '@/components/pane-shell'
import { RemoteDisplayBanner } from '@/components/remote-display-banner'
import { useMediaQuery } from '@/hooks/use-media-query'
import { isFocusWithin } from '@/lib/keybinds/combo'
import { cn } from '@/lib/utils'
import { useSkinCommand } from '@/themes/use-skin-command'

Expand Down Expand Up @@ -127,7 +128,9 @@ import { FileActionDialogs } from './right-sidebar/file-actions'
import { RemoteFolderPicker } from './right-sidebar/files/remote-picker'
import { ReviewPane } from './right-sidebar/review'
import { $terminalTakeover } from './right-sidebar/store'
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
import { TerminalPaneChrome } from './right-sidebar/terminal/chrome'
import { PersistentTerminal } from './right-sidebar/terminal/persistent'
import { closeActiveTerminal } from './right-sidebar/terminal/terminals'
import { CRON_ROUTE, NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes'
import { SessionPickerOverlay } from './session-picker-overlay'
import { SessionSwitcher } from './session-switcher'
Expand Down Expand Up @@ -388,11 +391,25 @@ export function DesktopController() {

useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (!$filePreviewTarget.get() && !$previewTarget.get()) {
if (event.altKey || event.shiftKey || event.key.toLowerCase() !== 'w' || (!event.metaKey && !event.ctrlKey)) {
return
}

if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'w') {
// Terminal focused: ⌘W closes the active terminal. Ctrl+W is left untouched
// for the shell's werase, and nothing else may steal ⌘/Ctrl+W from a
// focused terminal (so it never closes a preview tab out from under it).
if (isFocusWithin('[data-terminal]')) {
if (event.metaKey && !event.ctrlKey) {
event.preventDefault()
event.stopPropagation()
closeActiveTerminal()
}

return
}

// Otherwise ⌘/Ctrl+W closes the active preview tab when one is open.
if ($filePreviewTarget.get() || $previewTarget.get()) {
event.preventDefault()
event.stopPropagation()
closeActiveRightRailTab()
Expand Down Expand Up @@ -1095,11 +1112,13 @@ export function DesktopController() {
/>
)

// One PTY-backed terminal mounted forever; <TerminalSlot /> placeholders decide
// where it shows. Lives in main's stacking context (not the root overlay layer)
// so pane resize handles still paint above it. Toggling never rebuilds the shell.
// The persistent xterm layer (one host per terminal tab), CSS-overlaid onto the
// pane's <TerminalSlot />. Lives in main's stacking context (not the root overlay
// layer) so pane resize handles still paint above it. Terminals own their state
// (incl. a snapshotted cwd) independent of the session, so switching sessions
// never rebuilds or closes them; toggling the pane never rebuilds the shells.
const mainOverlays = (
<PersistentTerminal cwd={currentCwd} onAddSelectionToChat={composer.addTerminalSelectionAttachment} />
<PersistentTerminal onAddSelectionToChat={composer.addTerminalSelectionAttachment} />
)

const overlays = (
Expand Down Expand Up @@ -1330,7 +1349,7 @@ export function DesktopController() {
terminalAsRow ? 'border-l border-(--ui-stroke-secondary) pt-0' : 'pt-(--titlebar-height)'
)}
>
<TerminalSlot />
<TerminalPaneChrome />
</div>
</Pane>
)
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/src/app/hooks/use-keybinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'

import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
import { closeActiveTerminal, createTerminal, cycleTerminal } from '@/app/right-sidebar/terminal/terminals'
import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell'
import { matchesQuery } from '@/hooks/use-media-query'
import { PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions'
Expand Down Expand Up @@ -164,6 +165,17 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
'view.toggleReview': toggleReview,
'view.showFiles': showFiles,
'view.showTerminal': () => setTerminalTakeover(!$terminalTakeover.get()),
// Create first so the pane's open-effect ensure sees a non-empty set and
// doesn't also spawn one — net effect is exactly one fresh terminal.
'view.newTerminal': () => {
createTerminal()
setTerminalTakeover(true)
},
// Switch / close only act while the pane is open (no focus-scoping here, so
// this stands in for "terminal is showing").
'view.nextTerminal': () => $terminalTakeover.get() && cycleTerminal(1),
'view.prevTerminal': () => $terminalTakeover.get() && cycleTerminal(-1),
'view.closeTerminal': () => $terminalTakeover.get() && closeActiveTerminal(),
'view.flipPanes': togglePanesFlipped,

'appearance.toggleMode': () => setMode(resolvedMode === 'dark' ? 'light' : 'dark'),
Expand Down
100 changes: 100 additions & 0 deletions apps/desktop/src/app/right-sidebar/terminal/agent-terminal-stream.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Live agent-terminal output, pushed from the backend as `agent.terminal.output`
// events (see tui_gateway `_wire_agent_terminal_output`). Chunks route straight
// to the matching read-only xterm, keyed by process id — no polling, no tail
// truncation. A capped per-proc backlog lets a tab opened mid-stream replay what
// it missed, and lets a closed-then-reopened tab restore its history.

type Writer = (chunk: string) => void

const writers = new Map<string, Writer>()
const backlog = new Map<string, string>()
const commandHeaders = new Map<string, string>()
const lastSnapshots = new Map<string, string>()
const seededCommands = new Set<string>()

const MAX_BACKLOG = 256_000

/** A live agent terminal registers its xterm write and replays the backlog.
* Returns an idempotent unregister. */
export function registerAgentTerminalWriter(procId: string, write: Writer): () => void {
writers.set(procId, write)

const history = backlog.get(procId)

if (history) {
write(history)
}

return () => {
if (writers.get(procId) === write) {
writers.delete(procId)
}
}
}

/** Append a streamed chunk: buffer it (capped) for future opens and write it to
* the live terminal, if one is mounted. */
export function writeAgentTerminalChunk(procId: string, chunk: string): void {
if (!procId || !chunk) {
return
}

const next = (backlog.get(procId) ?? '') + chunk
backlog.set(procId, next.length > MAX_BACKLOG ? next.slice(-MAX_BACKLOG) : next)
writers.get(procId)?.(chunk)
}

/** Seed the tab with the command immediately, so an agent terminal never opens
* as an empty void while stdout is still pending or not yet observed. */
export function seedAgentTerminalCommand(procId: string, command: string): void {
const trimmed = command.trim()

if (!procId || !trimmed || seededCommands.has(procId)) {
return
}

seededCommands.add(procId)
const header = `$ ${trimmed}\r\n`
commandHeaders.set(procId, header)
writeAgentTerminalChunk(procId, header)
}

/** Ingest a full output snapshot from process.list/status-stack. This is the
* fallback for older/not-yet-restarted gateways and a seed for tabs opened
* after output already exists. If it extends our current backlog, append only
* the delta; if the registry's rolling tail slid, reset to that tail. */
export function syncAgentTerminalSnapshot(procId: string, output: string): void {
if (!procId || !output) {
return
}

const current = backlog.get(procId) ?? ''
const header = commandHeaders.get(procId) ?? ''
const body = header && current.startsWith(header) ? current.slice(header.length) : current
const previous = lastSnapshots.get(procId) ?? ''

if (output === previous || output === body || body.endsWith(output)) {
lastSnapshots.set(procId, output)

return
}

if (output.startsWith(previous)) {
writeAgentTerminalChunk(procId, output.slice(previous.length))
lastSnapshots.set(procId, output)

return
}

if (output.startsWith(body)) {
writeAgentTerminalChunk(procId, output.slice(body.length))
lastSnapshots.set(procId, output)

return
}

const next = `${header}${output}`.slice(-MAX_BACKLOG)
lastSnapshots.set(procId, output)
backlog.set(procId, next)
writers.get(procId)?.(`\x1bc${next}`)
}
29 changes: 22 additions & 7 deletions apps/desktop/src/app/right-sidebar/terminal/buffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,32 @@ export interface TerminalReadOptions {

type Reader = (opts: TerminalReadOptions) => TerminalReadResult

// The persistent terminal is a singleton (one xterm mounted forever), so a
// module-level slot is enough — set while the session is live, cleared on
// dispose. The gateway `terminal.read.request` handler reads through this.
let activeReader: Reader | null = null
// Each live terminal registers a reader keyed by its id; a single `activeId`
// (driven by the tab selection) decides which one the agent's `read_terminal`
// tool sees. Keying by id keeps switching race-free — a deactivating tab's
// cleanup can't null out the tab that just activated.
const readers = new Map<string, Reader>()
let activeId: string | null = null

export function setActiveTerminalReader(reader: Reader | null): void {
activeReader = reader
/** Register a live terminal's reader; returns an idempotent unregister. */
export function registerTerminalReader(id: string, reader: Reader): () => void {
readers.set(id, reader)

return () => {
if (readers.get(id) === reader) {
readers.delete(id)
}
}
}

export function setActiveTerminalId(id: string | null): void {
activeId = id
}

export function readActiveTerminal(opts: TerminalReadOptions = {}): TerminalReadResult | null {
return activeReader ? activeReader(opts) : null
const reader = activeId === null ? null : readers.get(activeId)

return reader ? reader(opts) : null
}

export function makeTerminalReader(term: Terminal): Reader {
Expand Down
24 changes: 24 additions & 0 deletions apps/desktop/src/app/right-sidebar/terminal/chrome.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useStore } from '@nanostores/react'

import { TerminalSlot } from './persistent'
import { TerminalRail } from './rail'
import { $terminals } from './terminals'

/** Pane-side terminal chrome: the body slot (which the persistent overlay chases)
* plus the always-on tab rail. Lives in the real pane DOM — NOT the z-4 terminal
* overlay — so the rail sits above the collapsed sidebars' z-30 hover-reveal
* triggers (z-40, like the thread timeline) and suppresses them while hovered.
* The rail is always shown when a terminal exists (even one), so every tab keeps
* its close affordance; closing the last one hides the pane (reopen re-creates). */
export function TerminalPaneChrome() {
const terminals = useStore($terminals)

return (
<div className="flex min-h-0 min-w-0 flex-1">
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
<TerminalSlot />
</div>
{terminals.length > 0 && <TerminalRail />}
</div>
)
}
88 changes: 0 additions & 88 deletions apps/desktop/src/app/right-sidebar/terminal/index.tsx

This file was deleted.

Loading
Loading