feat: sidebar_open tool gates model-driven sidebar opens behind a global setting

New global Side card setting `agentOpenTools` (default off, 常规 group):
when enabled, one `sidebar_open` tool is injected into the model so it can
actively open a local file, a local folder (tree rooted there), or an
HTTP(S) page in the calling session's sidebar.

- host: AgentOpenRegistry + registerOpenTool (src/agent-opens.ts); the tool
  classifies http(s) URLs / stat-driven file/folder paths, refuses other
  schemes, resolves relative paths against the session cwd, and reports
  target-tab-disabled instead of silently no-oping. Gated like
  agentTerminalTools (register/drain on settings watch).
- delivery: /sidebar/ws/agent-opens push (consume-on-send queue, replay on
  attach); the tool result reports `delivered` so queued opens for
  non-active sessions are honest.
- client: Sidebar subscribes and routes pushes to editor (file), folder
  window (meta.dir, tree rooted at the folder — EditorHost), or browser
  (URL) tabs; settings row in 常规 + 20 locale dictionaries.
- tests: agent-opens registry/tool spec, smoke gating + WS route list,
  prefs/plugin-shape defaults, editor-host folder window, side-card rows.
- docs: design doc, AGENTS.md key lists + reference, README feature bullets.
This commit is contained in:
Menghuan1918
2026-08-24 01:19:43 +08:00
parent 36a4119253
commit 68640101e1
39 changed files with 952 additions and 27 deletions
+262
View File
@@ -0,0 +1,262 @@
/**
* The model-facing `sidebar_open` tool and its delivery registry.
*
* One tool lets the model actively open a local file, a local folder (as a
* tree rooted there), or an HTTP(S) page in the CALLING session's sidebar.
* Mirroring the agent-terminal tools, the tool binds to the calling agent's
* session through `exec.agent.session.id` — the model never passes a
* sessionId, and opens for non-active sessions are queued until that
* session's sidebar view is next connected.
*
* Delivery is a host→browser push over the dedicated `/sidebar/ws/agent-opens`
* endpoint (the same pattern as `/sidebar/ws/agent-terminals`): the registry
* keeps a per-session queue; a push is consumed on send (`delivered: true`
* means a sidebar view was attached at call time), otherwise the request
* stays queued and is replayed when a view for that session attaches.
*
* Conventions (per plugin-development-guide.md §3):
* C1 — parameters schema-validated before `execute` runs.
* C4 — `execute` returns one canonical JSON value; `render` is a separate
* pure text projection.
* C6 — `exec.signal.throwIfAborted()` before any fs work.
* C10 — no UI/transport vocabulary in the canonical value.
*/
import { randomUUID } from 'node:crypto'
import { stat } from 'node:fs/promises'
import { isAbsolute, join, resolve } from 'node:path'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolRunContext } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { Context } from './context-types.ts'
import type { SidebarPrefs } from './prefs-shared.ts'
/** What the model asked to open. */
export type AgentOpenKind = 'file' | 'folder' | 'url'
/** One pending/broadcast open request (the wire face over the push socket). */
export interface AgentOpenRequest {
/** Opaque id (host-generated; the client ignores it beyond dedupe/debug). */
id: string
/** The session whose sidebar the open is targeted at. */
sessionId: string
kind: AgentOpenKind
/** Absolute local path (file/folder) or http(s) URL. */
target: string
/** Tab title the client should use (basename / hostname / caller-supplied). */
title: string
}
/** One subscribed sidebar view's sender. */
type Sender = (request: AgentOpenRequest) => void
/**
* Per-session queue of open requests plus the connected sidebar views.
*
* Lifecycle: `enqueue` adds a request and — when at least one view for the
* session is attached — pushes it immediately and removes it from the queue
* (consume-on-send: a reconnect must never replay an open the client already
* applied, and the browser tab type has no per-URL dedupe, so replaying
* would mint duplicate tabs). With no attached view the request stays queued
* and `attach` replays it on connect. `drainAll` drops every queued request
* (the feature was turned off); `dispose` also drops every subscriber.
*/
export class AgentOpenRegistry {
private pending = new Map<string, AgentOpenRequest[]>()
private subscribers = new Map<string, Set<Sender>>()
/** Queue one open and deliver it immediately when a view is attached.
* @returns the request id and whether a connected view received it now. */
enqueue(sessionId: string, kind: AgentOpenKind, target: string, title: string): { id: string; delivered: boolean } {
const request: AgentOpenRequest = { id: randomUUID(), sessionId, kind, target, title }
const list = this.pending.get(sessionId) ?? []
list.push(request)
this.pending.set(sessionId, list)
const views = this.subscribers.get(sessionId)
if (views !== undefined && views.size > 0) {
for (const send of views) send(request)
this.pending.delete(sessionId)
return { id: request.id, delivered: true }
}
return { id: request.id, delivered: false }
}
/** Attach one sidebar view (replays queued requests; consume-on-send).
* @returns the disposer detaching the view. */
attach(sessionId: string, send: Sender): () => void {
let views = this.subscribers.get(sessionId)
if (views === undefined) {
views = new Set()
this.subscribers.set(sessionId, views)
}
views.add(send)
const queued = this.pending.get(sessionId) ?? []
if (queued.length > 0) {
for (const request of queued) send(request)
this.pending.delete(sessionId)
}
return () => {
const current = this.subscribers.get(sessionId)
current?.delete(send)
if (current !== undefined && current.size === 0) this.subscribers.delete(sessionId)
}
}
/** Drop every queued request (the feature was turned off mid-session). */
drainAll(): void {
this.pending.clear()
}
/** Drop the queue and every subscriber (plugin teardown). */
dispose(): void {
this.pending.clear()
this.subscribers.clear()
}
}
/** Extract the calling agent or throw the canonical "no agent" error. */
function requireAgent(agent: ToolRunContext['agent']): NonNullable<ToolRunContext['agent']> {
if (agent === undefined) {
throw new Error('sidebar_open requires an initiating agent')
}
return agent
}
/** Resolve the calling agent's session id (the queue scope + ownership key). */
function sessionIdOf(exec: ToolRunContext): string {
return requireAgent(exec.agent).session.id
}
/** Pure text projection helper (the canonical value is already structured). */
function textRender<T>(fn: (value: T) => string): (_args: unknown, value: unknown) => ContentBlock[] {
return (_args, value) => [{ type: 'text', text: fn(value as T) }]
}
/** Classify a raw target: http(s) URL or a local path (stat-driven). */
async function classifyTarget(raw: string, cwd: string): Promise<{ kind: AgentOpenKind; target: string; title: string }> {
if (/^https?:\/\//i.test(raw)) {
let parsed: URL
try {
parsed = new URL(raw)
} catch {
throw new Error(`"${raw}" is not a valid URL`)
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('sidebar_open only accepts http:// and https:// URLs')
}
const title = parsed.hostname !== '' ? parsed.hostname : raw
return { kind: 'url', target: raw, title }
}
// Any other scheme (`file:`, `javascript:`, `vscode:`, ...) is refused —
// the model must use the plain URL form or a local path. A Windows drive
// prefix (`C:\` / `C:/`) is a PATH, not a scheme, and must not be caught.
if (!isWindowsDrivePrefix(raw) && /^[a-z][a-z0-9+.-]*:/i.test(raw)) {
throw new Error('sidebar_open only accepts http:// and https:// URLs; use a local path for files')
}
const target = resolve(isAbsolute(raw) ? raw : join(cwd, raw))
let info
try {
info = await stat(target)
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === 'ENOENT') throw new Error(`"${raw}" does not exist (resolved to "${target}")`)
if (code === 'EACCES' || code === 'EPERM') throw new Error(`"${target}" is not readable`)
throw new Error(`cannot open "${target}": ${error instanceof Error ? error.message : String(error)}`)
}
const title = basenameOf(target)
return { kind: info.isDirectory() ? 'folder' : 'file', target, title: title === '' ? raw : title }
}
/** The last path segment (mirror of the client's FileTree baseName). */
function basenameOf(path: string): string {
const trimmed = path.replace(/[\\/]+$/, '')
const at = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
return at === -1 ? trimmed : trimmed.slice(at + 1)
}
/** Whether a raw target starts with a Windows drive prefix (`C:\` / `C:/`). */
function isWindowsDrivePrefix(raw: string): boolean {
return /^[a-zA-Z]:[\\/]/.test(raw)
}
/**
* Register the `sidebar_open` tool against the host tool registry. The tool
* is gated by the side-card setting `agentOpenTools` (the caller registers
* and unregisters it); `readPrefs` supplies the live prefs so a disabled
* target tab type (editor/browser) is reported to the model instead of
* silently no-oping on the client. `resolveCwd` threads the calling
* session's live cwd so relative paths resolve the same way the sidebar's
* own routes do.
* @param ctx - host plugin context (carries the tools service).
* @param registry - the open-request registry (per-session queue + views).
* @param resolveCwd - live cwd resolver for one session id.
* @param readPrefs - live resolved side card prefs (for tab enable gates).
* @returns a disposer that unregisters the tool.
*/
export function registerOpenTool(
ctx: Context,
registry: AgentOpenRegistry,
resolveCwd: (sessionId: string) => string,
readPrefs: () => SidebarPrefs,
): () => void {
return ctx.tools.register(defineTool({
name: 'sidebar_open',
description:
'Open a local file, a local folder, or an HTTP(S) page in the sidebar of the calling conversation. '
+ 'A file opens in the sidebar editor (per-path dedupe: an already-open file is focused); a folder opens a file window whose tree is rooted at that folder; '
+ 'a URL opens in the sidebar browser (sandboxed iframe). '
+ 'The panel auto-expands for content opens and the tab title defaults to the file/folder name or the URL hostname. '
+ 'The path may be absolute or relative to the session working directory. '
+ 'The open lands in the CALLING session\'s sidebar: while that session\'s sidebar view is not connected '
+ '(e.g. the session is not the active one), the open is queued and delivered when the session sidebar is next shown — '
+ 'the result reports `delivered` so you know whether it is visible right now. '
+ 'The side card setting "model opens files/folders/pages in the sidebar" must be on, and the target tab type must be enabled in that session\'s settings.',
parameters: {
target: {
type: 'string',
required: true,
description: 'Absolute or session-cwd-relative local path, or an http:// / https:// URL.',
},
title: {
type: 'string',
description: 'Optional tab title (defaults to the file/folder name or the URL hostname).',
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, description: 'What was opened: file | folder | url.' },
target: { type: 'string', required: true, description: 'The absolute path or URL the open was requested for.' },
title: { type: 'string', required: true, description: 'The tab title used (provided title, basename, or hostname).' },
delivered: {
type: 'boolean',
required: true,
description: 'Whether the open was pushed to a connected sidebar at call time (false = queued until the session sidebar is next shown).',
},
},
},
render: textRender((v: { kind: AgentOpenKind; target: string; title: string; delivered: boolean }) =>
v.delivered
? `Opened ${v.kind} "${v.title}" (${v.target}) in the sidebar.`
: `Requested opening ${v.kind} "${v.title}" (${v.target}) in the sidebar — the session sidebar is not connected yet, so the open is queued and will appear when it is next shown.`,
),
},
execute: async (args: { target: string; title?: string }, exec: ToolRunContext) => {
exec.signal.throwIfAborted()
const sessionId = sessionIdOf(exec)
const cwd = resolveCwd(sessionId)
const { kind, target, title: defaultTitle } = await classifyTarget(args.target, cwd)
// A disabled target tab type would make the client no-op the open:
// report the real cause to the model instead of a silent success.
const prefs = readPrefs()
const tab = kind === 'url' ? 'browser' : 'editor'
if (prefs.tabsEnabled[tab] === false) {
throw new Error(`the built-in ${tab} tab is disabled in the side card settings; ask the user to enable it (or disable this tool)`)
}
const title = args.title !== undefined && args.title.trim() !== '' ? args.title : defaultTitle
const { delivered } = registry.enqueue(sessionId, kind, target, title)
return { kind, target, title, delivered }
},
}))
}
+17 -6
View File
@@ -103,6 +103,11 @@ export function EditorHost(props: {
const { ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile } = props
const path = tab.path ?? ''
const title = tab.title
// A folder window: the model's `sidebar_open` (or any caller) opens a
// directory as an editor tab carrying `meta.dir: true` with the directory
// as its path. It renders the file tree rooted at that folder instead of
// the viewer loading flow (a directory is not a file).
const isDir = metaOf(tab).dir === true
const [load, setLoad] = useState<EditorLoad>({ status: 'loading' })
// Reactive prefs read: flipping editorExplorer re-renders this tab with no
@@ -123,9 +128,12 @@ export function EditorHost(props: {
const openWithConfig = useMemo(() => parseOpenWithConfig(editorBlob.openWith), [editorBlob])
const openWithTargets = useMemo(() => resolveOpenWithTargets(openWithConfig), [openWithConfig])
// A path-less tab shows the empty-state hint in merged mode — and in split
// mode it is the standalone explorer (tree-only, see the render below).
// mode it is the standalone explorer (tree-only, see the render below). A
// folder tab is a folder window in BOTH modes: the tree rooted at the
// folder, no editor chrome.
const showEmpty = path === ''
const treeOnly = showEmpty && !inPlace
const folderRoot = isDir ? path : undefined
/**
* Open a file from THIS window (tree click / search row / path input):
@@ -258,8 +266,9 @@ export function EditorHost(props: {
// fresh viewer re-registers its own.
setToolbar(null)
// The seeded home tab (no path) never loads a viewer — the empty-state
// hint renders until the user picks a file.
if (showEmpty) return
// hint renders until the user picks a file. A folder tab never loads a
// viewer either — its tree is rooted at the folder.
if (showEmpty || isDir) return
let cancelled = false
// Aborts the matched viewer's `load` when the editor tears down (tab
// closed, path changed, session switched) or re-matches the viewer.
@@ -311,7 +320,7 @@ export function EditorHost(props: {
}
apply(planFirstMatch(ctx.betterSidebar?.matchFileViewer(path), mediaUrlOf))
return () => { cancelled = true; controller.abort() }
}, [scope.sessionId, scope.cwd, path, ctx, showEmpty])
}, [scope.sessionId, scope.cwd, path, ctx, showEmpty, isDir])
const treeOpen = treeOpenOf(tab)
/** Persist the panel flag on the tab (survives reloads with the layout). */
@@ -324,13 +333,15 @@ export function EditorHost(props: {
// Split mode: the path-less window IS the standalone explorer — the tree
// panel fills the whole tab (search + FileTree, full form), no editor
// chrome. File opens land in new per-path tabs through openFile above.
if (treeOnly) {
// A folder window (meta.dir, any mode) renders the SAME surface rooted
// at the folder instead of the session cwd.
if (treeOnly || folderRoot !== undefined) {
return (
<div className={css.editor}>
<TreePanel
full
sessionId={scope.sessionId}
cwd={scope.cwd}
cwd={folderRoot ?? scope.cwd}
expanded={expanded}
onToggle={onToggleDir}
onOpenFile={openFile}
+11
View File
@@ -913,6 +913,17 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
onChange={(next) => { applyPref({ interceptOpenPath: next }) }}
/>
</div>
<div className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsOpenToolsTitle')}</span>
<span className={css.desc}>{t('settingsOpenToolsDesc')}</span>
</span>
<Switch
label={t('settingsOpenToolsTitle')}
checked={prefs.agentOpenTools}
onChange={(next) => { applyPref({ agentOpenTools: next }) }}
/>
</div>
<div className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsTitleBarTitle')}</span>
+72
View File
@@ -463,6 +463,78 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
}
}, [sessionId, 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
* 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
* queued while no view was connected.
* While the side-card setting is off, pushes are ignored as a defensive
* gate — the host already unregisters the tool and drains the queue.
*/
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-opens', 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 request = JSON.parse(event.data) as { kind?: unknown; target?: unknown; title?: unknown }
if (request === null || typeof request !== 'object') return
if (request.kind !== 'file' && request.kind !== 'folder' && request.kind !== 'url') return
if (typeof request.target !== 'string' || request.target === '') return
if (store.getPrefs().agentOpenTools !== true) return
const scope = { sessionId }
const title = typeof request.title === 'string' && request.title !== '' ? request.title : undefined
if (request.kind === 'url') {
ctx.betterSidebar?.openTab({ type: 'browser', url: request.target, title }, scope)
} else if (request.kind === 'folder') {
ctx.betterSidebar?.openTab({
type: 'editor',
title,
path: request.target,
id: `editor:${request.target}`,
meta: { dir: true },
}, scope)
} else {
ctx.betterSidebar?.openFile(scope, request.target, title)
}
} catch {
// Malformed push: ignore (the next push carries its own request).
}
}
socket.onclose = () => {
if (closed) return
failures += 1
if (failures >= FAILURE_LIMIT) {
console.error('[dsh-better-sidebar] agent-opens 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, store])
/**
* Subagent auto-activation: the moment the current conversation spawns its
* FIRST direct subagent (a 0 → N transition on the list feed), the "auto
+2
View File
@@ -186,6 +186,8 @@ export const ar: Record<string, string> = {
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'فتح ملفات المحادثة في الشريط الجانبي',
settingsOpenPathDesc: 'فتح روابط الملفات في المحادثة (صفوف الأدوات، الملفات الناتجة، الإشارات) في محرّر الشريط الجانبي بدلاً من التطبيق الافتراضي للنظام',
settingsOpenToolsTitle: 'حقن أداة فتح الشريط الجانبي للنموذج',
settingsOpenToolsDesc: 'عند التفعيل، يمكن للنموذج فتح الملفات والمجلدات وصفحات HTTP(S) في الشريط الجانبي عبر أداة sidebar_open (معطّل افتراضياً)',
settingsTitleBarTitle: 'وضع توافق الموضع',
settingsTitleBarDesc: 'اختر نظام توافق الشريط العلوي: كشف تلقائي (افتراضي، محافظ) / ويب DSH الرسمي / أغلفة سطح المكتب المعروفة / مخصّص (مسافة الإزاحة + CSS مخصّص)',
settingsTitleBarStripTitle: 'مسافة الإزاحة',
+2
View File
@@ -171,6 +171,8 @@ export const de: Record<string, string> = {
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Chat-Dateien in der Seitenleiste öffnen',
settingsOpenPathDesc: 'Dateilinks im Chat (Werkzeugzeilen, erstellte Dateien, Erwähnungen) werden im Seitenleisten-Editor geöffnet statt in der System-Standardanwendung',
settingsOpenToolsTitle: 'Seitenleisten-Öffnungswerkzeug für das Modell bereitstellen',
settingsOpenToolsDesc: 'Wenn aktiviert, kann das Modell über das sidebar_open-Werkzeug Dateien, Ordner und HTTP(S)-Seiten in der Seitenleiste öffnen (standardmäßig deaktiviert)',
settingsTitleBarTitle: 'Kompatibilitätsmodus der Position',
settingsTitleBarDesc: 'Kompatibilitätsschema der Titelleiste wählen: automatische Erkennung (Standard, konservativ) / offizielles DSH-Web / bekannte Desktop-Shells / benutzerdefiniert (Verschiebung + benutzerdefiniertes CSS)',
settingsTitleBarStripTitle: 'Verschiebungsabstand',
+2
View File
@@ -178,6 +178,8 @@ export const fr: Record<string, string> = {
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Ouvrir les fichiers de discussion dans la barre latérale',
settingsOpenPathDesc: 'À la place de l’application par défaut du système, ouvrir dans l’éditeur de la barre latérale les liens vers les fichiers du chat (lignes d’outils, listes de produits, mentions de fichiers)',
settingsOpenToolsTitle: 'Injecter l\'outil d\'ouverture latérale pour le modèle',
settingsOpenToolsDesc: 'Une fois activé, le modèle peut ouvrir des fichiers, dossiers et pages HTTP(S) dans la barre latérale via l\'outil sidebar_open (désactivé par défaut)',
settingsTitleBarTitle: 'Mode de compatibilité de position',
settingsTitleBarDesc: 'Choisissez le schéma de compatibilité de la barre de titre : détection automatique (par défaut, conservateur) / Web officiel DSH / coquilles de bureau connues / schéma personnalisé (distance de décalage + CSS personnalisé)',
settingsTitleBarStripTitle: 'Distance de décalage',
+2
View File
@@ -185,6 +185,8 @@ export const hi: Record<string, string> = {
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'चैट फ़ाइलें साइडबार में खोलें',
settingsOpenPathDesc: 'चैट में फ़ाइल लिंक (टूल पंक्ति, उत्पादित फ़ाइलें, उल्लेख) क्लिक करने पर सिस्टम डिफ़ॉल्ट ऐप के बजाय साइडबार एडिटर में खोलें',
settingsOpenToolsTitle: 'मॉडल के लिए साइडबार ओपन टूल इंजेक्ट करें',
settingsOpenToolsDesc: 'चालू होने पर, मॉडल sidebar_open टूल से साइडबार में फ़ाइलें, फ़ोल्डर और HTTP(S) पेज खोल सकता है (डिफ़ॉल्ट रूप से बंद)',
settingsTitleBarTitle: 'स्थिति संगतता मोड',
settingsTitleBarDesc: 'टाइटल-बार संगतता योजना चुनें: ऑटो-डिटेक्ट (डिफ़ॉल्ट, रूढ़िवादी) / DSH आधिकारिक वेब / ज्ञात डेस्कटॉप शेल / कस्टम (शिफ्ट दूरी + कस्टम CSS)',
settingsTitleBarStripTitle: 'शिफ्ट दूरी',
+2
View File
@@ -183,6 +183,8 @@ export const id: Record<string, string> = {
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Buka berkas obrolan di sidebar',
settingsOpenPathDesc: 'Buka tautan berkas di obrolan (baris alat, berkas yang dihasilkan, mention) di editor sidebar alih-alih aplikasi default sistem',
settingsOpenToolsTitle: 'Suntik alat buka sidebar untuk model',
settingsOpenToolsDesc: 'Saat diaktifkan, model dapat membuka file, folder, dan halaman HTTP(S) di sidebar melalui alat sidebar_open (nonaktif secara default)',
settingsTitleBarTitle: 'Mode kompatibilitas posisi',
settingsTitleBarDesc: 'Pilih skema kompatibilitas title-bar: auto-detect (default, konservatif) / Web resmi DSH / shell desktop yang dikenal / kustom (jarak geser + CSS kustom)',
settingsTitleBarStripTitle: 'Jarak geser',
+2
View File
@@ -176,6 +176,8 @@ export const it: Record<string, string> = {
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Apri i file della chat nella barra laterale',
settingsOpenPathDesc: 'Apre i collegamenti ai file nella chat (righe di strumenti, file prodotti, menzioni) nell’editor della barra laterale invece dell’app predefinita di sistema',
settingsOpenToolsTitle: 'Iniettare lo strumento di apertura della barra laterale per il modello',
settingsOpenToolsDesc: 'Se attivato, il modello può aprire file, cartelle e pagine HTTP(S) nella barra laterale tramite lo strumento sidebar_open (disattivato per impostazione predefinita)',
settingsTitleBarTitle: 'Modalità di compatibilità della posizione',
settingsTitleBarDesc: 'Scelga lo schema di compatibilità della barra del titolo: rilevamento automatico (predefinito, prudente) / DSH Web ufficiale / shell desktop note / personalizzato (distanza di scorrimento + CSS personalizzato)',
settingsTitleBarStripTitle: 'Distanza di scorrimento',
+2
View File
@@ -185,6 +185,8 @@ export const ja: Record<string, string> = {
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'チャット内ファイルをサイドバーで開く',
settingsOpenPathDesc: 'チャット内のファイルリンク(ツール行、産物リスト、ファイル言及)クリック時に、システム既定アプリではなくサイドバーエディターで開く',
settingsOpenToolsTitle: 'モデルにサイドバー開くツールを注入',
settingsOpenToolsDesc: 'オンにすると、モデルは sidebar_open ツールでサイドバーにファイル・フォルダー・HTTP(S) ページを開ける(デフォルトオフ)',
settingsTitleBarTitle: '位置互換モード',
settingsTitleBarDesc: 'タイトルバー互換スキームを選択:自動検出(デフォルト、控えめ)/ DSH 公式 Web / 既知のデスクトップシェル / カスタムスキーム(下移動距離 + カスタム CSS)',
settingsTitleBarStripTitle: '下移動距離',
+2
View File
@@ -177,6 +177,8 @@ export const ko: Record<string, string> = {
settingsWidthSuffix: '%',
settingsOpenPathTitle: '채팅 영역 파일을 사이드바에서 열기',
settingsOpenPathDesc: '채팅에서 파일 링크(도구 행, 산출물 목록, 파일 언급)를 클릭하면 사이드바 편집기에서 열고, 시스템 기본 앱은 호출하지 않습니다',
settingsOpenToolsTitle: '모델에 사이드바 열기 도구 주입',
settingsOpenToolsDesc: '켜면 모델이 sidebar_open 도구로 사이드바에서 파일·폴더·HTTP(S) 페이지를 열 수 있습니다(기본 꺼짐)',
settingsTitleBarTitle: '위치 호환 모드',
settingsTitleBarDesc: '상단 바 호환 방식을 선택하세요: 자동 감지(기본, 보수적) / DSH 공식 Web / 알려진 데스크톱 셸 / 사용자 지정 방식(하강 거리 + 사용자 지정 CSS)',
settingsTitleBarStripTitle: '하강 거리',
+2
View File
@@ -183,6 +183,8 @@ export const nl: Record<string, string> = {
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Chatbestanden in de zijbalk openen',
settingsOpenPathDesc: 'Bestandslinks in de chat (toolrijen, geproduceerde bestanden, vermeldingen) openen in de zijbalk-editor in plaats van de standaard systeemapp',
settingsOpenToolsTitle: 'Zijbalk-openen-tool voor het model injecteren',
settingsOpenToolsDesc: 'Indien ingeschakeld kan het model bestanden, mappen en HTTP(S)-pagina\'s in de zijbalk openen via de sidebar_open-tool (standaard uit)',
settingsTitleBarTitle: 'Positiecompatibiliteitsmodus',
settingsTitleBarDesc: 'Kies het titelbalk-compatibiliteitsschema: auto-detect (standaard, conservatief) / DSH officiële web / bekende desktop-shells / aangepast (verschuivingsafstand + aangepaste CSS)',
settingsTitleBarStripTitle: 'Verschuivingsafstand',
+2
View File
@@ -187,6 +187,8 @@ export const pl: Record<string, string> = {
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Otwieraj pliki czatu w panelu bocznym',
settingsOpenPathDesc: 'Otwieraj linki plików na czacie (wiersze narzędzi, wyprodukowane pliki, wzmianki) w edytorze panelu bocznego zamiast w domyślnej aplikacji systemu',
settingsOpenToolsTitle: 'Wstrzyknij narzędzie otwierania panelu bocznego dla modelu',
settingsOpenToolsDesc: 'Po włączeniu model może otwierać pliki, foldery i strony HTTP(S) w panelu bocznym za pomocą narzędzia sidebar_open (domyślnie wyłączone)',
settingsTitleBarTitle: 'Tryb zgodności pozycji',
settingsTitleBarDesc: 'Wybierz schemat zgodności paska tytułu: automatyczne wykrywanie (domyślne, konserwatywne) / oficjalny web DSH / znane powłoki pulpitu / niestandardowy (przesunięcie + własne CSS)',
settingsTitleBarStripTitle: 'Przesunięcie',
+2
View File
@@ -168,6 +168,8 @@ export const pt: Record<string, string> = {
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Abrir arquivos do chat na barra lateral',
settingsOpenPathDesc: 'Abrir links de arquivos no chat (linhas de ferramentas, arquivos produzidos, menções) no editor da barra lateral em vez do aplicativo padrão do sistema',
settingsOpenToolsTitle: 'Injetar a ferramenta de abertura na barra lateral para o modelo',
settingsOpenToolsDesc: 'Quando ativado, o modelo pode abrir arquivos, pastas e páginas HTTP(S) na barra lateral pela ferramenta sidebar_open (desativado por padrão)',
settingsTitleBarTitle: 'Modo de compatibilidade de posição',
settingsTitleBarDesc: 'Escolha o esquema de compatibilidade da barra de título: detecção automática (padrão, conservador) / web oficial do DSH / shells de desktop conhecidos / personalizado (distância de deslocamento + CSS personalizado)',
settingsTitleBarStripTitle: 'Distância de deslocamento',
+2
View File
@@ -182,6 +182,8 @@ export const ru: Record<string, string> = {
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Открывать файлы чата в боковой панели',
settingsOpenPathDesc: 'При щелчке по ссылке на файл в чате (строки инструментов, списки результатов, упоминания) файл открывается в редакторе боковой панели, а не в системном приложении по умолчанию',
settingsOpenToolsTitle: 'Внедрить инструмент открытия в боковой панели для модели',
settingsOpenToolsDesc: 'Если включено, модель может открывать файлы, папки и HTTP(S)-страницы в боковой панели с помощью инструмента sidebar_open (по умолчанию выключено)',
settingsTitleBarTitle: 'Режим совместимости заголовка',
settingsTitleBarDesc: 'Выберите схему совместимости заголовка: автоопределение (по умолчанию, консервативно) / официальный Web DSH / известные десктоп-оболочки / своя схема (смещение + пользовательский CSS)',
settingsTitleBarStripTitle: 'Величина смещения',
+2
View File
@@ -168,6 +168,8 @@ export const sv: Record<string, string> = {
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Öppna chattfiler i sidopanelen',
settingsOpenPathDesc: 'Öppna fillänkar i chatten (verktygsrader, producerade filer, omnämnanden) i sidopanelens editor i stället för systemets standardapp',
settingsOpenToolsTitle: 'Injicera sidopanel-öppningsverktyg för modellen',
settingsOpenToolsDesc: 'När aktiverat kan modellen öppna filer, mappar och HTTP(S)-sidor i sidopanelen via sidebar_open-verktyget (av som standard)',
settingsTitleBarTitle: 'Positions kompatibilitetsläge',
settingsTitleBarDesc: 'Välj titelfälts kompatibilitetsschema: auto-detektera (standard, konservativt) / DSH officiella webb / kända skrivbordsskal / anpassat (förskjutningsavstånd + egen CSS)',
settingsTitleBarStripTitle: 'Förskjutningsavstånd',
+2
View File
@@ -185,6 +185,8 @@ export const th: Record<string, string> = {
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'เปิดไฟล์แชทในแถบด้านข้าง',
settingsOpenPathDesc: 'เปิดลิงก์ไฟล์ในแชท (แถวเครื่องมือ, ไฟล์ที่สร้าง, การกล่าวถึง) ในตัวแก้ไขแถบด้านข้างแทนแอปเริ่มต้นของระบบ',
settingsOpenToolsTitle: 'ฉีดเครื่องมือเปิดแถบด้านข้างสำหรับโมเดล',
settingsOpenToolsDesc: 'เมื่อเปิดใช้ โมเดลสามารถเปิดไฟล์ โฟลเดอร์ และหน้า HTTP(S) ในแถบด้านข้างผ่านเครื่องมือ sidebar_open (ปิดเป็นค่าเริ่มต้น)',
settingsTitleBarTitle: 'โหมดความเข้ากันได้ของตำแหน่ง',
settingsTitleBarDesc: 'เลือกโครงร่างความเข้ากันได้ของไตเติลบาร์: ตรวจจับอัตโนมัติ (ค่าเริ่มต้น, อนุรักษ์นิยม) / DSH เว็บอย่างเป็นทางการ / เชลล์เดสก์ท็อปที่รู้จัก / กำหนดเอง (ระยะการเลื่อน + CSS ที่กำหนดเอง)',
settingsTitleBarStripTitle: 'ระยะการเลื่อน',
+2
View File
@@ -185,6 +185,8 @@ export const tr: Record<string, string> = {
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Sohbet dosyalarını kenar çubuğunda aç',
settingsOpenPathDesc: 'Sohbetteki dosya bağlantılarını (araç satırları, üretilen dosyalar, anılmalar) sistem varsayılan uygulaması yerine kenar çubuğu düzenleyicisinde aç',
settingsOpenToolsTitle: 'Model için kenar çubuğu açma aracı enjekte et',
settingsOpenToolsDesc: 'Etkinleştirildiğinde model, sidebar_open aracıyla kenar çubuğunda dosyaları, klasörleri ve HTTP(S) sayfalarını açabilir (varsayılan kapalı)',
settingsTitleBarTitle: 'Konum uyumluluk modu',
settingsTitleBarDesc: 'Başlık çubuğu uyumluluk düzenini seçin: otomatik algıla (varsayılan, tutucu) / DSH resmi web / bilinen masaüstü kabukları / özel (kaydırma mesafesi + özel CSS)',
settingsTitleBarStripTitle: 'Kaydırma mesafesi',
+2
View File
@@ -185,6 +185,8 @@ export const vi: Record<string, string> = {
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Mở tệp chat trong thanh bên',
settingsOpenPathDesc: 'Khi nhấp link tệp trong chat (dòng công cụ, danh sách sản phẩm, nhắc tệp), mở trong trình soạn thảo thanh bên thay vì ứng dụng mặc định hệ thống',
settingsOpenToolsTitle: 'Tiêm công cụ mở thanh bên cho mô hình',
settingsOpenToolsDesc: 'Khi bật, mô hình có thể mở tệp, thư mục và trang HTTP(S) trong thanh bên qua công cụ sidebar_open (mặc định tắt)',
settingsTitleBarTitle: 'Chế độ tương thích thanh tiêu đề',
settingsTitleBarDesc: 'Chọn phương án tương thích thanh tiêu đề: tự động phát hiện (mặc định, bảo thủ) / Web DSH chính thức / shell desktop đã biết / tùy chỉnh (khoảng dịch chuyển + CSS tùy chỉnh)',
settingsTitleBarStripTitle: 'Khoảng dịch chuyển',
+2
View File
@@ -200,6 +200,8 @@ export const zhHK: Record<string, string> = {
settingsWidthSuffix: '%',
settingsOpenPathTitle: '聊天區檔案在側邊欄開啟',
settingsOpenPathDesc: '在聊天裡點擊檔案連結(工具行、產物列表、檔案提及)時,在側邊欄編輯器中開啟,不再呼叫系統預設應用程式',
settingsOpenToolsTitle: '為模型注入側邊欄開啟工具',
settingsOpenToolsDesc: '開啟後,模型可透過 sidebar_open 工具在側邊欄主動開啟檔案、資料夾和 HTTP(S) 網頁(預設關閉)',
settingsTitleBarTitle: '位置相容模式',
settingsTitleBarDesc: '選擇頂欄相容方案:自動偵測(預設,保守)/ DSH官方Web / 已知桌面殼 / 自訂方案(下移距離 + 自訂 CSS)',
settingsTitleBarStripTitle: '下移距離',
+2
View File
@@ -200,6 +200,8 @@ export const zhMO: Record<string, string> = {
settingsWidthSuffix: '%',
settingsOpenPathTitle: '聊天區檔案在側邊欄開啟',
settingsOpenPathDesc: '在聊天裡點擊檔案連結(工具行、產物列表、檔案提及)時,在側邊欄編輯器中開啟,不再呼叫系統預設應用程式',
settingsOpenToolsTitle: '為模型注入側邊欄開啟工具',
settingsOpenToolsDesc: '開啟後,模型可透過 sidebar_open 工具在側邊欄主動開啟檔案、資料夾和 HTTP(S) 網頁(預設關閉)',
settingsTitleBarTitle: '位置相容模式',
settingsTitleBarDesc: '選擇頂欄相容方案:自動偵測(預設,保守)/ DSH官方Web / 已知桌面殼 / 自訂方案(下移距離 + 自訂 CSS)',
settingsTitleBarStripTitle: '下移距離',
+2
View File
@@ -200,6 +200,8 @@ export const zhTW: Record<string, string> = {
settingsWidthSuffix: '%',
settingsOpenPathTitle: '聊天區檔案在側邊欄開啟',
settingsOpenPathDesc: '在聊天裡點擊檔案連結(工具行、產物列表、檔案提及)時,在側邊欄編輯器中開啟,不再呼叫系統預設應用程式',
settingsOpenToolsTitle: '為模型注入側邊欄開啟工具',
settingsOpenToolsDesc: '開啟後,模型可透過 sidebar_open 工具在側邊欄主動開啟檔案、資料夾和 HTTP(S) 網頁(預設關閉)',
settingsTitleBarTitle: '位置相容模式',
settingsTitleBarDesc: '選擇頂欄相容方案:自動偵測(預設,保守)/ DSH官方Web / 已知桌面殼 / 自訂方案(下移距離 + 自訂 CSS)',
settingsTitleBarStripTitle: '下移距離',
+4
View File
@@ -192,6 +192,8 @@ export const zh = {
settingsWidthSuffix: '%',
settingsOpenPathTitle: '聊天区文件在侧边栏打开',
settingsOpenPathDesc: '在聊天里点击文件链接(工具行、产物列表、文件提及)时,在侧边栏编辑器中打开,不再调用系统默认应用',
settingsOpenToolsTitle: '为模型注入侧边栏打开工具',
settingsOpenToolsDesc: '开启后,模型可通过 sidebar_open 工具在侧边栏主动打开文件、文件夹和 HTTP(S) 网页(默认关闭)',
settingsTitleBarTitle: '位置兼容模式',
settingsTitleBarDesc: '选择顶栏兼容方案:自动检测(默认,保守)/ DSH官方Web / 已知桌面壳 / 自定义方案(下移距离 + 自定义 CSS)',
settingsTitleBarStripTitle: '下移距离',
@@ -526,6 +528,8 @@ export const en: Record<keyof typeof zh, string> = {
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Open chat files in the sidebar',
settingsOpenPathDesc: 'Open file links in the chat (tool rows, produced files, mentions) in the sidebar editor instead of the system default app',
settingsOpenToolsTitle: 'Inject the sidebar-open tool for the model',
settingsOpenToolsDesc: 'When enabled, the model can actively open files, folders, and HTTP(S) pages in the sidebar through the sidebar_open tool (off by default)',
settingsTitleBarTitle: 'Position compatibility mode',
settingsTitleBarDesc: 'Pick the title-bar compatibility scheme: auto-detect (default, conservative) / DSH official web / known desktop shells / custom (shift distance + custom CSS)',
settingsTitleBarStripTitle: 'Shift distance',
+3
View File
@@ -58,6 +58,9 @@ export function parsePrefs(value: unknown): SidebarPrefs {
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,
+2 -2
View File
@@ -117,8 +117,8 @@ export interface SidebarSettingsDeclaration {
* Extra settings rows rendered under the feature's own row in the
* settings page (only while the feature is enabled). Keys must be fields
* of the host's PrefsSchema (built-ins: 'autoOpenSubagent',
* 'agentTerminalTools', 'terminalFontFamily'); unknown keys are dropped
* by the settings seam.
* 'agentTerminalTools', 'agentOpenTools', 'terminalFontFamily'); unknown
* keys are dropped by the settings seam.
*/
toggles?: readonly SidebarSettingToggle[]
/**
+1
View File
@@ -122,6 +122,7 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
autoOpenSubagent: z.boolean().default(true),
autoOpenJobs: z.boolean().default(true),
agentTerminalTools: z.boolean().default(false),
agentOpenTools: z.boolean().default(false),
bottomPanelAutoTerminal: z.boolean().default(true),
terminalFontFamily: z.string().default(''),
terminalFontSize: z.number().step(1).min(TERMINAL_FONT_SIZE_MIN).max(TERMINAL_FONT_SIZE_MAX).default(TERMINAL_FONT_SIZE_DEFAULT),
+95 -1
View File
@@ -23,6 +23,7 @@ import {
Config,
PrefsSchema,
resolveSidebarConfig,
SIDEBAR_PREFS_DEFAULTS,
SIDEBAR_PREFS_NS,
type ResolvedSidebarConfig,
type SidebarConfig,
@@ -48,6 +49,7 @@ import {
PTY_DEPS_MISSING,
} from './pty-deps.ts'
import { registerTools } from './tools.ts'
import { AgentOpenRegistry, registerOpenTool, type AgentOpenRequest } from './agent-opens.ts'
import { buildJobsApi, type SidebarJobsRoutes } from './jobs-routes.ts'
import { buildSubagentLiveApi, type SidebarSubagentLiveRoutes } from './subagent-live-route.ts'
import { buildSidechatApi } from './sidechat-routes.ts'
@@ -588,6 +590,11 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
const agentPtyRegistry = nodePty !== null
? new AgentPtyRegistry(terminalShell, resolved.shellArgs, nodePty)
: null
// The model-facing open-request registry: queues `sidebar_open` requests
// per session and pushes them to connected sidebar views over the
// `/sidebar/ws/agent-opens` socket. Unlike the pty registry it has no
// native dependencies — the tool works even in node-pty degraded mode.
const agentOpenRegistry = new AgentOpenRegistry()
// ── User-facing "Side card" preferences ──────────────────────────────────
// Register the namespace with the settings provider so the Settings page
@@ -603,6 +610,10 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
// turns the feature on, and turning it off mid-session unregisters the
// tools and releases the agent terminals they created.
let toolsDisposers: (() => void) | null = null
// The model-facing `sidebar_open` tool is gated the same way (see
// syncOpenToolsGate below); separate disposer (no native deps, and turning
// the feature off must not release user terminals).
let openToolsDisposers: (() => void) | null = null
const syncToolsGate = (scope: { get(): SidebarPrefs }): void => {
if (scope.get().agentTerminalTools) {
if (toolsDisposers === null) {
@@ -657,7 +668,38 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
// Register (or unregister) the terminal tools from the current setting,
// and keep them in sync with every settings commit.
syncToolsGate(scope)
scope.watch(() => { syncToolsGate(scope) })
// The model-facing open tool is gated the same way on `agentOpenTools`
// (default off): nothing is injected until the user turns the feature
// on, and turning it off mid-session unregisters the tool and drops the
// queued (undelivered) open requests. Already-delivered opens keep their
// tabs — the tools' only lever is the queue, not the rendered state.
const syncOpenToolsGate = (): void => {
if (scope.get().agentOpenTools) {
if (openToolsDisposers === null) {
openToolsDisposers = registerOpenTool(
ctx,
agentOpenRegistry,
(sessionId) => sessionCwdOf(ctx, sessionId),
() => {
const view = settingsFace?.get()
const value = view?.value
return value !== null && typeof value === 'object'
? value as SidebarPrefs
: SIDEBAR_PREFS_DEFAULTS
},
)
}
} else if (openToolsDisposers !== null) {
openToolsDisposers()
openToolsDisposers = null
agentOpenRegistry.drainAll()
}
}
syncOpenToolsGate()
// ONE watch subscription drives both gates: settings commits re-evaluate
// the terminal tools AND the open tool together (each gate is idempotent
// and owns its own disposer).
scope.watch(() => { syncToolsGate(scope); syncOpenToolsGate() })
})
// ── JSON API ────────────────────────────────────────────────────────────
@@ -890,15 +932,67 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
},
}), 'dsh-better-sidebar: agent-terminals push WebSocket')
// ── Agent opens push WebSocket ─────────────────────────────────────────
// Pushes `sidebar_open` requests for one session to the sidebar view: the
// host queues each request in the registry (consume-on-send), so a
// connected view applies it immediately and a disconnected one gets the
// replay when it attaches. The client mirrors each request into an
// editor / folder-window / browser tab open.
const agentOpenWss = new WebSocketServer({ noServer: true })
ctx.effect(() => ctx.webServer.registerUpgrade({
path: '/sidebar/ws/agent-opens',
handler: (req, socket, head) => {
if (!fence(req)) {
socket.destroy()
return
}
agentOpenWss.handleUpgrade(req as unknown as IncomingMessage, socket as unknown as Duplex, head as Buffer, (ws) => {
void attachAgentOpen(agentOpenRegistry, ws, req)
})
},
}), 'dsh-better-sidebar: agent-opens push WebSocket')
ctx.effect(() => () => {
toolsDisposers?.()
openToolsDisposers?.()
ptyManager?.disposeAll()
agentPtyRegistry?.disposeAll()
agentOpenRegistry.dispose()
wss.close()
agentListWss.close()
agentOpenWss.close()
}, 'dsh-better-sidebar: teardown')
}
/** Push queued `sidebar_open` requests for one session to a connected view. */
async function attachAgentOpen(
registry: AgentOpenRegistry,
ws: WebSocket,
req: SidebarHttpRequest,
): Promise<void> {
try {
const url = new URL(req.url ?? '/', 'http://dsh.internal')
const sessionId = url.searchParams.get('sessionId')
if (sessionId === null) {
ws.close(1008, 'sessionId is required')
return
}
const send = (request: AgentOpenRequest): void => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(request))
}
}
// Attach replays the queued (undelivered) requests for this session; the
// disposer detaches the view on socket close/error so later opens queue
// instead of accumulating on a dead socket.
const unsubscribe = registry.attach(sessionId, send)
ws.on('close', () => { unsubscribe() })
ws.on('error', () => { unsubscribe() })
} catch (error) {
ws.close(1011, error instanceof Error ? error.message : String(error))
}
}
/** Push the live agent-terminal list for one session to a connected sidebar view. */
async function attachAgentList(
registry: AgentPtyRegistry | null,
+9
View File
@@ -33,6 +33,14 @@ export interface SidebarPrefs {
* user explicitly enables it in the side card settings.
*/
agentTerminalTools: boolean
/**
* Whether the model-facing `sidebar_open` tool is injected into the
* model's toolset — one tool that lets the model actively open a local
* file, a local folder (as a tree rooted there), or an HTTP(S) page in
* the calling session's sidebar. Off by default: the feature stays
* dormant until the user explicitly enables it in the side card settings.
*/
agentOpenTools: boolean
/**
* Custom terminal font-family stack (a CSS font-family value, e.g.
* `'JetBrains Mono', monospace`). Empty string follows the app's theme
@@ -229,6 +237,7 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
autoOpenSubagent: true,
autoOpenJobs: true,
agentTerminalTools: false,
agentOpenTools: false,
bottomPanelAutoTerminal: true,
terminalFontFamily: '',
terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT,