feat: yield the terminal and browser to DSH 0.1.6's own sidebar types

DSH 0.1.6-alpha.2 ships a right-Sidebar terminal and a right-Sidebar browser
of its own. Both were things this plugin had built from scratch, so keeping
them meant two implementations of one surface plus a plugin that shadows a
host type the product is actively developing.

Terminal: the plugin's whole PTY stack goes — the UI-tab manager, the
agent-owned registry, the lazy node-pty loader, the eight model-facing
terminal_* tools, the xterm view and its font/link/scrollback helpers, and
the cross-session 'pinned terminal' rail that existed only to keep a terminal
alive across a session switch. The host's terminal kind becomes the only
provider, which also retires the node-pty dependency, its pnpm allowBuilds
entry, and the installer's -Repair mode (issue #140's whole class of
'node-pty failed to load' support).

Model-facing capability note: the plugin used to inject terminal_* tools
(off by default). Those are the model's only cross-call persistent terminal,
and DSH's own equivalent (@deepseek-ai/dsh-tool-terminal) is not mounted by
any shipped bundle, so removing them leaves the model with the one-shot
bash/pwsh tools only. That is the accepted cost of not maintaining a second
PTY surface; the README documents how to enable the upstream toolset.

Browser: the plugin's browser tab type is dropped, so the host's own browser
kind owns embedded pages. The DOM-level link takeover STAYS (it is the only
thing that can route an https link to the system browser by protocol, which
the host does not do) but now delegates to the host's browser tab via
ctx.sidebarRight.openTab('browser', …) with a window.open fallback, and the
two settings that only ever configured the plugin's own iframe (sandbox
escape hatch, loopback allowlist) go with it — along with the host-side
browser.probe route they fed.

Cards: the empty-pane welcome cards adopt the host's guide-capsule recipe
(0.5px l4 hairline, 24px radius, layer-1 fill, 56px floor, 14px/20px padding)
so the plugin's 'pick what opens here' surface reads as the same design as
DSH's own. A theme.spec guard pins the geometry and the token-only paint —
including that the upstream-only --dsw-alias-bg-l1/-l2 spellings (defined
nowhere, so their declarations are silently dropped upstream) never appear
here.

Also drops the dead state the removed features left behind (SidebarTab.pin,
nextTerminal, agentWaits, bottomOpenedOnce, the agent-tab helpers, the
pinned-tab memo key), re-points every test fixture that used them at
surviving equivalents, and prunes the specs of the deleted features.
This commit is contained in:
Menghuan1918
2026-09-21 20:35:11 +08:00
parent d60899841d
commit 5912274e41
79 changed files with 381 additions and 9464 deletions
-289
View File
@@ -1,289 +0,0 @@
/**
* The built-in browser tab: an address bar plus a sandboxed iframe.
*
* Security model (see browser.ts + the sandbox tokens below): the iframe is
* ALWAYS sandboxed without `allow-same-origin` (opaque origin — the visited
* page can never sit on the GUI's origin, read its storage, or reach
* /sidebar/api) and without `allow-top-navigation` (a page must not hijack
* the GUI). The address bar only accepts http(s) and refuses loopback /
* the GUI's own origin. The side card setting "关闭浏览器沙箱" drops the
* sandbox attribute entirely for fully trusted sites — the visited page then
* runs with the GUI's own origin and full session access, so a persistent
* warning bar renders while it is off.
*
* The URL is persisted onto the tab (path/title via the patchTab reducer)
* so a reload restores the visited page; the back/forward stack only tracks
* address-bar navigations (in-frame link clicks are cross-origin and
* invisible — a documented limitation).
*/
import { useEffect, useState } from 'react'
import {
IconChevronLeftOutline14,
IconChevronRightOutline14,
IconLinkOutline14,
IconRefreshOutline14,
IconWarningOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { VscLinkExternal } from 'react-icons/vsc'
import { api } from './api.ts'
import { embeddabilityOf, isAllowedLoopbackUrl, normalizeBrowserUrl } from './browser.ts'
import { patchTab } from './state.ts'
import { SandboxStatusBar } from './SandboxStatusBar.tsx'
import { t } from './locales.ts'
import type { TabComponentProps } from './service.ts'
import css from './sidebar.module.css'
/**
* The browser iframe sandbox tokens. NO allow-same-origin (opaque origin —
* no GUI storage/API access), NO allow-top-navigation (a browsed page must
* not hijack the GUI). allow-forms/allow-popups/allow-downloads/allow-modals
* keep login flows working; allow-popups-to-escape-sandbox lets OAuth
* popups open as normal tabs (they are cross-origin to the GUI either way).
*/
export const BROWSER_IFRAME_SANDBOX =
'allow-scripts allow-forms allow-popups allow-downloads allow-modals allow-popups-to-escape-sandbox'
/** allow-same-origin appended for explicitly allowlisted local addresses. */
const BROWSER_IFRAME_SANDBOX_SAME_ORIGIN =
`${BROWSER_IFRAME_SANDBOX} allow-same-origin`
/**
* The sandbox tokens for one URL: allowlisted loopback addresses (local dev
* servers the user explicitly trusts) additionally get `allow-same-origin`
* so Vite/module/HMR pipelines that need a real origin work; every other
* site keeps the opaque-origin sandbox. `allow-same-origin` does NOT give
* the page access to the GUI — it stays cross-origin to it and to every
* other site — but it does give it its OWN origin privileges (localStorage,
* fetch without CORS), so it is only granted for the explicit allowlist.
*
* The GUI itself is the one hard exception: even when its own host is
* allowlisted (a bare-host entry covers every port, so the GUI origin
* matches), a page at the GUI's exact origin must never get
* `allow-same-origin` — that would make it same-origin with its parent and
* hand it the GUI's storage/API (and the ability to shed the sandbox). The
* GUI keeps the opaque-origin sandbox no matter what the allowlist says.
*/
export function iframeSandboxFor(url: string | undefined, allowedLoopback: string, selfOrigin?: string): string | undefined {
if (url === undefined) return undefined
if (selfOrigin !== undefined) {
let parsed: URL
try {
parsed = new URL(url)
} catch {
return BROWSER_IFRAME_SANDBOX
}
if (parsed.origin === selfOrigin) return BROWSER_IFRAME_SANDBOX
}
return isAllowedLoopbackUrl(url, allowedLoopback)
? BROWSER_IFRAME_SANDBOX_SAME_ORIGIN
: BROWSER_IFRAME_SANDBOX
}
export function BrowserView(props: TabComponentProps) {
const { store, tab } = props
// The current address (initialized from the persisted tab.path so a
// reload restores the visited page).
const [url, setUrl] = useState<string | undefined>(tab.path)
const [input, setInput] = useState<string>(tab.path ?? '')
/** Blocked/invalid hint shown under the address bar (null = none). */
const [message, setMessage] = useState<string | null>(null)
/** Address-bar navigation history (in-frame clicks are not tracked). */
const [history, setHistory] = useState<string[]>(tab.path !== undefined ? [tab.path] : [])
const [cursor, setCursor] = useState<number>(tab.path !== undefined ? 0 : -1)
/** Bumped on reload to remount the iframe (also remounts on sandbox flip). */
const [reloadKey, setReloadKey] = useState(0)
/** TEMPORARY sandbox unlock for THIS surface only (never writes the global
* side card setting; lasts until the tab unmounts or the user restores). */
const [localUnlock, setLocalUnlock] = useState(false)
const noSandbox = store.getPrefs().browserNoSandbox === true || localUnlock
/** A site that refuses to be embedded (X-Frame-Options / frame-ancestors):
* the probe verdict shown instead of the blank iframe. */
const [embedBlocked, setEmbedBlocked] = useState<string | null>(null)
/** The user asked to load the refused site anyway (keeps the plain iframe). */
const [forceEmbed, setForceEmbed] = useState(false)
// Probe every navigation (address bar, history, restored path): when the
// target forbids embedding, show the reason + open-in-browser instead of
// the browser's cryptic "refused to connect" blank frame. A failed probe
// (unreachable) keeps the plain iframe.
useEffect(() => {
if (url === undefined) return
let cancelled = false
setEmbedBlocked(null)
setForceEmbed(false)
void api.browserProbe(url).then((probe) => {
if (!cancelled && embeddabilityOf(probe) === 'blocked') setEmbedBlocked(url)
}).catch(() => { /* unreachable: keep the plain iframe */ })
return () => { cancelled = true }
}, [url])
const persist = (nextUrl: string): void => {
let host = nextUrl
try { host = new URL(nextUrl).hostname } catch { /* keep the URL as title */ }
store.reduce(state => patchTab(state, tab.id, { path: nextUrl, title: host }))
}
const navigateTo = (raw: string): void => {
const result = normalizeBrowserUrl(raw, window.location.origin, store.getPrefs().browserAllowedLoopback)
if (result.kind === 'ok') {
const next = result.url
setUrl(next)
setInput(next)
setMessage(null)
// Push onto the stack, dropping any stale forward entries.
setHistory(previous => [...previous.slice(0, cursor + 1), next])
setCursor(previous => previous + 1)
setReloadKey(key => key + 1)
persist(next)
return
}
setMessage(result.kind === 'invalid'
? t('browserInvalid')
: result.reason === 'scheme' ? t('browserBlockedScheme')
: t('browserBlockedLoopback'))
}
const goBack = (): void => {
if (cursor <= 0) return
const next = history[cursor - 1]!
setCursor(cursor - 1)
setUrl(next)
setInput(next)
setReloadKey(key => key + 1)
}
const goForward = (): void => {
if (cursor >= history.length - 1) return
const next = history[cursor + 1]!
setCursor(cursor + 1)
setUrl(next)
setInput(next)
setReloadKey(key => key + 1)
}
return (
<div className={css.browser}>
<div className={css.browserBar}>
<button
type="button"
className={css.iconButton}
aria-label={t('browserBack')}
title={t('browserBack')}
disabled={cursor <= 0}
onClick={goBack}
>
<IconChevronLeftOutline14 />
</button>
<button
type="button"
className={css.iconButton}
aria-label={t('browserForward')}
title={t('browserForward')}
disabled={cursor >= history.length - 1}
onClick={goForward}
>
<IconChevronRightOutline14 />
</button>
<button
type="button"
className={css.iconButton}
aria-label={t('refresh')}
title={t('refresh')}
onClick={() => { setReloadKey(key => key + 1) }}
>
<IconRefreshOutline14 />
</button>
<input
className={css.browserInput}
value={input}
placeholder={t('browserPlaceholder')}
spellCheck={false}
onChange={event => { setInput(event.target.value) }}
onKeyDown={event => {
if (event.key === 'Enter') navigateTo(input)
}}
/>
<button
type="button"
className={css.iconButton}
aria-label={t('browserGo')}
title={t('browserGo')}
onClick={() => { navigateTo(input) }}
>
<IconLinkOutline14 />
</button>
<button
type="button"
className={css.iconButton}
aria-label={t('browserOpenExternal')}
title={t('browserOpenExternal')}
disabled={url === undefined}
onClick={() => {
if (url !== undefined) window.open(url, '_blank', 'noopener')
}}
>
<VscLinkExternal size={15} />
</button>
</div>
{message !== null && <div className={css.browserMessage}>{message}</div>}
<SandboxStatusBar
sandboxed={!noSandbox}
local={localUnlock}
dangerCopy={t('browserNoSandboxWarning')}
onUnlock={() => { setLocalUnlock(true) }}
onRestore={() => { setLocalUnlock(false) }}
/>
{url === undefined ? (
<div className={css.browserStart}>{t('browserStart')}</div>
) : embedBlocked !== null && !forceEmbed ? (
<BrowserEmbedBlocked
url={embedBlocked}
onOpenInBrowser={() => { window.open(embedBlocked, '_blank', 'noopener') }}
onLoadAnyway={() => { setForceEmbed(true) }}
/>
) : (
<iframe
key={`${reloadKey}:${noSandbox ? 'ns' : 'sb'}`}
className={css.browserFrame}
src={url}
sandbox={noSandbox ? undefined : iframeSandboxFor(url, store.getPrefs().browserAllowedLoopback, window.location.origin)}
referrerPolicy="no-referrer"
allow=""
title={url}
/>
)}
</div>
)
}
/**
* The embed-refusal panel: shown when the probed site forbids being
* displayed inside other pages (X-Frame-Options / frame-ancestors) — the
* iframe would only show the browser's "refused to connect" blank. Explains
* the reason and offers the real-browser open plus a load-anyway escape.
* Exported so the copy and the actions are testable without a DOM.
*/
export function BrowserEmbedBlocked(props: {
url: string
onOpenInBrowser: () => void
onLoadAnyway: () => void
}) {
const { url, onOpenInBrowser, onLoadAnyway } = props
let host = url
try { host = new URL(url).hostname } catch { /* keep the raw URL */ }
return (
<div className={css.browserBlocked}>
<IconWarningOutline16 size={16} />
<div className={css.browserBlockedTitle}>{t('browserEmbedBlocked', { host })}</div>
<div className={css.browserBlockedDesc}>{t('browserEmbedBlockedDesc')}</div>
<div className={css.browserBlockedActions}>
<button type="button" className={css.browserBlockedButton} onClick={onOpenInBrowser}>
{t('browserOpenExternal')}
</button>
<button type="button" className={css.browserBlockedButton} onClick={onLoadAnyway}>
{t('browserEmbedAnyway')}
</button>
</div>
</div>
)
}
+16 -91
View File
@@ -21,8 +21,8 @@
* conversations swaps the workbench.
*
* The shell binds the workbench actions to the store and dispatches tab
* content to the views. New tabs come from the + menu (explorer / git /
* terminal; editors open from the explorer).
* content to the views. New tabs come from the + menu (explorer / git;
* editors open from the explorer).
*/
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent as ReactDragEvent, type ReactNode } from 'react'
import { useSyncExternalStore } from 'react'
@@ -31,12 +31,11 @@ import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../context-types.ts'
import { referenceInChat as referenceInChatShared } from './reference-in-chat.ts'
import {
BOTTOM_MIN, CONVERSATION_MIN, agentUuidOf, firstLeaf, isAgentTabId,
BOTTOM_MIN, CONVERSATION_MIN,
leafWithTab, moveTab, moveTabToEdge, openDiffTab, resizeSplitIn,
setBottomHeight, setTabPin, toggleBottomPanel, toggleExpanded,
setBottomHeight, toggleBottomPanel, toggleExpanded,
type DropZone, type SidebarStore, type SidebarTab,
} from './state.ts'
import { getPinnedHomeScope } from './pinned.ts'
import { IconPanelBottomOutline16 } from './icons.tsx'
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
import { useViewportSize } from './breakpoints.ts'
@@ -48,7 +47,6 @@ import { computeTitleBarStrip } from './titlebar-strip.ts'
import { TabContent, buildNewTabOptions } from './sidebar/TabContent.tsx'
import { useCenterColumn } from './sidebar/use-center-column.ts'
import { useHostFeeds } from './sidebar/use-host-feeds.ts'
import { usePinnedTabs } from './sidebar/use-pinned-tabs.ts'
import type { TabDragPayload } from './TabBar.tsx'
import { t } from './locales.ts'
import { api } from './api.ts'
@@ -268,7 +266,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// While the session's header is still hydrating (or the session is blank),
// the list summary may carry no cwd; ask the host once (it falls back to
// the process cwd) so the explorer root and terminal cwd are real from
// the process cwd) so the explorer root and the git rows are real from
// first paint instead of showing "no session".
const [fetchedCwd, setFetchedCwd] = useState<string | undefined>(undefined)
useEffect(() => {
@@ -295,10 +293,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
[state, ctx, sessionId, cwd],
)
// Host feeds (sidebar/use-host-feeds.ts): the agent-terminals / agent-opens
// WebSocket pushes and the subagent / background-job auto-activation
// triggers, all keyed on the current session. The jump-back ref is the one
// piece the render side consumes (renderTab's onSubagentJump arms it).
// Host feeds (sidebar/use-host-feeds.ts): the agent-opens WebSocket push
// and the subagent / background-job auto-activation triggers, all keyed on
// the current session. The jump-back ref is the one piece the render side
// consumes (renderTab's onSubagentJump arms it).
const { subagentJumpRef } = useHostFeeds({ ctx, store, sessionList, sessionId })
// Center-column tracking (sidebar/use-center-column.ts): the bottom
@@ -310,36 +308,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const bottomRef = useRef<HTMLDivElement | null>(null)
const { centerRectRef, centerMeasured, measureCenter, draggingRef } = useCenterColumn(bottomRef, state?.bottomOpen)
/**
* Bottom-panel first-expansion auto terminal: the FIRST time the user
* expands the bottom panel in a session, try to open a fresh terminal tab
* there. "Try" is literal — the terminal's own quota and enable switch
* gate the attempt (a full quota or a disabled terminal type makes it a
* no-op). Gated on the bottomPanelAutoTerminal pref (the terminal tab's
* nested settings toggle, default on). Only a false→true TRANSITION fires
* (a panel persisted open never counts as an expansion), and the session's
* bottomOpenedOnce flag is set atomically with the first fire so later
* expansions never repeat it.
*/
const bottomWasOpenRef = useRef<boolean | undefined>(undefined)
useEffect(() => {
if (state === undefined) return
const wasOpen = bottomWasOpenRef.current
bottomWasOpenRef.current = state.bottomOpen
if (wasOpen === undefined || wasOpen || !state.bottomOpen) return
if (state.bottomOpenedOnce) return
if (store.getPrefs().bottomPanelAutoTerminal === false) return
if (ctx.get('betterSidebar')?.isTabEnabled('terminal') === false) return
// Land the tab in the bottom panel's first pane; the once-flag is set
// atomically so later expansions never repeat the auto-open.
store.reduce(s => ({ ...s, activePane: firstLeaf(s.bottomSplits).id, bottomOpenedOnce: true }))
ctx.get('betterSidebar')?.openTab({ type: 'terminal', target: 'bottom' })
}, [state, store, ctx])
// The bottom panel's height drag (top edge strip). Drags write the size
// DIRECTLY to the DOM (panel style + the layout CSS variable) instead of
// round-tripping the store on every pointer move — a store reduce
// re-renders the workbench (terminals, editors…) per move, which is the
// re-renders the workbench (editors, trees…) per move, which is the
// visible drag lag. The store is committed once on pointer up
// (clamping + persistence).
const bottomDrag = useRef({ startY: 0, startHeight: 0 })
@@ -527,28 +499,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const actions: WorkbenchActions = useMemo(() => ({
closeTab: (paneId, tabId) => {
// A closed terminal releases its pty immediately — including when its
// socket is mid-reconnect, where the unmount close frame never reaches
// the host and the process would hold the quota until the grace ends.
// Agent terminals (tabId `agent:<uuid>`) close through a different
// host route: the WS close frame is the primary path (sent by
// TerminalView on unmount), and the agent-pty.close HTTP route is the
// fallback when the WS is down.
const current = store.getSnapshot().state
const leaf = current === undefined ? undefined : leafWithTab(current.bottomSplits, tabId)
const tab = leaf?.tabs.find(candidate => candidate.id === tabId)
// Route through the service: the tab-bar close is the canonical close
// path (finds the pane itself, fires descriptor.onClose); the session
// scope (with its cwd) rides to the callback.
ctx.get('betterSidebar')?.closeTab(tabId, sessionId === undefined ? undefined : { sessionId, cwd })
if (tab?.type === 'terminal') {
if (isAgentTabId(tabId)) {
const uuid = agentUuidOf(tabId)
void api.agentPtyClose(uuid).catch(() => { /* the host may already have released it */ })
} else if (sessionId !== undefined) {
void api.ptyClose({ sessionId, cwd }, tabId).catch(() => { /* the host may already have released it */ })
}
}
},
activateTab: (paneId, tabId) => {
// Route through the service: same reducer (finds the pane, sets the
@@ -573,21 +527,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
resizeSplit: (splitId, index, deltaFrac) => {
store.reduce(s => resizeSplitIn(s, splitId, index, deltaFrac))
},
// Pin/unpin a terminal tab (v0.17.0+): the home cwd is snapshotted at
// pin time so a workspace-scoped pin only resurfaces in sessions whose
// cwd matches. Unpin passes null — the tab stays open in its home
// session, just unmarked.
pinTab: (tabId, scope) => {
store.reduce(s => setTabPin(s, tabId, scope === null ? null : { scope, homeCwd: cwd }))
},
}), [store, sessionId, cwd, ctx])
// Pinned virtual tabs (sidebar/use-pinned-tabs.ts): cross-session pinned
// tabs inject into the bottom workbench's first leaf, and the actions are
// wrapped so pinned virtual ids route to the HOME session (reduceFor +
// revision bump).
const { augmentedTree, wrappedActions } = usePinnedTabs({ store, sessionId, cwd, snapshot, actions })
/**
* The explorer's @-reference button. Directories append the folder mention
* (`@dir/`) as plain text so DSH's folder decoration and completion keep
@@ -650,15 +591,6 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
* strip must never break because a plugin's badge computation failed.
*/
const tabBadgeOf = (tab: SidebarTab): ReactNode => {
// Agent-terminal wait indicator (sidebar-internal, deliberately NOT a
// TabDescriptor.badge — that API is type-keyed and shared with external
// plugins, and cannot address one tab): the agent-terminals push mirrors
// the model's live terminal_wait_for into state.agentWaits; an agent tab
// whose uuid is waiting shows the hourglass pill.
if (isAgentTabId(tab.id)) {
const wait = state.agentWaits?.[agentUuidOf(tab.id)]
if (wait !== undefined) return <span className={css.tabBadge}>{'⏳'}</span>
}
const descriptor = ctx.get('betterSidebar')?.getTab(tab.type)
if (descriptor?.badge === undefined) return null
let value: string | number | null | undefined
@@ -681,19 +613,12 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
* with the tab so diff tabs can split below their source pane.
*/
const renderTab = (tab: SidebarTab, active: boolean, paneId: string) => {
// Pinned virtual tabs: pass the home session's scope (sessionId + cwd) so
// TerminalView's WS URL resolves to the home PTY, and effectiveTabId so
// the descriptor component receives the ORIGINAL tab id (the virtual id
// is only a display key). Regular tabs: effectiveTabId is undefined (no
// override), scope is the current session's.
const home = getPinnedHomeScope(tab)
return (
<TabContent
tab={tab}
effectiveTabId={home?.tabId}
paneId={paneId}
sessionId={home?.sessionId ?? sessionId}
cwd={home?.cwd ?? cwd}
sessionId={sessionId}
cwd={cwd}
expanded={state.expanded}
revealed={state.revealed ?? []}
onToggleDir={(path) => { store.reduce(s => toggleExpanded(s, path)) }}
@@ -720,8 +645,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
that, `centerRect` is the {0,0} fallback and `right` computes to the
full viewport width — the panel (and its overflow content) would
flash full-width for a frame until the first measurement lands.
Rendering stays unconditional so the mount/render chain (auto-terminal
etc.) is never gated on geometry.
Rendering stays unconditional so the mount/render chain is never
gated on geometry.
*/}
<div
ref={bottomRef}
@@ -790,9 +715,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
<div className={css.panelBody}>
<Workbench
state={state}
tree={augmentedTree}
tree={state.bottomSplits}
newTabOptions={newTabOptions}
actions={wrappedActions}
actions={actions}
onNewTab={onNewTab}
renderTab={renderTab}
getTabIcon={tabIconOf}
+20 -76
View File
@@ -1,11 +1,11 @@
/**
* The tab strip of one pane: tabs capped at TAB_MAX_WIDTH (ellipsized),
* overflow scrolls horizontally, a close button per tab, a four-way split
* button cluster, and the + menu that opens new tabs (explorer / git /
* terminal). Tabs are draggable; dropping onto another tab inserts before it,
* dropping on the strip background appends to this pane. Right-clicking a
* tab opens the tab context menu (close / close others / close to the left /
* close to the right, the close ones scoped to this pane).
* button cluster, and the + menu that opens new tabs (explorer / git). Tabs
* are draggable; dropping onto another tab inserts before it, dropping on the
* strip background appends to this pane. Right-clicking a tab opens the tab
* context menu (close / close others / close to the left / close to the
* right, the close ones scoped to this pane).
*/
import { useEffect, useRef, useState, type ReactNode } from 'react'
import clsx from 'clsx'
@@ -13,10 +13,6 @@ import {
IconCloseFill14, IconPlusOutline16, Menu,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarTab } from './state.ts'
import { isAgentTabId } from './state.ts'
import { isPinnedVirtualTab } from './pinned.ts'
import { useSubmenuFlip } from './menu-flip.ts'
import { IconPinOutline16 } from './icons.tsx'
import { t } from './locales.ts'
import css from './sidebar.module.css'
@@ -67,13 +63,6 @@ export function TabBar(props: {
newTabOptions: NewTabOption[]
/** Drop of a tab from any pane: (payload, insertBeforeTabId | null). */
onDropTab: (payload: TabDragPayload, before: string | null) => void
/**
* Pin/unpin a terminal tab (v0.17.0+). Called with `'workspace'` or
* `'global'` to pin (the shell snapshots the home cwd), or `null` to
* unpin. Non-terminal tabs never trigger this callback. Optional: the
* menu hides the pin entry when unset (legacy callers).
*/
onPinTab?: (tabId: string, scope: 'workspace' | 'global' | null) => void
/** Icon resolver for tab labels (reads from the tab descriptor registry). */
getTabIcon?: (tab: SidebarTab) => ReactNode
/** Badge resolver for tab labels (reads the descriptor's `badge`; the
@@ -81,15 +70,12 @@ export function TabBar(props: {
getTabBadge?: (tab: SidebarTab) => ReactNode
}) {
const {
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, onPinTab, getTabIcon, getTabBadge,
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, getTabIcon, getTabBadge,
} = props
const [menuOpen, setMenuOpen] = useState(false)
// The tab right-click context menu: the target tab plus the cursor
// position (the portaled Menu anchors there, following the git lens/FileTree).
const [tabMenu, setTabMenu] = useState<{ tabId: string; x: number; y: number } | null>(null)
// The strip sits at the panel's top, so the pin submenu must grow downward;
// the flip hook derives that from the cursor y (upper half → "down").
useSubmenuFlip(tabMenu)
const [dragOver, setDragOver] = useState(false)
const listRef = useRef<HTMLDivElement>(null)
// The context target's index in the render-time tab snapshot; -1 when the
@@ -183,15 +169,13 @@ export function TabBar(props: {
}}
>
<div ref={listRef} className={css.tabList}>
{tabs.map(tab => {
const pinned = isPinnedVirtualTab(tab) || tab.pin !== undefined
return (
{tabs.map(tab => (
<div
key={tab.id}
className={clsx(css.tab, active === tab.id && css.tabActive, pinned && css.pinnedTab)}
className={clsx(css.tab, active === tab.id && css.tabActive)}
title={tab.title}
draggable={!pinned}
onDragStart={pinned ? undefined : (event) => {
draggable
onDragStart={(event) => {
setTabDragging(true)
event.dataTransfer.setData(TAB_DRAG_TYPE, serializeDrag({ tabId: tab.id, paneId }))
event.dataTransfer.effectAllowed = 'move'
@@ -199,7 +183,6 @@ export function TabBar(props: {
onDragEnd={() => { setTabDragging(false); setDragOver(false) }}
onDragOver={(event) => { event.preventDefault(); event.stopPropagation() }}
onDrop={(event) => {
if (pinned) { event.stopPropagation(); return }
event.preventDefault()
event.stopPropagation()
setTabDragging(false)
@@ -228,7 +211,6 @@ export function TabBar(props: {
setTabMenu({ tabId: tab.id, x: event.clientX, y: event.clientY })
}}
>
{pinned && <IconPinOutline16 size={16} />}
{getTabIcon?.(tab) ?? null}
{getTabBadge?.(tab) ?? null}
<span className={css.tabTitle}>{tab.title}</span>
@@ -244,8 +226,7 @@ export function TabBar(props: {
<IconCloseFill14 />
</button>
</div>
)
})}
))}
{/*
The + sits immediately after the rightmost tab (sticky at the
right edge of the scrollport when the tabs overflow, so it stays
@@ -284,62 +265,25 @@ export function TabBar(props: {
so the panel's overflow clip cannot crop it). Close operations are
scoped to THIS pane: "close others/left/right" walk the render-time
tab snapshot and reuse the per-tab onClose path (which routes
through the service and releases terminals), so the target tab is
never closed and the pane never empties mid-loop.
through the service), so the target tab is never closed and the
pane never empties mid-loop.
*/}
<Menu
open={tabMenu !== null && tabMenuIndex >= 0}
onClose={() => { setTabMenu(null) }}
items={(() => {
// The target tab drives the pin entry's shape: terminal tabs
// get either a "Pin ▸" submenu (unpinned) or a single "Unpin"
// row (pinned). Non-terminal tabs and missing onPinTab get no
// pin entry at all. Pinned VIRTUAL tabs (injected from other
// sessions) get a stripped menu: only Unpin + Close (no
// close-others/left/right — those are pane-scoped operations
// that don't apply to cross-session virtual tabs).
const targetTab = tabMenuIndex >= 0 ? tabs[tabMenuIndex] : undefined
const isTerminal = targetTab?.type === 'terminal'
const isPinnedVirtual = targetTab !== undefined && isPinnedVirtualTab(targetTab)
const pinEntries = isTerminal && onPinTab !== undefined
? targetTab!.pin !== undefined
? [{ id: 'unpin', label: t('unpinTerminal') }]
: [{
id: 'pin',
label: isAgentTabId(targetTab!.id) ? t('pinAgentTerminal') : t('pinTerminal'),
submenu: [
{ id: 'pinWorkspace', label: t('pinToWorkspace') },
{ id: 'pinGlobal', label: t('pinToGlobal') },
],
}]
: []
if (isPinnedVirtual) {
return [
...pinEntries,
{ id: 'close', label: t('close') },
]
}
return [
...pinEntries,
{ id: 'close', label: t('close') },
{ id: 'closeOthers', label: t('closeOtherTabs'), ...(tabs.length <= 1 ? { disabled: true } : {}) },
{ id: 'closeLeft', label: t('closeLeftTabs'), ...(tabMenuIndex <= 0 ? { disabled: true } : {}) },
{ id: 'closeRight', label: t('closeRightTabs'), ...(tabMenuIndex >= tabs.length - 1 ? { disabled: true } : {}) },
]
})()}
items={[
{ id: 'close', label: t('close') },
{ id: 'closeOthers', label: t('closeOtherTabs'), ...(tabs.length <= 1 ? { disabled: true } : {}) },
{ id: 'closeLeft', label: t('closeLeftTabs'), ...(tabMenuIndex <= 0 ? { disabled: true } : {}) },
{ id: 'closeRight', label: t('closeRightTabs'), ...(tabMenuIndex >= tabs.length - 1 ? { disabled: true } : {}) },
]}
onSelect={(id) => {
const target = tabMenu
if (target === null) return
setTabMenu(null)
const index = tabs.findIndex(tab => tab.id === target.tabId)
if (index < 0) return
if (id === 'pinWorkspace') {
onPinTab?.(target.tabId, 'workspace')
} else if (id === 'pinGlobal') {
onPinTab?.(target.tabId, 'global')
} else if (id === 'unpin') {
onPinTab?.(target.tabId, null)
} else if (id === 'close') {
if (id === 'close') {
onClose(target.tabId)
} else if (id === 'closeOthers') {
for (const tab of tabs) {
-477
View File
@@ -1,477 +0,0 @@
/**
* The interactive terminal: xterm.js over a WebSocket to the host pty.
* The host replays the session's transcript on connect, then streams live
* output; input frames are raw text, resize frames are JSON with
* type:"resize". Transient disconnects (page refresh, host restart) reconnect
* automatically; a server-side refusal (close code 1011 with a reason, e.g.
* a failed pty spawn) stops the loop and shows the reason with a manual
* retry, and repeated unreasoned failures surface the close code after three
* attempts, so the banner never spins forever.
*
* Three control frames shape the pty lifecycle on unmount:
* - `{type:'close'}` — the user closed the tab. The host kills the pty
* immediately (quota released).
* - `{type:'park'}` — the user switched to another conversation. The tab is
* still open in its session's persisted state but its view unmounted; the
* host keeps the pty alive indefinitely (no grace countdown), so switching
* back reattaches the same shell instead of respawning one.
* - bare socket drop (no frame) — page refresh, crash, plugin teardown, or a
* same-session re-render. The host's reconnect grace keeps the shell alive
* for a quick reconnect.
*
* Two attach modes share one upgrade endpoint:
* - `tabId` starting with `agent:` is an agent-owned terminal (created by
* the `terminal_create` tool). The uuid is the suffix after `agent:`; the
* view connects with `?uuid=...`. A close frame kills the pty (the agent's
* terminal closes when the user closes the tab); a bare socket drop
* leaves the pty alive (the agent owns the lifetime) — agent terminals
* never send park (their lifetime is already indefinite on bare drop).
* - Any other `tabId` is a UI-tab terminal (the user created it from the +
* menu). The view connects with `?tab=...&sessionId=...&cwd=...`. A close
* frame schedules a 0-ms close; a park frame marks the pty as parked; a
* bare socket drop gets the host's reconnect grace.
*/
import { useEffect, useRef, useState } from 'react'
import { Terminal, type ITheme } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
import '@xterm/xterm/css/xterm.css'
import { t } from './locales.ts'
import { ONE_DARK, ONE_LIGHT } from './one-dark-palette.ts'
import { openWhenSized } from './open-when-sized.ts'
import { api, type SessionScope, type TerminalDepsStatus } from './api.ts'
import { agentUuidOf, isAgentTabId, type SidebarStore } from './state.ts'
import { isDarkScheme, subscribeColorScheme, effectiveTokenValue, tokenValue } from './theme.ts'
import { resolveTerminalFont } from './terminal-font.ts'
import {
buildTerminalLinks,
shouldActivateTerminalLink,
openTerminalUrl,
} from './terminal-links.ts'
import { TerminalWaitBanner } from './TerminalWaitBanner.tsx'
import css from './sidebar.module.css'
/** How many consecutive unreasoned failures before showing the error banner. */
const FAILURE_LIMIT = 3
/**
* The WS close-code-1011 reason the host sends when node-pty is unavailable
* (mirror of the host's PTY_DEPS_MISSING; the value is a wire contract, so
* the two sides keep the literal in lockstep). The view then fetches the
* full repair details from /sidebar/api/terminal.deps.
*/
const PTY_DEPS_MISSING = 'pty-deps-missing'
/**
* The WS close-reason prefix the host sends when the CONFIGURED shell was
* not found (mirror of src/index.ts wsCloseReasonOf; wire contract, keep the
* literal in lockstep). The view renders a localized, actionable banner.
*/
const SHELL_NOT_FOUND_PREFIX = 'shell-not-found:'
/** The degraded-mode payload rendered by {@link TerminalDepsBanner}. */
type TerminalDepsInfo = Extract<TerminalDepsStatus, { ok: false }>
/**
* Curated ANSI palettes for the terminal. The surface colors (background,
* foreground, cursor, selection) ride the theme tokens so the terminal
* blends with the panel in both schemes; the 16 ANSI colors are the same
* designed syntax families the app's code surfaces use — one-dark for
* dark, one-light for light (one-dark-palette.ts, shared with the
* CodeMirror themes) — read live so a scheme flip re-themes in place.
*/
const ANSI_DARK: Record<string, string> = {
black: ONE_DARK.black, red: ONE_DARK.red, green: ONE_DARK.green, yellow: ONE_DARK.yellow,
blue: ONE_DARK.blue, magenta: ONE_DARK.magenta, cyan: ONE_DARK.cyan, white: ONE_DARK.gray,
brightBlack: ONE_DARK.faintGray, brightRed: ONE_DARK.red, brightGreen: ONE_DARK.green,
brightYellow: ONE_DARK.yellow, brightBlue: ONE_DARK.blue, brightMagenta: ONE_DARK.magenta,
brightCyan: ONE_DARK.cyan, brightWhite: ONE_DARK.white,
}
const ANSI_LIGHT: Record<string, string> = {
black: ONE_LIGHT.black, red: ONE_LIGHT.red, green: ONE_LIGHT.green, yellow: ONE_LIGHT.yellow,
blue: ONE_LIGHT.blue, magenta: ONE_LIGHT.magenta, cyan: ONE_LIGHT.cyan, white: ONE_LIGHT.gray,
brightBlack: ONE_LIGHT.faintGray, brightRed: ONE_LIGHT.red, brightGreen: ONE_LIGHT.green,
brightYellow: ONE_LIGHT.yellow, brightBlue: ONE_LIGHT.blue, brightMagenta: ONE_LIGHT.magenta,
brightCyan: ONE_LIGHT.cyan, brightWhite: ONE_LIGHT.offWhite,
}
/** The xterm theme for the current scheme (surface from tokens, ANSI curated). */
function xtermTheme(): ITheme {
const dark = isDarkScheme()
// Skin systems set --dsw-alias-bg-base to `transparent` or translucent
// glass values (the dsh-web-ui skins use rgba 0.16–0.7); effectiveTokenValue
// treats those as unset below the opacity floor, so the opaque fallback
// engages and the terminal never renders see-through over the skin's
// backdrop (issue #90). Effectively opaque scoped surfaces (e.g. a skin's
// 0.96 porcelain) pass through — the skin still controls the terminal.
const background = effectiveTokenValue('--dsw-alias-bg-base') || (dark ? '#111114' : '#ffffff')
const foreground = effectiveTokenValue('--dsw-alias-label-primary') || (dark ? '#e6e6e6' : '#1a1a1a')
return {
background,
foreground,
cursor: foreground,
cursorAccent: background,
selectionBackground: dark ? 'rgba(255,255,255,0.22)' : 'rgba(0,0,0,0.12)',
...(dark ? ANSI_DARK : ANSI_LIGHT),
}
}
export function TerminalView(props: { scope: SessionScope; tabId: string; store: SidebarStore }) {
const { scope, tabId, store } = props
const hostRef = useRef<HTMLDivElement>(null)
const [connected, setConnected] = useState(false)
const [fatal, setFatal] = useState<string | null>(null)
const [depsFatal, setDepsFatal] = useState<TerminalDepsInfo | null>(null)
const [lastUrl, setLastUrl] = useState<string | null>(null)
// Agent terminals only: the model's active terminal_wait_for (mirrored
// from the host's agent-terminals push into the store) drives the wait
// banner. Read + subscribe like the font prefs above; the banner vanishes
// when the host's push drops the waiting field (skip / exit / abort all
// converge through the same push). getSnapshot() is {sessionId, state?,
// prefs} — the state may be briefly undefined around session switches.
const agentUuid = isAgentTabId(tabId) ? agentUuidOf(tabId) : null
const [waiting, setWaiting] = useState<{ needle: string; since: number } | undefined>(undefined)
useEffect(() => {
if (agentUuid === null) return
const read = (): void => {
const next = store.getSnapshot().state?.agentWaits?.[agentUuid]
setWaiting(prev => {
const nextValue = next === undefined ? undefined : { needle: next.needle, since: next.since }
if (prev?.needle === nextValue?.needle && prev?.since === nextValue?.since) return prev
return nextValue
})
}
read()
return store.subscribe(read)
}, [agentUuid, store])
const connectRef = useRef<(() => void) | null>(null)
useEffect(() => {
const host = hostRef.current
if (host === null) return
// The custom font prefs (side card settings, terminal card) resolve at
// mount; store changes re-apply them live below.
const font = resolveTerminalFont(store.getPrefs(), tokenValue('--ds-font-family-code'))
const term = new Terminal({
cursorBlink: true,
fontSize: font.fontSize,
fontFamily: font.fontFamily,
allowTransparency: true,
convertEol: false,
scrollback: 4000,
theme: xtermTheme(),
})
const fit = new FitAddon()
term.loadAddon(fit)
// Ctrl+Click (Cmd+Click on mac) opens http(s) URLs printed in the
// pty stream — a plain click is left for xterm's text-selection
// gesture. Only http(s) is dispatched; file:// / mailto: / etc. are
// underlined for visibility but rejected at activation. See
// terminal-links.ts for the line scanner, modifier gate and scheme
// guard.
const linkProvider = term.registerLinkProvider({
provideLinks: (lineNumber, callback) => {
// xterm's `provideLinks` hands us a 1-based buffer line number
// (its own built-in ILinkProvider does `buffer.lines.get(e - 1)`,
// i.e. the public `bufferLineNumber` is 1-based while `getLine`
// takes a 0-based index). Passing `lineNumber` straight through
// would fetch the row *below* the one xterm asked us to scan, so
// the URL text would come from the wrong row while `range.y` still
// pointed at the requested row — links landed one line too high.
const line = term.buffer.active.getLine(lineNumber - 1)
if (line === undefined) {
callback(undefined)
return
}
const descriptors = buildTerminalLinks(line.translateToString(true), lineNumber)
if (descriptors.length === 0) {
callback(undefined)
return
}
callback(descriptors.map(descriptor => ({
range: descriptor.range,
text: descriptor.text,
activate: (event) => {
if (!shouldActivateTerminalLink(event)) return
openTerminalUrl(descriptor.text)
},
})))
},
})
// Re-theme in place when the app's scheme flips (tokens + palette).
const applyTheme = (): void => {
term.options.theme = xtermTheme()
term.refresh(0, term.rows - 1)
}
const schemeSub = subscribeColorScheme(applyTheme)
let socket: WebSocket | null = null
let closed = false
let retry: number | undefined
let failures = 0
const wsUrl = (): string => {
const url = new URL('/sidebar/ws/terminal', location.origin)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
// Agent terminals attach by uuid (the host looks them up in the agent
// pty registry); UI-tab terminals attach by sessionId+tab (the host
// uses the UI-tab pty manager). Same upgrade endpoint, different query.
if (isAgentTabId(tabId)) {
url.search = new URLSearchParams({ uuid: agentUuidOf(tabId) }).toString()
} else {
const params = new URLSearchParams({ sessionId: scope.sessionId, tab: tabId })
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
url.search = params.toString()
}
// Same construction the app's own downlink WebSockets use (new URL
// over location.origin + protocol swap): whatever the environment
// does to the app's websockets applies identically here.
return url.toString()
}
const sendResize = (): void => {
if (socket !== null && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
}
}
const connect = (): void => {
if (closed) return
const url = wsUrl()
setLastUrl(url)
socket = new WebSocket(url)
socket.onopen = () => {
failures = 0
setConnected(true)
setFatal(null)
sendResize()
}
socket.onmessage = (event) => {
if (typeof event.data === 'string') term.write(event.data)
}
socket.onclose = (event) => {
setConnected(false)
// node-pty dependency missing/broken (issue #140): the host closed
// with the short marker. Fetch the full repair details over HTTP —
// a WS close reason is capped at 123 bytes, too small for the
// pasteable command. A failed fetch falls back to the plain banner.
if (event.code === 1011 && event.reason === PTY_DEPS_MISSING) {
void api.terminalDeps().then((status) => {
if (status.ok) {
// The host recovered between the close and the fetch — the
// plain banner with a retry is the honest state.
setFatal(t('terminalDepsFailed'))
return
}
setFatal(null)
setDepsFatal(status)
}).catch(() => {
setFatal(t('terminalDepsFailed'))
})
return
}
// The configured shell could not be found (settings page or yaml):
// a localized banner beats the raw English close reason.
if (event.code === 1011 && event.reason.startsWith(SHELL_NOT_FOUND_PREFIX)) {
setFatal(t('terminalShellNotFound', { name: event.reason.slice(SHELL_NOT_FOUND_PREFIX.length) || '?' }))
return
}
// A server-side refusal carries a close code + reason; retrying it
// forever would only spin the banner, so surface it with a retry.
if (event.code === 1011 && event.reason !== '') {
setFatal(event.reason)
return
}
// Unreasoned drops (upgrade rejected, host down, mid-handshake
// refusal) normally recover on the next attempt; after a few
// consecutive failures stop spinning and show the close code.
failures += 1
if (failures >= FAILURE_LIMIT) {
const detail = event.reason !== '' ? ` (${event.code}: ${event.reason})` : ` (${event.code})`
console.error('[dsh-better-sidebar] terminal connection failed:', event.code, event.reason, url)
setFatal(`${t('terminalConnectFailed')}${detail}`)
return
}
if (!closed) retry = window.setTimeout(connect, 2000)
}
socket.onerror = () => {
socket?.close()
}
}
connectRef.current = connect
const inputSub = term.onData((data) => {
if (socket !== null && socket.readyState === WebSocket.OPEN) socket.send(data)
})
// Resize streams fire per layout frame during panel open/close
// animations; fit() measures glyphs, so coalesce to one fit per
// animation frame (same pattern as the core's frame-batcher, kept
// local — this view lives in the lazy terminal chunk and does not
// import core-bundle modules).
let resizeFrame: number | null = null
const observer = new ResizeObserver(() => {
if (resizeFrame !== null) return
resizeFrame = requestAnimationFrame(() => {
resizeFrame = null
try {
fit.fit()
sendResize()
} catch {
// The terminal may be mid-dispose; ignore.
}
})
})
observer.observe(host)
// Custom font prefs (the terminal card's secondary settings) apply LIVE:
// on any store change re-resolve and diff the two options, re-fitting
// when they moved (the grid dimensions may change with the font). The
// subscribe fires on every store change (tabs, panels…), so the diff is
// what keeps this cheap.
const fontSub = store.subscribe(() => {
const next = resolveTerminalFont(store.getPrefs(), tokenValue('--ds-font-family-code'))
if (next.fontFamily !== term.options.fontFamily || next.fontSize !== term.options.fontSize) {
term.options.fontFamily = next.fontFamily
term.options.fontSize = next.fontSize
try {
fit.fit()
sendResize()
} catch {
// The terminal may be mid-dispose; ignore.
}
}
})
// The terminal must not be opened in a zero-size container: xterm's
// renderer creation fails there and the next Viewport refresh crashes
// reading `.dimensions` off the undefined renderer (blank terminal on
// WKWebView when the bottom panel's expand slide leaves the host at
// height 0; any display:none-hidden ancestor does the same). Defer
// open+fit until the host has a real size — writes arriving meanwhile
// are buffered by xterm's WriteBuffer and render once open, and
// FitAddon.fit() is a safe no-op before open. sendResize() here covers
// the deferred path where the socket may already be open with the
// default 80x24 dims.
const cancelOpen = openWhenSized(host, () => {
try {
term.open(host)
fit.fit()
sendResize()
} catch (error) {
console.error('[dsh-better-sidebar] xterm open failed:', error)
}
})
connect()
return () => {
closed = true
cancelOpen()
window.clearTimeout(retry)
observer.disconnect()
if (resizeFrame !== null) cancelAnimationFrame(resizeFrame)
fontSub()
schemeSub()
inputSub.dispose()
// Three unmount cases, distinguished by the store's tab/open state and
// the active session id:
// 1. The tab was closed by the user (NOT in its session's state): send
// `{type:'close'}` — the host releases the pty immediately.
// 2. The user switched to another conversation (the tab IS still open
// in scope.sessionId's state, but the active session is now a
// different one): send `{type:'park'}` — the host keeps the pty
// alive indefinitely (no grace countdown), so switching back
// reattaches the SAME shell. Without this, the bare socket drop
// would start the 30s reconnect-grace countdown and kill the shell
// while the user is still actively working in the other session.
// 3. A same-session unmount (page refresh, crash, plugin teardown, a
// re-render that re-mounts the view): bare socket drop — the host's
// reconnect grace keeps the shell alive for a quick reconnect.
// Agent terminals follow the close-frame rule; their lifetime is owned
// by the agent, so a bare drop (case 3) already leaves them alive
// indefinitely — no park frame needed.
const tabStillOpen = store.tabOpen(scope.sessionId, tabId)
const sessionSwitched = store.getSnapshot().sessionId !== scope.sessionId
if (!tabStillOpen
&& socket !== null && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'close' }))
} else if (tabStillOpen && sessionSwitched && !isAgentTabId(tabId)
&& socket !== null && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'park' }))
}
socket?.close()
linkProvider.dispose()
term.dispose()
connectRef.current = null
}
}, [scope.sessionId, scope.cwd, tabId, store])
return (
<div className={css.terminalWrap}>
{agentUuid !== null && waiting !== undefined && (
<TerminalWaitBanner
needle={waiting.needle}
onSkip={() => { void api.agentSkipWait(agentUuid).catch(() => { /* 跳过失败时 banner 留存,可重试 */ }) }}
/>
)}
{depsFatal !== null && (
<TerminalDepsBanner deps={depsFatal} onRetry={() => { setDepsFatal(null); connectRef.current?.() }} />
)}
{fatal !== null && (
<div className={css.terminalBanner}>
{t('terminalError')}: {fatal}
{lastUrl !== null && <div className={css.terminalBannerUrl}>{lastUrl}</div>}
<button
type="button"
className={css.terminalRetry}
onClick={() => { setFatal(null); connectRef.current?.() }}
>
{t('terminalRetry')}
</button>
</div>
)}
{fatal === null && depsFatal === null && !connected && <div className={css.terminalBanner}>{t('disconnected')}</div>}
<div ref={hostRef} className={css.terminal} />
</div>
)
}
/**
* The node-pty dependency failure banner (issue #140): explains that the
* terminal's native dependency failed to load and shows the PASTEABLE repair
* command (bash / cmd / PowerShell) with a copy button — the user pastes it
* into a terminal where their DSH profile lives and runs it, then retries.
* Extracted as a standalone component for direct testing.
*/
export function TerminalDepsBanner(props: { deps: TerminalDepsInfo; onRetry: () => void }) {
const { deps, onRetry } = props
const [copied, setCopied] = useState(false)
const copy = async (): Promise<void> => {
const written = await writeClipboard(deps.command)
if (written) {
setCopied(true)
window.setTimeout(() => setCopied(false), 2000)
}
}
return (
<div className={css.terminalDepsBanner}>
<div className={css.terminalDepsTitle}>{t('terminalDepsFailed')}</div>
<div className={css.terminalDepsHint}>
{t('terminalDepsHint')}
{deps.profile !== null ? t('terminalDepsProfile', { profile: deps.profile }) : ''}
</div>
<div className={css.terminalDepsCommandRow}>
<pre className={css.terminalRepairCommand}>{deps.command}</pre>
<button type="button" className={css.terminalRetry} onClick={() => { void copy() }} aria-label={t('copy')}>
{copied ? t('copied') : t('copy')}
</button>
</div>
{deps.note !== undefined && <div className={css.terminalDepsNote}>{deps.note}</div>}
<div className={css.terminalDepsActions}>
<button type="button" className={css.terminalRetry} onClick={onRetry}>
{t('terminalRetry')}
</button>
</div>
</div>
)
}
-43
View File
@@ -1,43 +0,0 @@
/**
* The "Agent 正在等待 {needle}" wait banner: rendered at the top of an
* agent-owned terminal's view while the model blocks in terminal_wait_for.
* The skip button asks the host to abort every active wait on the terminal
* (`agent-pty.skip-wait`); the banner disappears when the host's next
* agent-terminals push drops the waiting field — no optimistic UI. Kept in
* its own module (no xterm imports) so jsdom tests can render it directly.
*/
import { t } from './locales.ts'
import css from './sidebar.module.css'
/** Cap the needle shown inline; the full text rides the title tooltip. */
const NEEDLE_DISPLAY_CAP = 80
/** Truncate one needle for inline display (title attr carries the full text).
* Cut by Unicode code points, not UTF-16 code units — a naive slice can split
* a surrogate pair at the cutoff and render a dangling replacement char. */
export function truncateNeedle(needle: string): string {
const points = Array.from(needle)
return points.length > NEEDLE_DISPLAY_CAP
? `${points.slice(0, NEEDLE_DISPLAY_CAP - 1).join('')}…`
: needle
}
export function TerminalWaitBanner(props: { needle: string; onSkip: () => void }) {
const { needle, onSkip } = props
return (
<div className={css.terminalWaitBanner}>
<span
className={css.terminalWaitNeedle}
title={needle}
role="status"
aria-live="polite"
aria-label={t('terminalWaitBanner', { needle })}
>
{t('terminalWaitBanner', { needle: truncateNeedle(needle) })}
</span>
<button type="button" className={css.terminalRetry} onClick={onSkip}>
{t('terminalSkipWait')}
</button>
</div>
)
}
-40
View File
@@ -10,7 +10,6 @@ import { encodeHtmlUrl } from '../html-route.ts'
import type { LastActivity } from '../subagent-activity.ts'
import type { SidechatLiveEvent, SidechatLogEvent, SidechatThreadInfo } from '../sidechat-core.ts'
import type { SidebarSessionEvent } from '../context-types.ts'
import type { BrowserProbeResult } from './browser.ts'
/** One wire failure. */
export class SidebarApiError extends Error {
@@ -114,21 +113,6 @@ export interface JobOutputResult {
/** The `subagents.live` response: running child id → latest activity. */
export type SubagentLiveResult = { live: Record<string, LastActivity> }
/** Terminal dependency status (mirror of the host's depsStatus; issue #140). */
export type TerminalDepsStatus =
| { ok: true }
| {
ok: false
/** The require-time error message (module missing, native binding broken…). */
cause: string
/** The pasteable repair command (terminal/cmd). */
command: string
/** The detected profile name (null when undetected → the command defaults to web). */
profile: string | null
/** Optional supplementary hint (fallback command only). */
note?: string
}
/**
* Parse one `/sidebar` JSON response envelope into its value. A non-ok
* status, an unparseable body, or any shape other than `{ok: true, value}`
@@ -338,23 +322,6 @@ export const api = {
/** Cherry-pick one commit onto the current branch. */
gitCherryPick: (scope: SessionScope, hash: string, worktree?: string) =>
call<{ ok: true }>('git.cherry-pick', gitPayload(scope, worktree, { hash })),
/** Release a terminal's process immediately (tab closed; the WS close frame
* may be unreachable while the socket is down, so the host also accepts
* this explicit route). */
ptyClose: (scope: SessionScope, tab: string) =>
call<{ ok: true }>('pty.close', scopePayload(scope, { tab })),
/** Release an agent terminal by uuid (tab closed while WS was down). */
agentPtyClose: (uuid: string) =>
call<{ ok: true }>('agent-pty.close', { uuid }),
/** Skip every active terminal_wait_for on one agent terminal (the wait
* banner's skip button). Idempotent: {skipped:0} when none is active. */
agentSkipWait: (uuid: string) =>
call<{ ok: true; skipped: number }>('agent-pty.skip-wait', { uuid }),
/** Terminal dependency status (issue #140): after a WS close 1011 with
* reason `pty-deps-missing` the view fetches the full repair details here
* (the close reason itself is capped at 123 bytes). */
terminalDeps: () =>
call<TerminalDepsStatus>('terminal.deps', {}),
/**
* The output the model has read so far for one background job (replayed
* from the owner session's event log — never the model's job_output
@@ -403,9 +370,6 @@ export const api = {
childId,
...(afterSeq !== undefined ? { afterSeq } : {}),
}, signal),
/** The effective terminal shell and its display name (plugin-global). */
shellGet: () =>
call<{ shell: string; name: string }>('shell.get', {}),
/** Read the side card preferences (plugin-global, no session scope). */
settingsGet: () =>
call<{ value?: unknown; revision?: number; externalDisable?: boolean }>('settings.get', {}),
@@ -415,10 +379,6 @@ export const api = {
patch,
...(expectedRevision !== undefined ? { expectedRevision } : {}),
}),
/** Probe a URL's response headers (the sidebar browser's embeddability
* check; see the host's browser.probe route). */
browserProbe: (url: string, signal?: AbortSignal) =>
call<BrowserProbeResult>('browser.probe', { url }, signal),
/** External open for the file tree's "open with" menu. Remote SSH editor
* URLs are launched on the browser/client machine; reveal and local URLs
* keep using the host's platform opener. */
-167
View File
@@ -1,167 +0,0 @@
/**
* Pure URL policy for the built-in browser tab: normalize user input into
* an http(s) URL, and refuse destinations that would be dangerous to embed
* in the sidebar iframe. Kept dependency-free so it is unit-testable.
*
* The iframe sandbox (opaque origin, no allow-same-origin / top-navigation)
* is the primary security boundary; this module is the address-bar gate on
* top of it: only http/https may be navigated, and loopback addresses are
* refused so a browsed page cannot probe local services by user action.
* The GUI's OWN origin is explicitly ALLOWED — the user may open the GUI
* itself in the sidebar (debugging, mirroring); the sandbox still renders
* it in an opaque origin with no same-origin privileges, exactly like any
* other site.
*/
/** Why a navigation attempt was refused. */
export type BrowserBlockReason = 'scheme' | 'loopback'
/** Result of normalizing one address-bar input. */
export type BrowserNavigateResult =
| { kind: 'ok'; url: string }
| { kind: 'blocked'; reason: BrowserBlockReason }
| { kind: 'invalid' }
/** One browser.probe wire result (host fetch of the target's headers). */
export interface BrowserProbeResult {
reachable: boolean
/** The final (post-redirect) URL; present when reachable. */
url?: string
status?: number
xFrameOptions?: string
/** The CSP frame-ancestors source list; present when the directive exists. */
frameAncestors?: string[]
}
/** Embeddability verdict of one probe. */
export type Embeddability = 'embeddable' | 'blocked' | 'unknown'
/**
* Decide whether a site can render inside the sidebar iframe. The signals
* are exactly the ones the BROWSER enforces when it refuses an iframe load:
* X-Frame-Options DENY/SAMEORIGIN, or a frame-ancestors directive that does
* not allow `*` ('self' here means the SITE's own origin — never ours, so
* it also blocks the sidebar). A site we could not reach yields 'unknown'
* and the plain iframe stays.
*/
export function embeddabilityOf(probe: BrowserProbeResult): Embeddability {
if (probe.reachable !== true) return 'unknown'
const xfo = probe.xFrameOptions?.trim().toUpperCase()
if (xfo === 'DENY' || xfo === 'SAMEORIGIN') return 'blocked'
if (probe.frameAncestors !== undefined && !probe.frameAncestors.some(source => source === '*')) return 'blocked'
return 'embeddable'
}
/** A loopback hostname (localhost, IPv6 ::1, 127.0.0.0/8, 0.0.0.0). */
export function isLoopbackHostname(hostname: string): boolean {
const host = hostname.replace(/^\[|\]$/g, '').toLowerCase()
if (host === 'localhost' || host === '::1' || host === '0.0.0.0') return true
const parts = host.split('.')
return parts.length === 4
&& parts[0] === '127'
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
}
/**
* Normalize one address-bar input against the navigation policy.
* @param input - raw user text.
* @param selfOrigin - the GUI's own origin (window.location.origin). The GUI
* itself may be browsed in the sidebar (the sandbox keeps it opaque), so it
* is let through BEFORE the loopback check — its host is normally loopback.
* @param allowedLoopback - comma-separated loopback allowlist from the side
* card prefs (`browserAllowedLoopback`): bare hosts (`localhost`,
* `127.0.0.1`) allow every port, `host:port` entries allow exactly that
* authority. Entries are matched case-insensitively; portless entries match
* the host on any port. Empty allowlist keeps the default loopback block.
*/
/** Schemes that must never reach the iframe, even without `//` (javascript:,
* data:, file:, ...). Host:port lookalikes (example.com:8080) are NOT here —
* they parse as hosts below. */
const FORBIDDEN_SCHEMES = new Set([
'javascript', 'data', 'file', 'about', 'vbscript', 'blob',
'mailto', 'tel', 'ftp', 'ftps', 'ws', 'wss', 'sftp', 'ssh',
'chrome', 'chrome-extension', 'moz-extension', 'edge', 'opera', 'resource', 'view-source',
])
/** Parse the loopback allowlist into a matcher predicate over host:port. */
export function parseLoopbackAllowlist(allowlist: string): (host: string, port: string) => boolean {
const entries = allowlist.split(',').map(entry => entry.trim().toLowerCase()).filter(entry => entry !== '')
const exact = new Set(entries)
const hosts = new Set<string>()
for (const entry of entries) {
if (!entry.includes(':')) hosts.add(entry.replace(/^\[|\]$/g, ''))
}
return (host, port) => {
const key = `${host}:${port}`
if (exact.has(key) || exact.has(host)) return true
return port !== '' && hosts.has(host)
}
}
/**
* Whether a loopback URL is explicitly allowlisted by the side card prefs
* (`browserAllowedLoopback`). Only allowlisted local addresses may run with
* `allow-same-origin` in the sidebar iframe — needed for local dev servers
* (Vite etc.) whose module/HMR/fetch pipeline requires a real origin, while
* the page stays cross-origin to the GUI and to every other site.
*/
export function isAllowedLoopbackUrl(url: string, allowlist: string): boolean {
if (allowlist.trim() === '') return false
let parsed: URL
try {
parsed = new URL(url)
} catch {
return false
}
if (!isLoopbackHostname(parsed.hostname)) return false
return parseLoopbackAllowlist(allowlist)(parsed.hostname, parsed.port)
}
export function normalizeBrowserUrl(input: string, selfOrigin: string, allowedLoopback = ''): BrowserNavigateResult {
const trimmed = input.trim()
if (trimmed === '') return { kind: 'invalid' }
// Distinguish an explicit scheme from a bare host:port. "example.com:8080"
// would match a naive scheme regex (dots are legal in schemes), so a
// scheme prefix is only honored when it is http(s) or a known-forbidden
// scheme; anything else is treated as a host and gets https://.
const schemeMatch = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(trimmed)
let withScheme: string
if (schemeMatch === null) {
withScheme = `https://${trimmed}`
} else {
const scheme = schemeMatch[1]!.toLowerCase()
if (scheme === 'http' || scheme === 'https') withScheme = trimmed
else if (FORBIDDEN_SCHEMES.has(scheme)) return { kind: 'blocked', reason: 'scheme' }
else withScheme = `https://${trimmed}`
}
let url: URL
try {
url = new URL(withScheme)
} catch {
return { kind: 'invalid' }
}
// The protocol backstop: any URL that still parses to a non-http(s)
// scheme (e.g. ftp://, ws:// — which carry `//` and skip the list) is
// refused here.
if (url.protocol !== 'http:' && url.protocol !== 'https:') return { kind: 'blocked', reason: 'scheme' }
// The GUI's own origin is ALLOWED (the user may browse the GUI itself in
// the sidebar; the sandbox renders it in an opaque origin like any other
// site). It must be checked before the loopback gate because its host is
// normally loopback.
try {
if (url.origin === new URL(selfOrigin).origin) return { kind: 'ok', url: url.href }
} catch {
// Unparsable selfOrigin (never in practice): fall through to the loopback gate.
}
if (isLoopbackHostname(url.hostname)) {
// An explicit user allowlist (browserAllowedLoopback) can lift the
// loopback block for trusted local dev servers. The sandbox still
// renders them in an opaque origin — no GUI access, exactly like any
// other browsed site.
if (allowedLoopback.trim() !== '' && parseLoopbackAllowlist(allowedLoopback)(url.hostname, url.port)) {
return { kind: 'ok', url: url.href }
}
return { kind: 'blocked', reason: 'loopback' }
}
return { kind: 'ok', url: url.href }
}
+2 -3
View File
@@ -8,7 +8,7 @@
*/
import type { Context } from '../../context-types.ts'
import type { BetterSidebarService } from '../service.ts'
import { builtinTabs, type BuiltinTabOptions } from './tabs.tsx'
import { builtinTabs } from './tabs.tsx'
import { builtinViewers } from './viewers.tsx'
/**
@@ -20,10 +20,9 @@ import { builtinViewers } from './viewers.tsx'
export function registerBuiltins(
ctx: Context,
service: BetterSidebarService,
options: BuiltinTabOptions = {},
): () => void {
const disposers: (() => void)[] = []
for (const tab of builtinTabs(ctx, options)) {
for (const tab of builtinTabs()) {
disposers.push(service.registerTab(tab))
}
for (const viewer of builtinViewers()) {
+13 -175
View File
@@ -1,86 +1,34 @@
/**
* The 7 built-in tab descriptors: the plugin registers its own pages
* (editor / git — the unified changes tab / subagent / sidechat / terminal /
* browser / diff) through
* The built-in tab descriptors: the plugin registers its own pages
* (editor / git — the unified changes tab / subagent / sidechat / browser /
* diff) through
* the same {@link BetterSidebarService} external plugins use — eating its
* own dogfood. The terminal descriptor owns its quota (`TERMINAL_LIMIT`)
* and mints `terminal:<uuid>` ids through `createTab`; the browser mints
* `browser:<n>` the same way (no quota). The editor IS the files window
* (the old standalone explorer merged into it).
* own dogfood. The editor IS the files window (the old standalone explorer
* merged into it).
*
* DSH 0.1.6-alpha.2 ships its own right-Sidebar terminal and browser tab
* types, so this plugin contributes neither: the host's `terminal` kind owns
* interactive shells outright, and the host's `browser` kind (delegated to
* from the chat's http(s) links) owns embedded pages. See
* docs/plans/2026-09-21-dsh-0.1.6-alpha.2-adaptation.md.
*/
import { IconCodeOutline16, IconPanelLeftOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../../context-types.ts'
import {
browserTabIcon, changesTabIcon, filesTabIcon, sidechatTabIcon, tasksTabIcon, terminalTabIcon,
changesTabIcon, filesTabIcon, sidechatTabIcon, tasksTabIcon,
} from './tab-icons.tsx'
import { allLeaves, isAgentTabId, type SidebarState } from '../state.ts'
import { t } from '../locales.ts'
import { openSidebarFile } from '../sidebar-file.ts'
import { EditorHost } from '../EditorHost.tsx'
import { OpenWithSettings } from '../open-with-settings.tsx'
import { lazyChunkComponent } from '../lazy-chunk.tsx'
import { ChangesTab, opCountOf } from '../changes/ChangesTab.tsx'
import { DiffTab } from '../DiffTab.tsx'
import { SubagentView } from '../SubagentView.tsx'
import { consumeSidechatSeed, SideChatView, sidechatThreadIdOf } from '../SideChatView.tsx'
import { api } from '../api.ts'
import { BrowserView } from '../BrowserView.tsx'
import { TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN } from '../../prefs-shared.ts'
import type { ComponentType } from 'react'
import type { SessionScope } from '../api.ts'
import type { SidebarStore } from '../state.ts'
import type { TabDescriptor } from '../service.ts'
/**
* Lazy wrapper over the terminal view: xterm (and its stylesheet) is fetched
* only when a terminal tab is first opened (see chunk-loader.ts). The
* wrapper keeps the descriptor contract `(props) => ReactNode` — Sidebar
* calls it as a plain function.
*
* TerminalView's props are { scope, tabId, store } — `tabId` is NOT part of
* TabComponentProps (it carries `tab: SidebarTab` instead), so the
* descriptor maps it explicitly; a bare pass-through would leave tabId
* undefined and TerminalView's isAgentTabId(tabId) would crash on
* `undefined.startsWith` (regression-pinned in tests/lazy-chunk.spec.tsx).
*/
const LazyTerminal = lazyChunkComponent<TerminalViewProps>(
'terminal',
(mod) => mod.TerminalView as ComponentType<TerminalViewProps> | undefined,
)
/** The terminal view's props (mirror of TerminalView's own signature). */
interface TerminalViewProps {
scope: SessionScope
tabId: string
store: SidebarStore
}
/** How many UI-owned terminals may be open at once (agent-owned ones are uncapped). */
export const TERMINAL_LIMIT = 3
/** Optional per-registration builtin behavior (currently terminal title). */
export interface BuiltinTabOptions {
/** Returns the display title for newly opened terminal tabs. */
terminalTitle?: () => string
}
/** A client-side uuid for terminal tab identity (not shown in the UI). */
function terminalUuid(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `t${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`
}
/** Count UI-owned terminals (agent:` tabs excluded — they are the model's). */
function uiTerminalCount(state: SidebarState): number {
return allLeaves(state.bottomSplits)
.flatMap(leaf => leaf.tabs)
.filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length
}
/** The 6 built-in tab descriptors. */
export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): readonly TabDescriptor[] {
export function builtinTabs(): readonly TabDescriptor[] {
return [
{
id: 'editor',
@@ -246,116 +194,6 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
<SideChatView ctx={ctx} scope={scope} tab={tab} visible={visible} />
),
},
{
id: 'terminal',
title: () => t('terminal'),
description: () => t('guideDescTerminal'),
icon: terminalTabIcon,
order: 40,
available: (_ctx, _scope, state) => uiTerminalCount(state) < TERMINAL_LIMIT,
// Declarative settings: the model-facing terminal tools switch, the
// bottom-panel first-expansion auto-terminal switch, and the custom
// font family/size rows render under this card in the Side card
// settings page (the host gates the toolset on the tools one
// independently; the font rows apply live to every terminal).
settings: {
toggles: [{
key: 'agentTerminalTools',
title: () => t('settingsToolsTitle'),
desc: () => t('settingsToolsDesc'),
}, {
key: 'bottomPanelAutoTerminal',
title: () => t('settingsBottomTerminalTitle'),
desc: () => t('settingsBottomTerminalDesc'),
}, {
key: 'terminalShell',
type: 'text',
title: () => t('settingsShellTitle'),
desc: () => t('settingsShellDesc'),
placeholder: t('settingsShellPlaceholder'),
}, {
key: 'terminalShellArgs',
type: 'text',
title: () => t('settingsShellArgsTitle'),
desc: () => t('settingsShellArgsDesc'),
placeholder: t('settingsShellArgsPlaceholder'),
}, {
key: 'terminalFontFamily',
type: 'text',
title: () => t('settingsFontFamilyTitle'),
desc: () => t('settingsFontFamilyDesc'),
placeholder: t('settingsFontFamilyPlaceholder'),
}, {
key: 'terminalFontSize',
type: 'number',
title: () => t('settingsFontSizeTitle'),
desc: () => t('settingsFontSizeDesc'),
min: TERMINAL_FONT_SIZE_MIN,
max: TERMINAL_FONT_SIZE_MAX,
unit: 'px',
}],
},
createTab: (state) => {
const count = uiTerminalCount(state)
if (count >= TERMINAL_LIMIT) return null
return {
tab: {
id: `terminal:${terminalUuid()}`,
type: 'terminal',
title: options.terminalTitle?.() ?? t('terminal'),
},
// Keep the legacy counter advancing for compatibility with older
// persisted states; new ids no longer use it.
patch: { nextTerminal: state.nextTerminal + 1 },
}
},
component: ({ tab, scope, store }) => <LazyTerminal scope={scope} store={store} tabId={tab.id} />,
},
{
id: 'browser',
title: () => t('browser'),
description: () => t('guideDescBrowser'),
icon: browserTabIcon,
order: 50,
// Declarative settings: the sandbox escape hatch, the link-takeover
// MASTER switch, and the per-protocol takeover switches (http on /
// https off by default) render under this tab's row in the Side card
// settings page (the sandbox one is warned on).
settings: {
toggles: [{
key: 'browserNoSandbox',
title: () => t('settingsBrowserSandboxTitle'),
desc: () => t('settingsBrowserSandboxDesc'),
}, {
key: 'browserInterceptLinks',
title: () => t('settingsBrowserLinksTitle'),
desc: () => t('settingsBrowserLinksDesc'),
}, {
key: 'browserInterceptHttp',
title: () => t('settingsBrowserHttpTitle'),
desc: () => t('settingsBrowserHttpDesc'),
}, {
key: 'browserInterceptHttps',
title: () => t('settingsBrowserHttpsTitle'),
desc: () => t('settingsBrowserHttpsDesc'),
}, {
key: 'browserAllowedLoopback',
type: 'text',
title: () => t('settingsBrowserLoopbackTitle'),
desc: () => t('settingsBrowserLoopbackDesc'),
placeholder: t('settingsBrowserLoopbackPlaceholder'),
}],
},
createTab: (state) => ({
tab: {
id: `browser:${state.nextBrowser}`,
type: 'browser',
title: t('browser'),
},
patch: { nextBrowser: state.nextBrowser + 1 },
}),
component: (props) => <BrowserView {...props} />,
},
{
id: 'diff',
title: () => t('changes'),
+7 -7
View File
@@ -1,16 +1,16 @@
/**
* Lazy chunk loader for the client bundle. The heavy preview/terminal
* libraries (CodeMirror, xterm — the editor/terminal stacks, several MB)
* live in separate build-time bundles (`lib/client-<name>.js`) fetched only
* on first use of the feature that needs them, so startup downloads/parses
* only the ~1MB core bundle. (The office stack — Univer / docx-preview /
* Lazy chunk loader for the client bundle. The heavy preview libraries
* (CodeMirror's editor stack, mermaid — several MB) live in separate
* build-time bundles (`lib/client-<name>.js`) fetched only on first use of
* the feature that needs them, so startup downloads/parses only the ~1MB
* core bundle. (The office stack — Univer / docx-preview /
* pptx-renderer — is no longer bundled here: Office previews moved to the
* recommended office plugin, see plugins-viewers.ts.)
*
* How a chunk script works (see tsdown.config.ts chunkBundle):
*
* globalThis.__dshChunks__ = globalThis.__dshChunks__ || {};
* globalThis.__dshChunks__["terminal"] = (require) => { ...exports };
* globalThis.__dshChunks__["editor"] = (require) => { ...exports };
*
* The script registers its factory on a plugin-owned global registry (NOT
* through window.__ModuleLoader__.load — the module loader's import() only
@@ -50,7 +50,7 @@
* client.js); an edit that does land while a core HMR happens is caught by
* the ETag comparison on the next activation.
*/
export type ChunkName = 'terminal' | 'editor' | 'mermaid' | 'locale'
export type ChunkName = 'editor' | 'mermaid' | 'locale'
/** The module exports a chunk factory provides (namespace-ish record). */
export type ChunkExports = Record<string, unknown>
-9
View File
@@ -1,9 +0,0 @@
/**
* Lazy chunk entry: the interactive terminal (xterm + fit addon). Built as
* `lib/client-terminal.js` and registered under
* `dsh-better-sidebar/terminal` — fetched only when a terminal tab is first
* opened (see chunk-loader.ts and docs/plans/2026-08-12-lazy-chunks-design.md).
* Never import this module from the core bundle: it pulls xterm (and its
* stylesheet) into the startup path.
*/
export { TerminalView } from '../TerminalView.tsx'
-11
View File
@@ -58,17 +58,6 @@ export const IconUploadOutline16 = ({ size = 16, className }: IconProps) => (
</svg>
)
/**
* Pin glyph in the app's outline style (1.5px stroke, currentColor): a pushpin
* tilted to the lower-right. Used by the PinnedRail and the tab context menu's
* pin entry (v0.17.0+).
*/
export const IconPinOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.5 1.5 14.5 6.5 12.5 8.5 10 6 5.5 10.5 6 12 4.5 13.5 2.5 11.5 4 10 5.5 10.5 10 6 7.5 8.5 6.5Z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
</svg>
)
// ── File-viewer inventory glyphs (Side card settings page) ────────────────
/** Image viewer glyph: a picture frame with a sun and a mountain. */
+6 -24
View File
@@ -3,14 +3,15 @@
* preferences through the plugin's own fenced settings route, mounts the
* right sidebar portal (inside an error boundary so a rendering failure
* shows an error strip instead of a blank panel), and contributes the Side
* card settings section to the DSH Settings shell. Requires the runtime's slots and sessions services; the
* bundle itself is a module-table consumer only (react + ui-primitives +
* xterm, all provided or inlined).
* card settings section to the DSH Settings shell. Requires the runtime's
* slots and sessions services; the bundle itself is a module-table consumer
* only (react + ui-primitives, all provided or inlined — the editor and
* mermaid libraries arrive as lazy chunks).
*/
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import type { Context } from '../context-types.ts'
import { allLeaves, createSidebarStore, isAgentTabId } from './state.ts'
import { createSidebarStore } from './state.ts'
import { createBetterSidebarService, matchUrlTarget } from './service.ts'
import { revalidateChunksOnReactivate, setChunkModuleSystem } from './chunk-loader.ts'
import { registerBuiltins } from './builtins/index.ts'
@@ -184,30 +185,11 @@ export function apply(ctx: Context): void {
() => () => { nativeSurface.dispose(); service.setSurface(undefined) },
'dsh-better-sidebar: native right-Sidebar surface',
)
// Terminal tab titles use the host's effective shell name (e.g. bash/zsh)
// instead of "Terminal 1". Start with a safe fallback and replace it as
// soon as the host shell info resolves. Tabs created before the response
// arrives keep the fallback title, so also retitle any already-open UI
// terminal tabs that still carry it.
const fallbackTitle = t('terminal')
let terminalTitle = fallbackTitle
void api.shellGet().then(({ name }) => {
terminalTitle = name
const snapshot = service.getSnapshot()
if (snapshot.state === undefined) return
const tabs = allLeaves(snapshot.state.bottomSplits)
.flatMap(leaf => leaf.tabs)
for (const tab of tabs) {
if (tab.type === 'terminal' && !isAgentTabId(tab.id) && tab.title === fallbackTitle) {
service.updateTab(tab.id, { title: name })
}
}
}).catch(() => { /* keep fallback */ })
// Register the plugin's own built-in tabs and viewers through the same
// service (eating our own dogfood). The disposer unregisters them on
// fiber disposal (HMR-safe).
ctx.effect(
() => registerBuiltins(ctx, service, { terminalTitle: () => terminalTitle }),
() => registerBuiltins(ctx, service),
'dsh-better-sidebar: register built-in tabs and viewers',
)
try {
-177
View File
@@ -1,177 +0,0 @@
/**
* Cross-session pinned-terminal resolution (v0.17.0+).
*
* A pinned terminal tab lives in its HOME session's state (the only
* authoritative copy) — switching sessions never copies or projects it.
* The viewer session's TabBar renders the tabs OTHER sessions have pinned
* as VIRTUAL tabs appended to the first leaf's tab list, so the user sees
* them inline with their own tabs. Clicking a virtual tab activates it
* in-place: TerminalView connects to the home session's PTY via WebSocket
* (sessionId + tab query params resolve to the home PTY on the host side),
* so the terminal renders in the current workbench without jumping sessions.
*
* Visibility rule:
*
* | pin.scope | visible when |
* |-----------|--------------|
* | `global` | any session (cwd-independent) |
* | `workspace` | `viewer.cwd === tab.pin.homeCwd` (both undefined match; viewer.cwd unknown → conservative visible) |
*
* The "viewer.cwd unknown → visible" branch is intentional: during
* hydration the session summary may carry no cwd yet, and hiding pinned
* workspace tabs on first paint would flash them away. Once the cwd
* resolves, the next store notify re-runs the resolver with the real cwd.
*
* The viewer's OWN session is excluded: its pinned tabs are already on its
* own tab strip, so rendering them again as virtual tabs would double-show.
* Tabs whose `pin` field is missing or whose `type` is not `'terminal'` are
* ignored — only terminal tabs can be pinned.
*/
import type { SidebarState, SidebarTab, SplitNode } from './state.ts'
/** A pinned terminal surfaced to the viewer, paired with its home session. */
export interface PinnedTabEntry {
tab: SidebarTab
homeSessionId: string
}
/** A viewer's session identity for visibility resolution. */
export interface PinnedViewer {
sessionId: string
cwd: string | undefined
}
/** The home-session scope stored on a pinned virtual tab's meta, so
* TerminalView connects to the home session's PTY (not the viewer's). */
export interface PinnedHomeScope {
sessionId: string
cwd: string | undefined
/** The original tab id in the home session (TerminalView's `tab` param). */
tabId: string
}
const PINNED_META_KEY = '__pinnedHome'
const PINNED_VID_PREFIX = 'pinned:'
/** Whether a tab id is a pinned virtual id (prefixed). */
export function isPinnedVirtualId(tabId: string): boolean {
return tabId.startsWith(PINNED_VID_PREFIX)
}
/** Parse a pinned virtual id into its home session id and original tab id.
* Format: `pinned:<homeSessionId>:<originalTabId>` — session ids are UUIDs
* (no colons), so the first colon after the prefix delimits the session. */
export function parsePinnedVirtualId(tabId: string): { homeSessionId: string; tabId: string } {
const rest = tabId.slice(PINNED_VID_PREFIX.length)
const sep = rest.indexOf(':')
if (sep < 0) return { homeSessionId: rest, tabId: '' }
return { homeSessionId: rest.slice(0, sep), tabId: rest.slice(sep + 1) }
}
/** Extract the home scope from a pinned virtual tab's meta (undefined for
* regular tabs). */
export function getPinnedHomeScope(tab: SidebarTab): PinnedHomeScope | undefined {
const meta = tab.meta as Record<string, unknown> | undefined
return (meta?.[PINNED_META_KEY] as PinnedHomeScope | undefined) ?? undefined
}
/** Whether a tab is a pinned virtual tab (injected from another session). */
export function isPinnedVirtualTab(tab: SidebarTab): boolean {
return getPinnedHomeScope(tab) !== undefined
}
/** Create a virtual SidebarTab for a pinned entry. The virtual id is unique
* (prefixed with home session) to avoid collision with the viewer's own
* tab ids; the original id is stored in meta for TerminalView. */
export function createPinnedVirtualTab(entry: PinnedTabEntry): SidebarTab {
const { tab, homeSessionId } = entry
const home: PinnedHomeScope = {
sessionId: homeSessionId,
cwd: tab.pin?.homeCwd,
tabId: tab.id,
}
return {
...tab,
id: PINNED_VID_PREFIX + homeSessionId + ':' + tab.id,
meta: { ...(tab.meta as Record<string, unknown> | undefined ?? {}), [PINNED_META_KEY]: home },
}
}
/** Inject pinned virtual tabs into the first leaf of a split tree, and
* override that leaf's `active` when a pinned tab is activated. Returns
* the original tree when there are no pinned tabs and no active override. */
export function injectPinnedIntoTree(
tree: SplitNode,
pinned: readonly SidebarTab[],
activePinnedId: string | null,
): SplitNode {
if (pinned.length === 0 && activePinnedId === null) return tree
if (tree.kind === 'leaf') {
return {
...tree,
tabs: pinned.length > 0 ? [...tree.tabs, ...pinned] : tree.tabs,
active: activePinnedId ?? tree.active,
}
}
return {
...tree,
children: [
injectPinnedIntoTree(tree.children[0]!, pinned, activePinnedId),
...tree.children.slice(1),
],
}
}
/**
* Whether a pinned tab is visible to the viewer session. Conservative on
* unknown cwd: a `workspace` pin with no `homeCwd` is visible everywhere
* (the pin was set before the home session's cwd resolved), and a viewer
* whose cwd is unknown sees every workspace pin (avoids hydration flash).
*/
export function pinnedVisibleTo(tab: SidebarTab, viewer: PinnedViewer): boolean {
const pin = tab.pin
if (pin === undefined) return false
if (pin.scope === 'global') return true
// workspace scope
const home = pin.homeCwd
if (home === undefined) return true
if (viewer.cwd === undefined) return true
return viewer.cwd === home
}
/**
* Collect every pinned terminal visible to the viewer across ALL cached
* session states. Excludes the viewer's own session (those tabs are on its
* own strip). Order is stable: sessions in the cache's insertion order,
* tabs in tree order within each session — the order tabs were
* opened/pinned, so the rail never reorders between renders.
*/
export function collectPinnedTabs(
bySession: ReadonlyMap<string, SidebarState>,
viewer: PinnedViewer,
): PinnedTabEntry[] {
const entries: PinnedTabEntry[] = []
for (const [homeSessionId, state] of bySession) {
if (homeSessionId === viewer.sessionId) continue
collectFromTree(state.bottomSplits, homeSessionId, viewer, entries)
}
return entries
}
/** Walk one split tree depth-first, collecting visible pinned terminals. */
function collectFromTree(
node: SplitNode,
homeSessionId: string,
viewer: PinnedViewer,
out: PinnedTabEntry[],
): void {
if (node.kind === 'leaf') {
for (const tab of node.tabs) {
if (tab.type === 'terminal' && pinnedVisibleTo(tab, viewer)) {
out.push({ tab, homeSessionId })
}
}
return
}
for (const child of node.children) collectFromTree(child, homeSessionId, viewer, out)
}
-26
View File
@@ -10,7 +10,6 @@
*/
import type { api } from './api.ts'
import {
clampTerminalFontSize,
clampTitleBarStrip,
SIDEBAR_PREFS_DEFAULTS,
TITLE_BAR_SCHEMES,
@@ -23,7 +22,6 @@ export {
SIDEBAR_PREFS_DEFAULTS,
TITLE_BAR_SCHEMES,
TITLE_BAR_STRIP_DEFAULT,
clampTerminalFontSize,
clampTitleBarStrip,
}
export type { SidebarPrefs, TitleBarScheme }
@@ -47,27 +45,9 @@ export function parsePrefs(value: unknown): SidebarPrefs {
autoOpenJobs: typeof record.autoOpenJobs === 'boolean'
? record.autoOpenJobs
: SIDEBAR_PREFS_DEFAULTS.autoOpenJobs,
agentTerminalTools: typeof record.agentTerminalTools === 'boolean'
? record.agentTerminalTools
: SIDEBAR_PREFS_DEFAULTS.agentTerminalTools,
agentOpenTools: typeof record.agentOpenTools === 'boolean'
? record.agentOpenTools
: SIDEBAR_PREFS_DEFAULTS.agentOpenTools,
bottomPanelAutoTerminal: typeof record.bottomPanelAutoTerminal === 'boolean'
? record.bottomPanelAutoTerminal
: SIDEBAR_PREFS_DEFAULTS.bottomPanelAutoTerminal,
terminalFontFamily: typeof record.terminalFontFamily === 'string'
? record.terminalFontFamily
: SIDEBAR_PREFS_DEFAULTS.terminalFontFamily,
terminalShell: typeof record.terminalShell === 'string'
? record.terminalShell
: SIDEBAR_PREFS_DEFAULTS.terminalShell,
terminalShellArgs: typeof record.terminalShellArgs === 'string'
? record.terminalShellArgs
: SIDEBAR_PREFS_DEFAULTS.terminalShellArgs,
terminalFontSize: typeof record.terminalFontSize === 'number' && Number.isFinite(record.terminalFontSize)
? clampTerminalFontSize(record.terminalFontSize)
: SIDEBAR_PREFS_DEFAULTS.terminalFontSize,
editorExplorer: typeof record.editorExplorer === 'boolean'
? record.editorExplorer
: SIDEBAR_PREFS_DEFAULTS.editorExplorer,
@@ -102,9 +82,6 @@ export function parsePrefs(value: unknown): SidebarPrefs {
htmlViewerDefaultUnsafe: typeof record.htmlViewerDefaultUnsafe === 'boolean'
? record.htmlViewerDefaultUnsafe
: SIDEBAR_PREFS_DEFAULTS.htmlViewerDefaultUnsafe,
browserNoSandbox: typeof record.browserNoSandbox === 'boolean'
? record.browserNoSandbox
: SIDEBAR_PREFS_DEFAULTS.browserNoSandbox,
browserInterceptLinks: typeof record.browserInterceptLinks === 'boolean'
? record.browserInterceptLinks
: SIDEBAR_PREFS_DEFAULTS.browserInterceptLinks,
@@ -114,9 +91,6 @@ export function parsePrefs(value: unknown): SidebarPrefs {
browserInterceptHttps: typeof record.browserInterceptHttps === 'boolean'
? record.browserInterceptHttps
: SIDEBAR_PREFS_DEFAULTS.browserInterceptHttps,
browserAllowedLoopback: typeof record.browserAllowedLoopback === 'string'
? record.browserAllowedLoopback
: SIDEBAR_PREFS_DEFAULTS.browserAllowedLoopback,
tabsEnabled: booleanMapOf(record.tabsEnabled),
viewersEnabled: booleanMapOf(record.viewersEnabled),
pluginSettings: pluginSettingsMapOf(record.pluginSettings),
+5 -5
View File
@@ -13,8 +13,8 @@
* hardcode: single-instance (`() => type`), per-path (`tab => tab.path`),
* and per-id (`tab => tab.id` for diff tabs whose id is change-derived).
* `single: true` is sugar for `dedupeKey: () => id`.
* - `createTab` lets a descriptor own tab instantiation (the terminal
* builtin uses it to mint `terminal:<n>` ids and bump `nextTerminal`).
* - `createTab` lets a descriptor own tab instantiation (the side chat
* builtin uses it to mint one `sidechat:<threadId>` tab per thread).
* - `matchFileViewer` walks descriptors in priority order (desc, stable):
* per descriptor it tries `detect` first (when `head` bytes are given),
* then `exts`; `exts: []` is a catch-all that matches any path.
@@ -201,8 +201,8 @@ export interface TabDescriptor {
dedupeKey?: (tab: SidebarTab) => string | undefined
/**
* Custom tab creation (minting the `SidebarTab` and any state patches).
* Return `null` to refuse creation. The terminal builtin uses this to
* mint `terminal:<n>` ids and bump `nextTerminal`.
* Return `null` to refuse creation. The side chat builtin uses this to mint
* one tab per thread and to park a pending thread id in `meta`.
* When omitted, a default `{ id, type, title }` tab is created.
*/
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
@@ -969,7 +969,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
// native sidebar owns the right column).
const land = openTabInBottomPane
const reducer = (state: SidebarState): SidebarState => {
// Let the descriptor mint the tab (terminal's nextTerminal bump, etc.).
// Let the descriptor mint the tab (and any state patch it owns).
let tab: SidebarTab
let next: SidebarState
if (descriptor.createTab !== undefined) {
+37 -150
View File
@@ -444,41 +444,60 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
display: none;
}
/* An empty pane's welcome cards: a responsive grid of compact cards — as
/* An empty pane's welcome cards: a responsive grid of openable types — as
many per row as the pane width allows; rows that cannot fit the pane
height are clipped (never scrolled or squeezed smaller). */
height are clipped (never scrolled or squeezed smaller).
The card itself mirrors DSH's OWN guide capsule (the built-in tab types'
entry box, `ui-sidebar-right`'s GuideBody / `ui-sidebar-terminal`'s
TerminalGuide): one hairline in the l4 weight, a 24px radius, the layer-1
fill and the 56px floor. The two surfaces are the same thing — "pick what
opens here" — so they must not read as two different design systems.
The column floor is wider than before because a 24px radius needs the
room; the vertical gap keeps the capsule proportions. */
.paneEmptyCards {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
align-content: start;
gap: 8px;
gap: 12px;
padding: 12px;
overflow: hidden;
}
.paneCard {
display: flex;
flex-direction: column;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 6px;
justify-content: flex-start;
gap: 14px;
min-width: 0;
padding: 12px 8px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 8px;
min-height: 56px;
box-sizing: border-box;
padding: 14px 20px;
border: 0.5px solid var(--dsw-alias-border-l4);
border-radius: 24px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xxs-strong-12);
color: var(--dsw-alias-label-primary);
/* The host capsule sets a raw `font-size: 15px` with no token behind it;
the ladder's nearest step is the 14px medium row, so the label reads at
the same weight as DSH's own entry without inventing a literal. */
font: var(--dsw-font-s-strong-14);
cursor: pointer;
text-align: center;
text-align: left;
}
/* The glyph takes the host capsule's secondary ink; the label carries the
primary ink (same split as the host's guide entry). */
.paneCard > svg {
flex: none;
color: var(--dsw-alias-label-secondary);
}
.paneCard:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
border-color: var(--dsw-alias-border-l2);
border-color: var(--dsw-alias-border-l4);
}
.paneCard:disabled {
@@ -609,20 +628,6 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
color: var(--dsw-alias-label-primary);
}
/* ── Pinned-terminal tab (v0.17.0+) ───────────────────────────────────── */
/* Pinned virtual tabs (injected from other sessions) render inline in the
tab bar with a pin icon and a subtle tint to distinguish them from the
session's own tabs. Token-driven so skins follow. */
.pinnedTab {
color: var(--dsw-alias-label-tertiary);
font-style: italic;
}
.pinnedTab:hover {
color: var(--dsw-alias-label-secondary);
}
/* ── Explorer ──────────────────────────────────────────────────────────── */
.explorer {
@@ -1865,68 +1870,7 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
pointer-events: auto !important;
}
/* ── Terminal ──────────────────────────────────────────────────────────── */
.terminalWrap {
flex: 1;
min-height: 0;
position: relative;
display: flex;
flex-direction: column;
background: var(--dsw-alias-bg-base);
}
.terminal {
flex: 1;
min-height: 0;
padding: 6px 4px 6px 8px;
}
.terminal :global(.xterm) {
height: 100%;
}
.terminalBanner {
flex: none;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 3px 10px;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-state-warn-label);
background: var(--dsw-alias-state-warn-tertiary);
}
/* The attempted ws URL, shown when the connection failed (diagnosis). */
.terminalBannerUrl {
flex-basis: 100%;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
word-break: break-all;
opacity: 0.85;
}
/* The "Agent 正在等待 {needle}" wait banner: a warn-toned strip above the
terminal surface while the model blocks in terminal_wait_for; the needle
ellipsizes inline and the full text rides the title tooltip. */
.terminalWaitBanner {
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding: 3px 10px;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-state-warn-label);
background: var(--dsw-alias-state-warn-tertiary);
}
.terminalWaitNeedle {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Crash containment ─────────────────────────────────────────────────── */
.boundaryError {
position: fixed;
@@ -1946,6 +1890,8 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
overflow: auto;
}
/* The retry pill of the crash UI: the per-tab RenderBoundary block and the
lazy-chunk load failure both render it, so the terminal-era name stays. */
.terminalRetry {
flex: none;
padding: 1px 8px;
@@ -1962,65 +1908,6 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
color: var(--dsw-alias-label-primary);
}
/* The node-pty dependency failure banner (issue #140): a taller warn block
carrying the pasteable repair command (bash / cmd / PowerShell). */
.terminalDepsBanner {
flex: none;
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-state-warn-label);
background: var(--dsw-alias-state-warn-tertiary);
}
.terminalDepsTitle {
font: var(--dsw-font-xxs-strong-12);
color: var(--dsw-alias-state-warn-primary);
}
.terminalDepsHint {
opacity: 0.9;
}
.terminalDepsCommandRow {
display: flex;
align-items: flex-start;
gap: 8px;
}
/* The command the user pastes into a terminal / cmd. Selectable, wrapping,
and scrollable when long (Windows paths can get very long). */
.terminalRepairCommand {
flex: 1;
min-width: 0;
margin: 0;
padding: 6px 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
user-select: text;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-bg-layer-2);
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 4px;
max-height: 160px;
overflow: auto;
}
.terminalDepsNote {
opacity: 0.85;
}
.terminalDepsActions {
display: flex;
align-items: center;
gap: 8px;
}
/* The PER-TAB error strip: fills one tab's content area (unlike
.boundaryError, the full-height fixed rail for shell-level crashes), so a
crashing viewer/editor never hides the toggle cluster or the other tabs. */
+3 -8
View File
@@ -33,22 +33,17 @@ interface TabContentProps extends TabContentMemoKey {
/** Render the content of one tab (dispatched by type). */
export const TabContent = memo(function TabContent(props: TabContentProps) {
const { tab, effectiveTabId, sessionId, cwd, expanded, revealed, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props
const { tab, sessionId, cwd, expanded, revealed, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props
const scope = { sessionId, cwd }
const descriptor = ctx.get('betterSidebar')?.getTab(tab.type)
if (descriptor === undefined) {
return <OrphanedTab ctx={ctx} store={store} scope={scope} tab={tab} visible={visible} />
}
// For pinned virtual tabs, the tab descriptor's component (e.g. TerminalView)
// must receive the ORIGINAL tab id so it connects to the home session's PTY.
// The virtual tab's own id is a unique display key (prefixed); effectiveTabId
// restores the real id at the component boundary.
const componentTab = effectiveTabId !== undefined ? { ...tab, id: effectiveTabId } : tab
return createElement(
RenderBoundary,
{ className: css.tabBoundaryError },
createElement(descriptor.component, {
ctx, store, scope, tab: componentTab, visible, expanded, revealed,
ctx, store, scope, tab, visible, expanded, revealed,
onToggleDir, onReferenceFile, onOpenDiff, onSubagentJump,
}),
)
@@ -56,7 +51,7 @@ export const TabContent = memo(function TabContent(props: TabContentProps) {
/** The + menu options for the current state, driven by the tab registry.
* Hidden tabs (editor/diff) never show; `available` returning false shows
* a disabled row (e.g. terminal at capacity) instead of hiding the option.
* a disabled row instead of hiding the option.
* Tabs the user disabled in the side card settings are filtered out
* entirely — re-enabling them is the settings page's job. */
export function buildNewTabOptions(state: SidebarState, ctx: Context, scope: SessionScope): NewTabOption[] {
+14 -69
View File
@@ -1,20 +1,20 @@
/**
* Host-feed subscriptions (extracted from Sidebar.tsx, behavior identical):
* the WebSocket pushes (agent terminals, agent opens) and the session-list
* driven auto-activation triggers (subagent, background jobs, topology
* the WebSocket push (agent opens) and the session-list driven
* auto-activation triggers (subagent, background jobs, topology
* jump-back). All of it reacts to the host's live feeds for the CURRENT
* session; the sidebar shell only consumes the returned jump-back ref.
*/
import { useEffect, useRef } from 'react'
import type { Context, SidebarSessionList } from '../../context-types.ts'
import { mirrorAgentWaits, reconcileAgentTerminals, type SidebarStore } from '../state.ts'
import type { SidebarStore } from '../state.ts'
import { isNarrowWidth } from '../breakpoints.ts'
import { detectNewDirectSubagent } from '../subagent-detect.ts'
import { detectNewJob } from '../subagent-jobs.ts'
import { t } from '../locales.ts'
/** How many consecutive reconnect failures stop the agent-terminals push loop
* (mirror of the terminal view's own cap; the loop restarts on session switch). */
/** How many consecutive reconnect failures stop the agent-opens push loop
* (the loop restarts on session switch). */
const FAILURE_LIMIT = 3
/**
@@ -84,75 +84,13 @@ export function useHostFeeds(feeds: {
}): { subagentJumpRef: { current: string | undefined } } {
const { ctx, store, sessionList, sessionId } = feeds
/**
* Agent terminals push: subscribe to the host's live list of agent-owned
* terminals for this session (created by the model through the
* `terminal_create` tool). The host pushes a JSON array on every
* create / close / exit; the sidebar reconciles the list into tabs
* (id `agent:<uuid>`, title from the agent). A disconnected socket
* retries with a short backoff so a refresh or transient drop reattaches
* the same shell without losing the agent's work — capped like the
* terminal view's own reconnect loop, so a refused endpoint never spins
* forever (the next session switch restarts the loop).
* While the terminal tab type is disabled in settings, pushes add / remove
* no tabs — but the authoritative wait map is STILL mirrored (see the
* branch below); re-enabling makes the next push converge on both.
*/
useEffect(() => {
if (sessionId === undefined) return
let socket: WebSocket | null = null
let retry: number | undefined
let closed = false
let failures = 0
const connect = (): void => {
if (closed) return
const url = new URL('/sidebar/ws/agent-terminals', location.origin)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.search = new URLSearchParams({ sessionId }).toString()
socket = new WebSocket(url.toString())
socket.onmessage = (event) => {
if (typeof event.data !== 'string') return
try {
const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean; waiting?: { needle: string; since: number } | null }>
if (!Array.isArray(list)) return
store.reduce(s => ctx.get('betterSidebar')?.isTabEnabled('terminal') === false
// Terminal tabs are disabled: skip tab add/remove reconciliation,
// but STILL mirror the authoritative wait map — a wait resolving
// during the disabled window must clear its banner state, or a
// re-enabled terminal keeps a stale banner until the next
// unrelated push.
? mirrorAgentWaits(s, list)
: reconcileAgentTerminals(s, list))
} catch {
// Malformed push: ignore (the next push will reconcile).
}
}
socket.onclose = () => {
if (closed) return
failures += 1
if (failures >= FAILURE_LIMIT) {
console.error('[dsh-better-sidebar] agent-terminals connection failed; stopping reconnect loop', sessionId)
return
}
retry = window.setTimeout(connect, 2000)
}
socket.onerror = () => { socket?.close() }
}
connect()
return () => {
closed = true
window.clearTimeout(retry)
socket?.close()
}
}, [sessionId, ctx, store])
/**
* Agent opens push: subscribe to the host's `sidebar_open` requests for
* this session (the model actively opens a file / folder / HTTP(S) page).
* The host pushes one JSON request per open; the sidebar routes it to the
* matching built-in tab: a file opens in the editor (per-path dedupe), a
* folder opens a file window whose tree is rooted at the folder
* (`meta.dir`), and a URL opens in the browser tab. A disconnected socket
* (`meta.dir`), and a URL opens in DSH's own browser tab type. A disconnected socket
* retries with a short backoff (mirror of the agent-terminals loop): the
* host queue keeps undelivered requests and replays them on the first
* attach, so a refresh or a session switch lands the opens the model
@@ -183,7 +121,14 @@ export function useHostFeeds(feeds: {
const scope = { sessionId }
const title = typeof request.title === 'string' && request.title !== '' ? request.title : undefined
if (request.kind === 'url') {
ctx.get('betterSidebar')?.openTab({ type: 'browser', url: request.target, title }, scope)
// DSH's own browser tab type (absent from the plugin's registry
// since 0.20.0); an unregistered kind throws, so the agent-open
// path degrades to doing nothing rather than breaking the push.
try {
ctx.get('sidebarRight')?.openTab('browser', { params: { url: request.target } })
} catch {
// Host without a browser tab type: nothing to open into.
}
} else if (request.kind === 'folder') {
ctx.get('betterSidebar')?.openTab({
type: 'editor',
-147
View File
@@ -1,147 +0,0 @@
/**
* Inline pinned terminals (extracted from Sidebar.tsx, behavior identical):
* pinned tabs from OTHER sessions inject as VIRTUAL tabs into the first
* leaf of the bottom workbench's split tree, and the workbench actions are
* wrapped so virtual ids route back to the HOME session. The shell only
* consumes the augmented tree and the wrapped actions.
*/
import { useMemo, useState } from 'react'
import {
agentUuidOf, closeTab, isAgentTabId, leafWithTab, setTabPin,
type SidebarSnapshot, type SidebarStore, type SplitNode,
} from '../state.ts'
import {
collectPinnedTabs, createPinnedVirtualTab, getPinnedHomeScope, injectPinnedIntoTree,
isPinnedVirtualId, parsePinnedVirtualId, type PinnedTabEntry,
} from '../pinned.ts'
import type { WorkbenchActions } from '../split-pane.tsx'
import { api } from '../api.ts'
export function usePinnedTabs(input: {
store: SidebarStore
sessionId: string | undefined
cwd: string | undefined
snapshot: SidebarSnapshot
actions: WorkbenchActions
}): { augmentedTree: SplitNode | undefined; wrappedActions: WorkbenchActions } {
const { store, sessionId, cwd, snapshot, actions } = input
const state = snapshot.state
/**
* Inline pinned terminals (v0.17.0+): pinned tabs from OTHER sessions
* inject as VIRTUAL tabs into the first leaf of the bottom workbench's
* split tree. The virtual tabs have unique ids (prefixed with the home session)
* and carry the home scope in meta. Clicking a virtual tab sets
* `activePinnedTabId` — the augmented tree overrides the leaf's `active`
* so the pinned tab's content renders in-place (TerminalView connects to
* the home session's PTY via WS, no session jump).
*
* Closing/unpinning a virtual tab targets the HOME session via reduceFor
* (which doesn't notify — targeted opens must not re-render the active
* session). The `pinnedRevision` state bump forces the pinnedEntries
* useMemo to recompute after such an action.
*/
const [activePinnedTabId, setActivePinnedTabId] = useState<string | null>(null)
const [pinnedRevision, setPinnedRevision] = useState(0)
/**
* Cross-session pinned-tab collection. Recomputed on every store notify,
* session-list change, and pinned action (the revision bump covers
* reduceFor updates that don't notify). Only tabs from OTHER sessions —
* the viewer's own pinned tabs are already on its tab strip.
*/
const pinnedEntries: readonly PinnedTabEntry[] = useMemo(() => {
if (sessionId === undefined) return []
return collectPinnedTabs(store.getSessionStates(), { sessionId, cwd })
// snapshot / pinnedRevision are deliberate cache-busters (see the comment
// above): they recompute this memo for changes that carry no dep of their own.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [store, sessionId, cwd, snapshot, pinnedRevision])
/** Virtual SidebarTab objects for the pinned entries (stable references
* via useMemo so TabContent's memo comparator holds). */
const pinnedVirtualTabs = useMemo(
() => pinnedEntries.map(createPinnedVirtualTab),
[pinnedEntries],
)
/** The bottom workbench's split tree with pinned virtual tabs injected
* into the first leaf. When `activePinnedTabId` is set, that leaf's
* `active` is overridden so the pinned tab's content is visible. */
const augmentedTree = useMemo(
() => state === undefined ? undefined : injectPinnedIntoTree(state.bottomSplits, pinnedVirtualTabs, activePinnedTabId),
[state, pinnedVirtualTabs, activePinnedTabId],
)
/**
* Wrap the base actions to intercept pinned VIRTUAL tab ids (injected from
* other sessions). Regular tab ids pass through unchanged. Virtual ids are
* detected by the `pinned:` prefix and routed to the HOME session via
* reduceFor (which doesn't notify — the revision bump is the local signal).
*/
const wrappedActions = useMemo<WorkbenchActions>(() => {
if (pinnedVirtualTabs.length === 0) return actions
const closePinnedInHome = (virtualId: string): void => {
const { homeSessionId, tabId: originalId } = parsePinnedVirtualId(virtualId)
// The home cwd lives in the virtual tab's meta (snapshotted at pin
// time) — pass it to ptyClose so the host resolves the PTY in the
// correct workspace container (same scope the WS open used).
const vtab = pinnedVirtualTabs.find(t => t.id === virtualId)
const homeCwd = vtab !== undefined ? getPinnedHomeScope(vtab)?.cwd : undefined
store.reduceFor(homeSessionId, s => {
const leaf = leafWithTab(s.bottomSplits, originalId)
return leaf === undefined ? s : closeTab(s, leaf.id, originalId)
})
if (isAgentTabId(originalId)) {
void api.agentPtyClose(agentUuidOf(originalId)).catch(() => { /* already released */ })
} else {
void api.ptyClose({ sessionId: homeSessionId, ...(homeCwd !== undefined ? { cwd: homeCwd } : {}) }, originalId).catch(() => { /* already released */ })
}
if (activePinnedTabId === virtualId) setActivePinnedTabId(null)
setPinnedRevision(v => v + 1)
}
return {
...actions,
activateTab: (paneId, tabId) => {
if (isPinnedVirtualId(tabId)) {
setActivePinnedTabId(tabId)
} else {
setActivePinnedTabId(null)
actions.activateTab(paneId, tabId)
}
},
closeTab: (paneId, tabId) => {
if (isPinnedVirtualId(tabId)) {
closePinnedInHome(tabId)
} else {
actions.closeTab(paneId, tabId)
}
},
moveTabBefore: (payload, toPane, beforeTabId) => {
if (isPinnedVirtualId(payload.tabId)) return
if (isPinnedVirtualId(beforeTabId)) {
actions.moveTabToEdge(payload, toPane, 'center')
} else {
actions.moveTabBefore(payload, toPane, beforeTabId)
}
},
moveTabToEdge: (payload, toPane, zone) => {
if (isPinnedVirtualId(payload.tabId)) return
actions.moveTabToEdge(payload, toPane, zone)
},
pinTab: (tabId, scope) => {
if (isPinnedVirtualId(tabId)) {
if (scope !== null) return
const { homeSessionId, tabId: originalId } = parsePinnedVirtualId(tabId)
store.reduceFor(homeSessionId, s => setTabPin(s, originalId, null))
if (activePinnedTabId === tabId) setActivePinnedTabId(null)
setPinnedRevision(v => v + 1)
} else {
actions.pinTab?.(tabId, scope)
}
},
}
}, [actions, pinnedVirtualTabs, activePinnedTabId, store])
return { augmentedTree, wrappedActions }
}
+3 -11
View File
@@ -29,12 +29,6 @@ export interface WorkbenchActions {
/** Reorder within a pane (drop onto another tab inserts before it). */
moveTabBefore: (payload: TabDragPayload, toPane: string, beforeTabId: string) => void
resizeSplit: (splitId: string, index: number, deltaFrac: number) => void
/**
* Pin/unpin a terminal tab (v0.17.0+). The shell snapshots the home cwd
* at pin time; null clears the pin. Optional: when undefined the tab
* context menu hides the pin entry (legacy callers).
*/
pinTab?: (tabId: string, scope: 'workspace' | 'global' | null) => void
}
/** One divider: pointer-capture drag translating px deltas into fractions.
@@ -214,15 +208,13 @@ function LeafView(props: {
if (before === null) actions.moveTabToEdge(payload, leaf.id, 'center')
else actions.moveTabBefore(payload, leaf.id, before)
}}
onPinTab={actions.pinTab}
/>
{leaf.tabs.length > 0 ? (
/*
Every tab stays MOUNTED (inactive ones hidden), so switching tabs
never tears down the content: a terminal keeps its pty connection
and scrollback, an editor keeps its CodeMirror view and unsaved
draft, explorer/git keep their loaded data. The unmount (and the
terminal's close frame) happens only when a tab is truly closed.
never tears down the content: an editor keeps its CodeMirror view
and unsaved draft, explorer/git keep their loaded data. The unmount
happens only when a tab is truly closed.
*/
<div className={css.paneContent}>
{leaf.tabs.map(tab => (
+11 -244
View File
@@ -36,12 +36,6 @@ export interface SidebarTab {
/** Plugin-owned state (v0.12.0+): MUST be JSON-serializable — it is
* persisted with the layout and restored verbatim on reload. */
meta?: unknown
/** Pinned-terminal marker (v0.17.0+): a pinned terminal tab survives a
* session switch in its home session's state and surfaces in the
* PinnedRail of every session the scope allows. `homeCwd` is the cwd
* snapshot at pin time — a `workspace`-scoped pin is only visible to
* sessions whose cwd matches it. Absent = unpinned (legacy states). */
pin?: { scope: 'workspace' | 'global'; homeCwd?: string }
}
/** A tab group. */
@@ -67,9 +61,7 @@ export type SplitNode = SidebarLeaf | SidebarSplit
export interface SidebarState {
/** The pane receiving newly opened tabs (the last pane the user touched). */
activePane: string | null
/** Monotonic terminal tab counter (ids survive reloads). */
nextTerminal: number
/** Monotonic browser tab counter (ids survive reloads; mirrors nextTerminal). */
/** Monotonic browser tab counter (ids survive reloads). */
nextBrowser: number
/** Explorer expansion set (absolute directory paths). */
expanded: string[]
@@ -83,22 +75,8 @@ export interface SidebarState {
bottomOpen: boolean
/** The bottom panel's height (clamped to the contract range). */
bottomHeight: number
/**
* Whether the bottom panel has been expanded at least once in this
* session — the FIRST expansion tries to auto-open a terminal tab (gated
* on the bottomPanelAutoTerminal pref); later expansions never do.
*/
bottomOpenedOnce: boolean
/** The bottom workbench's split tree. */
bottomSplits: SplitNode
/**
* Live agent-terminal wait state (uuid → the wait the model currently
* blocks on in `terminal_wait_for`), mirrored from the host's
* agent-terminals push. Transient by design: sanitizeState never restores
* it, so a reload starts clean and the next push (sent immediately on WS
* attach) repopulates it.
*/
agentWaits: Record<string, { needle: string; since: number }>
}
export const TAB_MAX_WIDTH = 160
@@ -167,15 +145,12 @@ export function makeDefaultState(): SidebarState {
const bottomLeaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
return {
activePane: bottomLeaf.id,
nextTerminal: 1,
nextBrowser: 1,
expanded: [],
revealed: [],
bottomOpen: false,
bottomHeight: BOTTOM_DEFAULT,
bottomOpenedOnce: false,
bottomSplits: bottomLeaf,
agentWaits: {},
}
}
@@ -388,73 +363,11 @@ export function patchTab(
return changed ? { ...state, bottomSplits } : state
}
/**
* Set or clear the pin marker on one open tab (v0.17.0+). A pin marker is
* structural metadata (NOT display fields like title/path), so it walks
* the workbench's split tree exactly like {@link patchTab}. Passing `null` clears the pin
* (the tab stays open in its home session); passing a `{ scope, homeCwd }`
* object sets it. An unknown tab id is a strict no-op (same reference
* returned) so a stale pin request never churns the state or rewrites
* localStorage.
* @param state - the current per-session sidebar state.
* @param tabId - the tab to pin/unpin.
* @param pin - the pin marker to set, or null to clear.
* @returns the next state (or the same reference when the tab is missing
* or the pin marker is already the requested value).
*/
export function setTabPin(
state: SidebarState,
tabId: string,
pin: { scope: 'workspace' | 'global'; homeCwd?: string } | null,
): SidebarState {
let changed = false
const apply = (tab: SidebarTab): SidebarTab => {
// Pin is terminal-only (design YAGNI): a defensive guard keeps the
// invariant even if a caller accidentally targets a non-terminal tab.
if (tab.type !== 'terminal') return tab
// Idempotent: setting the same pin (deep-equal on scope + homeCwd) is a
// no-op so re-clicking the menu item never churns the state.
if (pin === null) {
if (tab.pin === undefined) return tab
} else if (
tab.pin !== undefined
&& tab.pin.scope === pin.scope
&& tab.pin.homeCwd === pin.homeCwd
) {
return tab
}
changed = true
const { pin: _omit, ...rest } = tab
return pin === null ? rest : { ...rest, pin }
}
const walk = (node: SplitNode): SplitNode => {
if (node.kind === 'leaf') {
// Find the target tab without rebuilding the whole array: only clone
// when the tab is actually here and apply changed it (idempotent
// no-ops return the same tab reference, so === holds).
const idx = node.tabs.findIndex(tab => tab.id === tabId)
if (idx < 0) return node
const oldTab = node.tabs[idx]!
const newTab = apply(oldTab)
if (newTab === oldTab) return node
const tabs = node.tabs.slice()
tabs[idx] = newTab
return { ...node, tabs }
}
const children = node.children.map(walk)
// Only rebuild if at least one child actually changed reference.
if (children.every((child, i) => child === node.children[i])) return node
return { ...node, children }
}
const bottomSplits = walk(state.bottomSplits)
return changed ? { ...state, bottomSplits } : state
}
/**
* Land a tab in the workbench's first pane — the plugin's own opens (its
* bottom-panel + menu, the auto-terminal, and every open when no native
* surface is installed): the plugin owns no right column any more (DSH's
* native sidebar is the right one), so the bottom workbench is the only tree.
* bottom-panel + menu, and every open when no native surface is installed):
* the plugin owns no right column any more (DSH's native sidebar is the
* right one), so the bottom workbench is the only tree.
* @param state - the session state.
* @param tab - the tab to land.
* @returns the next state, with the bottom panel open.
@@ -652,129 +565,6 @@ export function resizeSplitIn(state: SidebarState, splitId: string, index: numbe
return { ...state, [key]: resizeSplit(state[key], splitId, index, delta) }
}
/** Prefix marking a tab id as an agent-owned terminal (suffix is the uuid). */
export const AGENT_TAB_PREFIX = 'agent:'
/** Whether a tab id refers to an agent-owned terminal. */
export function isAgentTabId(tabId: string): boolean {
return tabId.startsWith(AGENT_TAB_PREFIX)
}
/** Extract the agent terminal uuid from an `agent:<uuid>` tab id. */
export function agentUuidOf(tabId: string): string {
return tabId.slice(AGENT_TAB_PREFIX.length)
}
/** Build the sidebar tab id for one agent terminal uuid. */
export function agentTabId(uuid: string): string {
return `${AGENT_TAB_PREFIX}${uuid}`
}
/** Shallow equality of two agent-wait maps (same keys, same needle+since). */
function sameAgentWaits(
a: SidebarState['agentWaits'] | undefined,
b: Record<string, { needle: string; since: number }>,
): boolean {
if (a === undefined) return Object.keys(b).length === 0
const aKeys = Object.keys(a)
if (aKeys.length !== Object.keys(b).length) return false
for (const key of aKeys) {
const av = a[key]
const bv = b[key]
if (av === undefined || bv === undefined) return false
if (av.needle !== bv.needle || av.since !== bv.since) return false
}
return true
}
/** Fold the pushed terminal snapshots into the authoritative wait map. */
function serverWaitsOf(
agentTerminals: ReadonlyArray<{ uuid: string; title: string; waiting?: { needle: string; since: number } | null }>,
): Record<string, { needle: string; since: number }> {
const serverWaits: Record<string, { needle: string; since: number }> = {}
for (const terminal of agentTerminals) {
if (terminal.waiting !== undefined && terminal.waiting !== null) {
serverWaits[terminal.uuid] = { needle: terminal.waiting.needle, since: terminal.waiting.since }
}
}
return serverWaits
}
/**
* Reconcile the sidebar's agent-terminal tabs with the host's live list.
* The host pushes the current list of agent terminals (created by the model
* through the `terminal_create` tool) over a dedicated WebSocket; this
* reducer mirrors that list into tabs: new uuids get a tab, vanished uuids
* lose theirs. The agent owns the lifetime — the user closing a tab sends a
* WS close frame that kills the pty, which fires a change, which converges
* the view. Idempotent: a no-op when the lists already match.
* @param state - the current per-session sidebar state.
* @param agentTerminals - the live agent terminal snapshots from the host.
* @returns the next state (or the same reference if no change was needed).
*/
export function reconcileAgentTerminals(
state: SidebarState,
agentTerminals: ReadonlyArray<{ uuid: string; title: string; waiting?: { needle: string; since: number } | null }>,
): SidebarState {
const existingTabs = allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs)
const existingAgentTabs = existingTabs.filter(tab => isAgentTabId(tab.id))
const existingUuids = new Set(existingAgentTabs.map(tab => agentUuidOf(tab.id)))
const serverUuids = new Set(agentTerminals.map(t => t.uuid))
const toAdd = agentTerminals.filter(t => !existingUuids.has(t.uuid))
// Pinned agent terminals (v0.17.0+) are EXEMPT from removal: the agent
// closed them or the pty exited, but the user pinned them so the tab
// stays as a disconnected surface. The xterm view's reconnect-failure
// banner is the user-visible "disconnected" signal (the design's M3
// convergence: no title suffix, no meta write — the tab keeps its uuid
// so a later reconcile push revives it if the agent reopens the same one).
const toRemove = existingAgentTabs.filter(tab => !serverUuids.has(agentUuidOf(tab.id)) && tab.pin === undefined)
// Mirror the live wait state from the push (authoritative: a vanished
// waiting field simply drops the entry). A waits-only change must still
// produce a new state — the tab add/remove no-change check alone would
// swallow banner updates.
const serverWaits = serverWaitsOf(agentTerminals)
if (toAdd.length === 0 && toRemove.length === 0 && sameAgentWaits(state.agentWaits, serverWaits)) return state
// Remove tabs whose uuids vanished from the server list (the agent closed
// them, or the pty exited and was reaped). Reuse closeTab's leaf cleanup.
let bottomSplits = state.bottomSplits
for (const tab of toRemove) {
const leaf = leafWithTab(bottomSplits, tab.id)
if (leaf !== undefined) {
bottomSplits = closeTab({ ...state, bottomSplits }, leaf.id, tab.id).bottomSplits
}
}
// Add tabs for new uuids (the agent created a terminal). They land in the
// workbench's landing pane via openTabInBottomPane; the next reconcile is a
// no-op for them.
let next: SidebarState = { ...state, bottomSplits }
for (const terminal of toAdd) {
const tab: SidebarTab = {
id: agentTabId(terminal.uuid),
type: 'terminal',
title: terminal.title,
}
next = openTabInBottomPane(next, tab)
}
return { ...next, agentWaits: serverWaits }
}
/**
* Mirror ONLY the authoritative agent-wait map from a push — no tab
* add/remove reconciliation. Used while the `terminal` tab type is disabled:
* the tab surface is frozen, but a wait that resolves during that window
* must still clear its banner state, or a re-enabled terminal keeps a stale
* banner/⏳ until some unrelated host event fires the next full reconcile.
* Idempotent: a no-op when the map already matches.
*/
export function mirrorAgentWaits(
state: SidebarState,
agentTerminals: ReadonlyArray<{ uuid: string; title: string; waiting?: { needle: string; since: number } | null }>,
): SidebarState {
const serverWaits = serverWaitsOf(agentTerminals)
if (sameAgentWaits(state.agentWaits, serverWaits)) return state
return { ...state, agentWaits: serverWaits }
}
// ── The per-session store ──────────────────────────────────────────────────
const STORAGE_PREFIX = 'dsh-sidebar:v1'
@@ -855,12 +645,10 @@ function loadState(sessionId: string): SidebarState {
export function sanitizeState(parsed: unknown): SidebarState | undefined {
if (parsed === null || typeof parsed !== 'object') return undefined
const record = parsed as Record<string, unknown>
if (typeof record.nextTerminal !== 'number' || !Number.isInteger(record.nextTerminal) || record.nextTerminal < 1) {
return undefined
}
// nextBrowser arrived in a later build; a missing or malformed value on an
// OLDER persisted state defaults to 1 so existing layouts keep loading
// (unlike nextTerminal, which is strict — it predates the v1 shape).
// A missing or malformed counter defaults to 1 rather than rejecting the
// whole layout: these counters only name NEW tabs, so restarting one at 1
// can at worst reuse a free id, while refusing the state would drop every
// open tab of a session that was persisted by an older build.
const nextBrowser = typeof record.nextBrowser === 'number' && Number.isInteger(record.nextBrowser) && record.nextBrowser >= 1
? record.nextBrowser
: 1
@@ -899,20 +687,12 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
// A stale duplicate pane id may have been re-ided; follow the rename so
// new tabs still land in the pane the user was using.
activePane,
nextTerminal: record.nextTerminal,
nextBrowser,
expanded: record.expanded as string[],
revealed: [],
bottomOpen,
bottomHeight,
// An older persisted state never expanded the bottom panel (the field
// arrived later): defaulting to false gives it the first-expansion
// auto-terminal exactly once after the upgrade.
bottomOpenedOnce: record.bottomOpenedOnce === true,
bottomSplits,
// The agent wait state is TRANSIENT (like revealed): never restored from
// storage — the host's first push after attach repopulates it.
agentWaits: {},
}
}
@@ -980,22 +760,9 @@ function sanitizePersistedTab(tab: unknown): SidebarTab | 'diff' | undefined {
...(typeof candidate.path === 'string' ? { path: candidate.path } : {}),
...(candidate.meta !== undefined ? { meta: candidate.meta } : {}),
}
// `pin` (v0.17.0+): a pinned-terminal marker. Whitelist-validate the
// shape so a hand-edited / corrupted pin never crashes the rail's
// resolver: an unknown scope or a non-string homeCwd drops the pin
// silently (the tab survives, just unpinned — the legacy behavior).
// Pin is terminal-only: a non-terminal tab carrying a persisted pin
// (e.g. from a hand-edited state) has it stripped here.
const pin = (candidate as Record<string, unknown>).pin
if (pin !== null && typeof pin === 'object' && !Array.isArray(pin) && result.type === 'terminal') {
const pinRecord = pin as Record<string, unknown>
if (pinRecord.scope === 'workspace' || pinRecord.scope === 'global') {
const homeCwd = pinRecord.homeCwd
result.pin = homeCwd === undefined || typeof homeCwd === 'string'
? { scope: pinRecord.scope, ...(typeof homeCwd === 'string' ? { homeCwd } : {}) }
: { scope: pinRecord.scope }
}
}
// A persisted `pin` (the removed pinned-terminal marker) is simply not
// copied into the rebuilt tab: only known fields survive, so a stale
// layout loads unpinned instead of failing validation.
return result
}
+2 -8
View File
@@ -3,7 +3,7 @@
*
* Kept as a pure module so the "what re-renders a tab cell" contract is unit
* testable: geometry/store re-renders of the Sidebar shell must NOT reconcile
* every mounted tab (xterm/CodeMirror/FileTree subtrees — issue #315), while
* every mounted tab (CodeMirror/FileTree subtrees — issue #315), while
* locale switches (localeRevision), tab-registry updates (tabsVersion), and
* a tab MOVING BETWEEN PANES (paneId — onOpenDiff closes over the pane id,
* and moveTab reuses the SAME tab object, so only the pane id identifies the
@@ -27,11 +27,6 @@ export interface TabContentMemoKey {
revealed: string[]
localeRevision: string
tabsVersion: number
/** When the tab is a pinned virtual tab (injected from another session),
* this overrides `tab.id` passed to the tab descriptor's component so
* TerminalView connects to the home session's PTY by the original id.
* Undefined for regular tabs (no override). */
effectiveTabId: string | undefined
}
/** True when the cell may skip a re-render (all render-affecting fields
@@ -49,7 +44,6 @@ export function tabContentCompare(prev: TabContentMemoKey, next: TabContentMemoK
prev.expanded === next.expanded &&
prev.revealed === next.revealed &&
prev.localeRevision === next.localeRevision &&
prev.tabsVersion === next.tabsVersion &&
prev.effectiveTabId === next.effectiveTabId
prev.tabsVersion === next.tabsVersion
)
}
-255
View File
@@ -1,255 +0,0 @@
/**
* Terminal font resolution: the user's custom font prefs (SidebarPrefs,
* configured under the terminal card's secondary settings) turned into the
* xterm options. Kept as a pure module (no DOM, no xterm) so the fallback
* chain and clamping are unit-testable without mounting a terminal.
*/
import { clampTerminalFontSize, type SidebarPrefs } from '../prefs-shared.ts'
/** The built-in fallback stack when neither the user nor the theme sets one. */
export const DEFAULT_TERMINAL_FONT_FAMILY = '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace'
/**
* Icon fonts appended to whichever base stack wins, so shell prompts that
* draw glyphs from the Nerd Font Private Use Areas resolve to a real glyph
* instead of the missing-glyph box (aka tofu).
*
* Why this is needed even though the OS "should" fall back automatically:
* prompt frameworks (starship, powerlevel10k, oh-my-posh) take their icons
* from the PUA. Chromium's implicit system fallback reliably covers the
* *BMP* PUA (U+E000–U+F8FF — e.g. the U+E0B0 powerline separator) but NOT
* the *supplementary-plane* PUA-B (U+F0000+) where Nerd Fonts v3 relocated
* the Material Design icon set. Naming the families explicitly makes the
* browser consult them per character, which covers both planes.
*
* Deliberately NOT listed: color-emoji families. Chromium routes genuine
* emoji code points through a dedicated emoji fallback path (which is why
* emoji already render), so naming them buys nothing here — while placing a
* color font ahead of the generic family risks capturing BMP symbols the
* prompt expects in monospace (U+26A0 ⚠, U+2714 ✔ …) and rendering them as
* wide color glyphs that break the cell grid.
*
* Ordering rationale: the symbols-only patches ship glyphs without Latin,
* so they can never hijack ASCII metrics — the safest first hop. The
* fully-patched distributions follow for users who installed one of those
* instead. Both the `… Mono` and proportional family names are listed
* because the Nerd Fonts installers register them as distinct families.
*
* These are strictly *appended*, never prepended: xterm derives its cell
* metrics from the first entry, so the base font must stay in front or the
* whole grid would be re-measured against an icon font.
*/
export const ICON_FONT_FALLBACKS: readonly string[] = [
// Symbols-only patches (glyph coverage without Latin) — ideal fallbacks.
'"Symbols Nerd Font Mono"',
'"Symbols Nerd Font"',
// Common fully-patched Nerd Font distributions.
'"Hack Nerd Font Mono"',
'"Hack Nerd Font"',
'"JetBrainsMono Nerd Font Mono"',
'"JetBrainsMono Nerd Font"',
'"FiraCode Nerd Font Mono"',
'"FiraCode Nerd Font"',
'"CaskaydiaCove Nerd Font Mono"',
'"CaskaydiaCove Nerd Font"',
'"SauceCodePro Nerd Font Mono"',
'"UbuntuMono Nerd Font Mono"',
'"Iosevka Nerd Font Mono"',
'"MesloLGS Nerd Font Mono"',
'"MesloLGS NF"',
]
/** The symbols-only patch count at the head of {@link ICON_FONT_FALLBACKS}
* (these carry no Latin glyphs, so they can never hijack ASCII metrics). */
const SYMBOLS_ONLY_COUNT = 2
/**
* CSS generic font families. A generic is a catch-all that always resolves,
* so icon fonts must be spliced in *before* the first one to stay reachable.
*/
const GENERIC_FAMILIES = new Set([
'monospace', 'serif', 'sans-serif', 'cursive', 'fantasy',
'system-ui', 'ui-monospace', 'ui-serif', 'ui-sans-serif', 'ui-rounded',
'math', 'emoji', 'fangsong',
])
/**
* CSS-wide keywords. These are only valid as an *entire* declaration value,
* so a stack cannot be appended to one.
*/
const CSS_WIDE_KEYWORDS = new Set(['inherit', 'initial', 'unset', 'revert', 'revert-layer'])
/** Normalize one family name for comparison: unquote, collapse runs of
* whitespace, casefold. */
function normalizeFamily(family: string): string {
return family
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase()
}
/**
* Split a CSS font-family stack on its top-level commas.
*
* Naive `split(',')` would corrupt quoted family names containing a comma
* and function values such as `var(--x, monospace)`, so quotes (with
* backslash escapes) and parentheses are tracked.
*
* @param stack - a CSS font-family stack.
* @returns the trimmed, non-empty family entries in source order.
*/
function splitFamilies(stack: string): string[] {
const entries: string[] = []
let buffer = ''
let quote: string | null = null
let depth = 0
for (let i = 0; i < stack.length; i += 1) {
const char = stack[i] as string
if (quote !== null) {
// Escapes are copied verbatim so `\"` does not close the quote.
if (char === '\\' && i + 1 < stack.length) {
buffer += char + (stack[i + 1] as string)
i += 1
continue
}
buffer += char
if (char === quote) quote = null
continue
}
if (char === '"' || char === "'") {
quote = char
buffer += char
continue
}
if (char === '(') depth += 1
else if (char === ')') depth = Math.max(0, depth - 1)
else if (char === ',' && depth === 0) {
entries.push(buffer)
buffer = ''
continue
}
buffer += char
}
entries.push(buffer)
return entries.map(entry => entry.trim()).filter(entry => entry !== '')
}
/**
* Append {@link ICON_FONT_FALLBACKS} to a CSS font-family stack, keeping
* the caller's own entries and order intact.
*
* - Families already named in `stack` are not duplicated (quote-, case- and
* whitespace-insensitive), so a user who already lists their Nerd Font
* keeps their exact priority.
* - The icon fonts are spliced in ahead of the *first* generic family
* (`monospace` etc.), because a generic always resolves: anything after it
* would never be consulted. A stack that OPENS with a generic is the one
* exception: only the symbols-only patches (no Latin) may precede it — a
* fully-patched Nerd Font there would become xterm's measuring base font
* and override the user/theme family precedence, so it is placed after the
* generic instead.
* - Idempotent — re-applying to an already-topped-up stack is a no-op, which
* matters because `TerminalView` diffs the resolved value against the live
* `term.options.fontFamily` before reflowing.
*
* @param stack - a CSS font-family stack (base font first).
* @returns the stack with icon fallbacks merged in.
*/
export function withIconFontFallbacks(stack: string): string {
const entries = splitFamilies(stack)
if (entries.length === 0) return ICON_FONT_FALLBACKS.join(', ')
const present = new Set(entries.map(normalizeFamily))
const notPresent = (family: string): boolean => !present.has(normalizeFamily(family))
const symbolsOnly = ICON_FONT_FALLBACKS.slice(0, SYMBOLS_ONLY_COUNT).filter(notPresent)
const patched = ICON_FONT_FALLBACKS.slice(SYMBOLS_ONLY_COUNT).filter(notPresent)
if (symbolsOnly.length === 0 && patched.length === 0) return entries.join(', ')
const firstGeneric = entries.findIndex(entry => GENERIC_FAMILIES.has(normalizeFamily(entry)))
const cut = firstGeneric === -1 ? entries.length : firstGeneric
// A fully-patched distribution ships Latin glyphs: placed ahead of a
// LEADING generic (cut === 0) it would become xterm's measuring/base font
// and override the user/theme family precedence. Only the symbols-only
// patches — which carry no Latin — may precede it; the patched fonts go
// after the generic (still reachable per character, never the base).
if (cut === 0) {
return [...symbolsOnly, entries[0]!, ...patched, ...entries.slice(1)].join(', ')
}
return [...entries.slice(0, cut), ...symbolsOnly, ...patched, ...entries.slice(cut)].join(', ')
}
/**
* Reduce one link of the base-family chain to a usable stack, or `''` when
* it cannot carry appended fallbacks so the next link should win.
*
* Rejecting CSS-wide keywords matters because the theme font arrives as a
* raw token value (`tokenValue('--ds-font-family-code')`); skins do set
* tokens to `initial`/`inherit`/`unset` (see `effectiveTokenValue` in
* `theme.ts`, which guards the color tokens for the same reason). Appending
* to such a value yields an invalid `font-family`, which the CSSOM discards
* silently — the terminal would lose the theme font *and* the icon fonts.
*/
function usableBase(value: string | undefined): string {
const trimmed = (value ?? '').trim()
if (trimmed === '') return ''
if (CSS_WIDE_KEYWORDS.has(trimmed.toLowerCase())) return ''
return trimmed
}
/**
* Guarantee the stack ends in a generic family, so a font the browser cannot
* resolve degrades to a monospace one.
*
* Without this a stack of unresolvable names (a misspelled family, or one only
* installed on the machine running the shell rather than the one running the
* browser) falls through to the browser's *standard* font, which is
* proportional — xterm then measures its cell from a proportional advance and
* the whole grid breaks, rather than merely losing the requested typeface.
*
* This does not sniff whether the named families exist (an explicit non-goal
* of the terminal font design): it only terminates the stack. A stack that
* already names a generic family is returned untouched, so a user who
* deliberately wrote one keeps it. Generics are matched unquoted only — a
* quoted `"monospace"` is a family *name*, not the keyword.
*
* @param stack - the resolved font-family value.
* @returns the stack, ending in a generic family.
*/
export function withMonospaceFallback(stack: string): string {
const families = splitFamilies(stack)
if (families.length === 0) return DEFAULT_TERMINAL_FONT_FAMILY
const only = families.length === 1 ? families[0] : undefined
if (only !== undefined && CSS_WIDE_KEYWORDS.has(only.toLowerCase())) return DEFAULT_TERMINAL_FONT_FAMILY
if (families.some((family) => GENERIC_FAMILIES.has(family.toLowerCase()))) return families.join(', ')
return `${families.join(', ')}, monospace`
}
/**
* Resolve the xterm font options for the given prefs.
*
* The base family keeps its existing precedence — user pref > theme code
* font > built-in stack. {@link withMonospaceFallback} then terminates the
* stack with a generic family so an unresolvable name degrades to a
* monospace instead of the proportional standard font, and finally
* {@link withIconFontFallbacks} tops it up so prompt icons resolve
* regardless of which base won.
*
* @param prefs - the current side card preferences.
* @param themeFontFamily - the app's theme code font (`--ds-font-family-code`
* token value, read live by the caller); undefined when the token is absent.
* @returns the `fontFamily` / `fontSize` xterm options.
*/
export function resolveTerminalFont(
prefs: SidebarPrefs,
themeFontFamily: string | undefined,
): { fontFamily: string; fontSize: number } {
const base = usableBase(prefs.terminalFontFamily)
|| usableBase(themeFontFamily)
|| DEFAULT_TERMINAL_FONT_FAMILY
return {
fontFamily: withIconFontFallbacks(withMonospaceFallback(base)),
fontSize: clampTerminalFontSize(prefs.terminalFontSize),
}
}
-194
View File
@@ -1,194 +0,0 @@
/**
* Terminal URL hyperlinks: xterm's `registerLinkProvider` is fed per-line
* link descriptors built from the pty stream, so URLs printed by any tool
* become hoverable / clickable spans.
*
* To stay out of the user's way (a terminal's primary interaction is text
* selection, not browsing), a click only activates when the user holds
* Ctrl (Win/Linux) or Cmd (mac) — a plain click is left for xterm's
* normal selection handling. Only http(s) URLs are dispatched to the
* browser; other schemes (`file://`, `mailto:`, `javascript:`, …) are
* underlined for visibility but rejected at activation, so a `file://`
* URL printed by a tool stays inert instead of being handed to
* `window.open`.
*
* Kept as a pure module (no xterm import) so the regex, the line
* scanner, the modifier gate and the scheme guard are unit-testable
* without mounting a terminal. `buildTerminalLinks` returns plain-object
* descriptors whose shape matches xterm's `ILink` minus the `activate`
* callback — the caller attaches `activate` (which closes over the
* event modifier check + `openTerminalUrl`) so this module never
* imports xterm types.
*/
/** Scheme allowlist for activation. Only http(s) is opened externally. */
const OPENABLE_SCHEMES = new Set(['http:', 'https:'])
/**
* The URL pattern used to scan each terminal line.
*
* A word boundary (`\b`) guards the leading scheme so `notttps://…` does
* not match. The character class excludes ASCII whitespace and the
* wrapping punctuation that shells commonly emit around URLs (quotes,
* angle brackets, brackets/braces, pipes, backslashes, backticks) so a
* URL printed as `"https://example.com"` or `<https://example.com>` does
* not drag the wrapping character into the link target.
*
* Carries the `g` flag so `findTerminalUrlsInLine` can iterate every
* match on a line; callers must reset `lastIndex` before reuse (the
* helper does this defensively).
*/
export const TERMINAL_URL_REGEX = /\bhttps?:\/\/[^\s"'<>[\]{}|\\^`]+/gi
/** A URL match in a terminal line: the text and its 0-based start offset. */
export interface TerminalUrlMatch {
/** 0-based start index of the URL within the line string. */
start: number
/** The matched URL text. */
text: string
}
/**
* Strip trailing closing parens that are not balanced by an opening paren
* earlier in the URL.
*
* The regex's character class keeps `(` and `)` (Wikipedia-style URLs
* carry real parens: `…/Python_(programming_language)`), so a URL wrapped
* in parens by a shell (`(https://example.com)`) captures the trailing
* `)`. Balanced pairs are kept intact; only the unmatched excess is
* trimmed, so:
* - `https://example.com)` → `https://example.com`
* - `https://en.wikipedia.org/wiki/Python_(programming_language)` → unchanged
* - `https://example.com/(` → unchanged (more openers than closers; the
* trailing `(` is the URL's own, not a wrapper)
*/
function trimUnbalancedTrailingParens(url: string): string {
let opens = 0
let closers = 0
for (let i = 0; i < url.length; i += 1) {
const ch = url[i]
if (ch === '(') opens += 1
else if (ch === ')') closers += 1
}
const excess = closers - opens
if (excess <= 0) return url
let end = url.length
let stripped = 0
while (end > 0 && url[end - 1] === ')' && stripped < excess) {
end -= 1
stripped += 1
}
return url.slice(0, end)
}
/**
* Find every http(s) URL in a line of terminal text, in source order
* with 0-based start offsets. A link provider maps these to buffer
* ranges for xterm's `registerLinkProvider`.
*
* Trailing unmatched closing parens are trimmed from each match (see
* {@link trimUnbalancedTrailingParens}), so a URL wrapped in parens by
* a shell opens without the trailing `)`, while Wikipedia-style URLs
* with balanced parens stay intact.
*
* Resets the regex's `lastIndex` before and after the scan so a
* previous partial iteration can't desynchronize a later one (the
* regex carries the `g` flag and is module-shared).
*/
export function findTerminalUrlsInLine(line: string): TerminalUrlMatch[] {
TERMINAL_URL_REGEX.lastIndex = 0
const matches: TerminalUrlMatch[] = []
let m: RegExpExecArray | null
while ((m = TERMINAL_URL_REGEX.exec(line)) !== null) {
const trimmed = trimUnbalancedTrailingParens(m[0])
if (trimmed.length > 0) {
matches.push({ start: m.index, text: trimmed })
}
}
TERMINAL_URL_REGEX.lastIndex = 0
return matches
}
/**
* A buffer range in xterm's coordinate system. xterm's cell `x` is
* 1-based (1..cols) and `y` is the buffer line number; the `end` is
* inclusive (the last cell covered by the link). Structurally
* compatible with xterm's `IBufferRange` so the caller can pass it
* straight through without importing xterm types here.
*/
export interface TerminalLinkRange {
start: { x: number; y: number }
end: { x: number; y: number }
}
/**
* A link descriptor: a buffer range plus the URL text. The caller
* (the link provider in `TerminalView`) attaches the `activate`
* callback, which closes over the modifier gate and `openTerminalUrl`.
*/
export interface TerminalLinkDescriptor {
range: TerminalLinkRange
text: string
}
/**
* Build link descriptors for every URL found in a terminal line.
*
* @param lineText - the line's text (e.g. from
* `IBufferLine.translateToString(true)`).
* @param lineNumber - the buffer line number xterm passed to
* `ILinkProvider.provideLinks` (used as the `y` of every range; URLs
* never span wrapped lines because each wrapped row is its own
* buffer line).
* @returns descriptors in source order; empty when the line has no URL.
*/
export function buildTerminalLinks(lineText: string, lineNumber: number): TerminalLinkDescriptor[] {
return findTerminalUrlsInLine(lineText).map(({ start, text: url }) => ({
range: {
// xterm's cell `x` is 1-based; `start` is 0-based from
// translateToString, so the first URL character sits at x = start + 1.
start: { x: start + 1, y: lineNumber },
// `end` is inclusive: the last URL character is at 0-based
// (start + url.length - 1), which is 1-based (start + url.length).
end: { x: start + url.length, y: lineNumber },
},
text: url,
}))
}
/**
* Decide whether a click on a terminal link should activate (open the URL).
*
* Mirrors what every modern terminal does (Windows Terminal, iTerm2, the
* VSCode integrated terminal): a plain click stays a text-selection
* gesture, and only Ctrl (Win/Linux) or Cmd (mac) hands the URL to the
* browser. The modifier is read off the activating `MouseEvent`, not
* tracked separately, so a key-up between hover and click never
* desynchronizes the gate.
*/
export function shouldActivateTerminalLink(event: MouseEvent): boolean {
return event.ctrlKey || event.metaKey
}
/**
* Open a URL matched in the terminal, with a scheme guard so a printed
* `file://` or anything that slipped past the regex cannot reach
* `window.open`. The URL is constructed via `new URL(...)` which throws
* on malformed input; the catch makes the function total so the xterm
* handler never throws into the terminal's event loop.
*
* @returns `true` when the URL was dispatched to `window.open`, `false`
* when it was rejected (bad URL, disallowed scheme, no `window`).
*/
export function openTerminalUrl(uri: string): boolean {
if (typeof window === 'undefined') return false
let url: URL
try {
url = new URL(uri)
} catch {
return false
}
if (!OPENABLE_SCHEMES.has(url.protocol)) return false
window.open(url.toString(), '_blank', 'noopener,noreferrer')
return true
}