@zhangzhian/dsh-devtools-terminal 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.js","names":["useState","Menu","Button","IconChevronDownOutlineRegular","PluginArtworkTerminal","ShortcutKeys","useCallback","useSyncExternalStore","useRef","useState","LOCALE_NS","localeZh","localeEn","PluginArtworkTerminal"],"sources":["../src/client/locale/index.ts","../src/client/styles.ts","../src/client/guide.tsx","../src/links.ts","../src/link-target.ts","../src/client/injection.ts","../src/client/terminal/chunk-loader.ts","../src/client/terminal/styles.ts","../src/client/terminal/body.tsx","../src/client/title.tsx","../src/client/index.tsx"],"sourcesContent":["/**\n * 终端组件的中英文案。\n *\n * 键位联合来自 `zh`:写错一个键名是**编译错误**,而不是界面上冒出一个\n * `devtoolsTerminal.new`。文案与平台终端保持同一套说法(用户看到的是「终端」,\n * 不是某个插件自己的术语)。\n *\n * ⚠️ 不要在模块顶层把 `t()` 的结果存进常量 —— 那样切语言不会更新。\n */\nimport type { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'\n\n/** 本插件在 locale 服务里占的命名空间。 */\nexport const NS = 'devtools-terminal'\n\n/** 全部中文文案。 */\nexport const zh = {\n // ── 页签与引导 ────────────────────────────────────────────────\n title: '终端',\n new: '新建终端',\n description: '在会话工作区运行命令',\n rename: '终端名称',\n shell: '选择 Shell',\n shellLoading: '正在读取 Shell…',\n shellEmpty: '没有可用的 Shell',\n\n // ── 状态 ──────────────────────────────────────────────────────\n loading: '正在读取终端环境…',\n creating: '正在启动…',\n connecting: '正在连接…',\n disconnected: '连接已断开。',\n reconnect: '重新连接',\n readonly: '此页面当前只读。',\n control: '接管输入',\n closed: '终端已关闭。',\n exited: '进程已退出({code})',\n failed: '终端错误:{message}',\n unavailable: '不可用',\n retry: '重试',\n missingTerminal: '此终端已不存在,请新建终端。',\n terminalLimit: '终端数量已达上限,请关闭不用的终端后重试。已退出的终端也计入数量。',\n} as const\n\n/** 终端文案键。 */\nexport type TerminalLocaleKey = keyof typeof zh\n\n/** 全部英文文案。 */\nexport const en: Record<TerminalLocaleKey, string> = {\n title: 'Terminal',\n new: 'New terminal',\n description: 'Run commands in the session workspace',\n rename: 'Terminal name',\n shell: 'Select shell',\n shellLoading: 'Reading shells…',\n shellEmpty: 'No shell available',\n\n loading: 'Reading the terminal environment…',\n creating: 'Starting…',\n connecting: 'Connecting…',\n disconnected: 'Connection lost.',\n reconnect: 'Reconnect',\n readonly: 'This page is read-only.',\n control: 'Take control',\n closed: 'Terminal closed.',\n exited: 'Process exited ({code})',\n failed: 'Terminal error: {message}',\n unavailable: 'Unavailable',\n retry: 'Retry',\n missingTerminal: 'This terminal no longer exists. Open a new one.',\n terminalLimit:\n 'The terminal limit has been reached. Close unused terminals and try again. Exited terminals also count toward the limit.',\n}\n\ndeclare module '@deepseek-ai/dsh-client-ui-slots' {\n interface LocaleNamespaceMap {\n /** 开发工具 · 终端组件的界面文案。 */\n 'devtools-terminal': TerminalLocaleKey\n }\n}\n\n/** 当前绑定的 locale 运行时(未绑定前回退中文)。 */\nlet bound: ((key: TerminalLocaleKey, params?: Record<string, unknown>) => string) | undefined\n\n/**\n * 绑定 locale 服务。\n *\n * @param runtime - 客户端 locale 服务。\n */\nexport function installLocale(runtime: LocaleRuntime): void {\n bound = runtime.bind(NS)\n}\n\n/**\n * 取一条文案。\n *\n * @param key - 字典键。\n * @param params - `{name}` 模板参数。\n * @returns 当前语言下的文案。\n */\nexport function t(key: TerminalLocaleKey, params?: Record<string, unknown>): string {\n if (bound === undefined) return zh[key]\n return bound(key, params)\n}\n","/**\n * 客户端样式的幂等注入。\n *\n * DSH 的客户端 bundle 是**一个脚本**,没有 CSS 文件的概念(插件只能往\n * `<head>` 里插 `<style>`)。这里给一个按 id 幂等的小工具:同一段样式重复\n * `injectStyle` 不会插第二遍,`data-plugin` 标记方便在 DevTools 里认出是谁的。\n *\n * 为什么不直接在每个模块里 `document.head.appendChild`:插件会热重载,\n * 不按 id 去重就会在同一页里叠出好几份同 id 的样式表。\n */\n\n/** 已经注入过的样式 id 集合。 */\nconst injected = new Set<string>()\n\n/**\n * 注入一段样式(幂等)。\n *\n * @param id - 样式 id(建议用 `${包名}/${文件名}`)。\n * @param css - 样式文本。\n */\nexport function injectStyle(id: string, css: string): void {\n if (injected.has(id) || typeof document === 'undefined') return\n if (document.querySelector(`style[data-devtools-terminal-style=${JSON.stringify(id)}]`) !== null) {\n injected.add(id)\n return\n }\n const style = document.createElement('style')\n style.dataset.plugin = 'dsh-devtools-terminal'\n style.dataset.devtoolsTerminalStyle = id\n style.textContent = css\n document.head.appendChild(style)\n injected.add(id)\n}\n","/**\n * 右侧栏引导页上的「终端」卡片。\n *\n * ## 为什么不能只给一张\"能点就行\"的卡片\n *\n * 引导页的卡片是**两层**组成的:终端页签类型声明一条 `guide` 条目(标题 / 描述 /\n * 图标),渲染时平台按 `providerId` 去 `sidebar.right.tab.guide.entry` 槽里找同\n * id 的组件;找不到才退回平台自带的 `EntryBox`。\n *\n * 我们接管了 `terminal` kind(`priority: 'extension'`),于是**生效的类型是我们**,\n * 平台那条「新建终端 + Shell 下拉菜单」卡片(它注册在平台自己的类型 id 上)不再\n * 参与渲染。所以这一版必须把平台卡片的两个既有行为都复刻出来:\n *\n * 1. **图标用平台自己的插画**(`PluginArtworkTerminal`,26px)—— 「文件」「Git」\n * 那几张卡用的都是平台插画;自己画一个灰色 `>_` 会又小又灰,夹在同一列里\n * 一眼就不是一套(用户 2026-10-24 的反馈就是这一条)。\n * 2. **标题右边的 Shell 下拉菜单** —— 点卡片用「上次选的 shell」直接开终端;点\n * chevron 列出宿主发现的 shell(`webTerminals.launchShells`),选一个就记成\n * 下次默认(`selectShell`)并带着 `shellPath` 打开。\n *\n * 尺寸也照平台卡片来(14px 标题 / 11px 描述 / 14px 20px 内边距 / 56px 最小高度 /\n * `--dsw-radius-xl`),不然同一列里只有这一张\"瘦\"一点。\n *\n * ## 为什么是手写而不是直接 import 平台组件\n *\n * 平台的 `TerminalGuide` 没有从 `@deepseek-ai/dsh-client-ui-sidebar-terminal/client`\n * 导出,而客户端 bundle 在浏览器里只能 require 9 个**种子模块** —— 直接 import\n * 平台组件就是运行时找不到模块。所以这里用同为种子模块的\n * `@deepseek-ai/dsh-client-ui-primitives` 里的 `Menu` / `Button` /\n * `PluginArtworkTerminal` 拼一张等价卡片。\n */\nimport { useEffect, useState } from 'react'\nimport type { ReactNode } from 'react'\nimport {\n Button,\n IconChevronDownOutlineRegular,\n Menu,\n PluginArtworkTerminal,\n ShortcutKeys,\n} from '@deepseek-ai/dsh-client-ui-primitives'\nimport type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives'\nimport type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'\nimport type { TerminalGuideInjected, TerminalShellsLike } from './injection.js'\nimport { t } from './locale/index.js'\nimport { injectStyle } from './styles.js'\n\n/**\n * 终端页签的导航参数。\n *\n * 平台自己的声明在 `dsh-client-ui-sidebar-terminal/lib/types/client/face.d.ts`,而\n * 那个文件**没有**从包的 `./client` 入口导出(`dsh-tunnel-core` 的 `terminal.ts`\n * 也补过同一份)。运行时的契约在正文的 `injection.ts`:它按 `shellPath` 建终端视图。\n * 没有这份声明,`openTab('terminal', { params: { shellPath } })` 过不了类型检查。\n */\ndeclare module '@deepseek-ai/dsh-client-ui-sidebar-right/client' {\n interface SidebarRightTabParamsMap {\n /** 终端页签:恢复一个已有终端(`terminalId`),或新开一个(`shellPath`)。 */\n terminal: { terminalId: string } | { shellPath: string }\n }\n}\n\n/** 卡片样式。 */\ninjectStyle(\n 'dsh-devtools-terminal/guide',\n '.dsh-devtools-terminal-guide{box-sizing:border-box;position:relative;display:flex;align-items:center;gap:14px;' +\n 'width:100%;min-height:56px;padding:14px 20px;border:0.5px solid var(--dsw-alias-border-l3);' +\n 'border-radius:var(--dsw-radius-xl);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);' +\n 'text-align:left;overflow:hidden}' +\n '.dsh-devtools-terminal-guide:hover{background:var(--dsw-alias-interactive-bg-hover)}' +\n '.dsh-devtools-terminal-guide-main{position:absolute;inset:0;width:100%;height:100%;padding:0;border-radius:0}' +\n '.dsh-devtools-terminal-guide-main:focus-visible{outline-offset:-2px}' +\n '.dsh-devtools-terminal-guide-icon{flex:none;display:inline-flex;align-items:center;justify-content:center;' +\n 'width:26px;height:26px;color:var(--dsw-alias-label-secondary);pointer-events:none;position:relative}' +\n '.dsh-devtools-terminal-guide-text{display:flex;flex-direction:column;flex:1;gap:3px;min-width:0;' +\n 'pointer-events:none;position:relative}' +\n '.dsh-devtools-terminal-guide-title-row{display:flex;align-items:center;gap:4px;min-width:0}' +\n '.dsh-devtools-terminal-guide-title{font-size:14px;line-height:1.4;white-space:nowrap;overflow:hidden;' +\n 'text-overflow:ellipsis}' +\n '.dsh-devtools-terminal-guide-description{font-size:11px;line-height:1.4;color:var(--dsw-alias-label-tertiary);' +\n 'white-space:nowrap;overflow:hidden;text-overflow:ellipsis}' +\n '.dsh-devtools-terminal-guide-menu{flex:none;display:flex;pointer-events:auto}' +\n '.dsh-devtools-terminal-guide-trigger{flex:none;width:20px;height:20px;padding:0;border-radius:4px}' +\n '.dsh-devtools-terminal-guide-trigger svg{width:14px;height:14px;color:var(--dsw-alias-label-tertiary)}' +\n '.dsh-devtools-terminal-guide-shortcut{pointer-events:none;position:relative}',\n)\n\n/** 平台「新建终端」命令的 id(快捷键提示读它)。 */\nconst TERMINAL_NEW = 'terminal.new'\n\n/** Shell 下拉菜单的状态机。 */\ntype ShellMenuState =\n | { readonly phase: 'loading' }\n | {\n readonly phase: 'ready'\n readonly shells: TerminalShellsLike['shells']\n readonly selected?: string\n }\n | { readonly phase: 'failed'; readonly message: string }\n\n/** 引导卡片的 props:槽运行时给的运行时 props + 宿主 shell 服务注入面。 */\nexport type TerminalGuideProps = PropsRuntime<'sidebar.right.tab.guide.entry'> & InjectFace<TerminalGuideInjected>\n\n/**\n * 「终端」引导卡片。\n *\n * @param props - 槽运行时 props(标题 / 描述 / 页签动作)与 shell 注入面。\n * @returns 一张可点的卡片;宿主提供 shell 服务时标题右边还带下拉菜单。\n */\nexport function TerminalGuide(props: TerminalGuideProps): ReactNode {\n const { useTabInfo, useShortcuts, title, description, loadShells, selectShell } = props\n const { tab } = useTabInfo()\n // 平台在这个位置显示「新建终端」的有效快捷键(默认 Ctrl+`);绑定被用户改过也显示改后的。\n const shortcut = useShortcuts((entries) => entries.find((entry) => entry.id === TERMINAL_NEW))\n const [open, setOpen] = useState(false)\n const [attempt, setAttempt] = useState(0)\n const [state, setState] = useState<ShellMenuState>({ phase: 'loading' })\n\n // 每次打开(或重试)才去问宿主有哪些 shell:列表可能随环境变化,打开时读最新的一份。\n useEffect(() => {\n if (!open || loadShells === undefined) return undefined\n const lifetime = new AbortController()\n loadShells(lifetime.signal).then(\n (choices) => {\n if (lifetime.signal.aborted) return\n setState(\n choices.selectedShell === undefined\n ? { phase: 'ready', shells: choices.shells }\n : { phase: 'ready', shells: choices.shells, selected: choices.selectedShell },\n )\n },\n (error: unknown) => {\n if (lifetime.signal.aborted) return\n setState({ phase: 'failed', message: error instanceof Error ? error.message : String(error) })\n },\n )\n return () => {\n lifetime.abort()\n }\n }, [open, attempt, loadShells])\n\n const items: MenuEntry[] = (() => {\n if (state.phase === 'loading') return [{ id: 'loading', label: t('shellLoading'), disabled: true }]\n if (state.phase === 'failed') {\n return [\n { id: 'error', label: t('failed', { message: state.message }), disabled: true },\n { id: 'retry', label: t('retry') },\n ]\n }\n if (state.shells.length === 0) return [{ id: 'empty', label: t('shellEmpty'), disabled: true }]\n return state.shells.map((shell) => ({ id: shell.path, label: shell.name }))\n })()\n\n /** 菜单选择:失败项是「重试」,其余是一项 shell。 */\n const choose = (path: string): void => {\n if (state.phase === 'failed') {\n setState({ phase: 'loading' })\n setAttempt((value) => value + 1)\n return\n }\n selectShell?.(path)\n setOpen(false)\n tab.actions.openTab('terminal', { replaceTab: true, params: { shellPath: path } })\n }\n\n const shellMenu =\n loadShells === undefined ? null : (\n <Menu\n open={open}\n portal\n autoFocus\n align=\"end\"\n className=\"dsh-devtools-terminal-guide-menu\"\n items={items}\n selectedId={state.phase === 'ready' ? state.selected : undefined}\n onClose={() => {\n setOpen(false)\n }}\n onSelect={choose}\n anchor={\n <Button\n variant=\"ghost\"\n className=\"dsh-devtools-terminal-guide-trigger\"\n aria-label={t('shell')}\n aria-haspopup=\"menu\"\n aria-expanded={open}\n onClick={() => {\n setState({ phase: 'loading' })\n setOpen((value) => !value)\n }}\n >\n <IconChevronDownOutlineRegular />\n </Button>\n }\n />\n )\n\n return (\n <div className=\"dsh-devtools-terminal-guide\">\n {/* 整卡可点:绝对定位的透明按钮铺满卡片;文字层 pointer-events:none 让点击穿透到这里。 */}\n <Button\n variant=\"ghost\"\n className=\"dsh-devtools-terminal-guide-main\"\n aria-label={description === undefined ? title : `${title} ${description}`}\n aria-keyshortcuts={shortcut?.aria}\n onClick={() => {\n // 与平台终端一致:替换引导页本身(引导页只是空态,不是要保留的页面)。\n tab.actions.openTab('terminal', { replaceTab: true })\n }}\n />\n <span className=\"dsh-devtools-terminal-guide-icon\" aria-hidden=\"true\">\n {/* 平台自己的终端插画 —— 与旁边「文件」「Git」卡片同一套,别再自己画。 */}\n <PluginArtworkTerminal size={description === undefined ? 22 : 26} />\n </span>\n <span className=\"dsh-devtools-terminal-guide-text\">\n <span className=\"dsh-devtools-terminal-guide-title-row\">\n <span className=\"dsh-devtools-terminal-guide-title\" aria-hidden=\"true\">\n {title}\n </span>\n {shellMenu}\n </span>\n {description !== undefined && (\n <span className=\"dsh-devtools-terminal-guide-description\" aria-hidden=\"true\">\n {description}\n </span>\n )}\n </span>\n {shortcut !== undefined && shortcut.keys.length > 0 && (\n <ShortcutKeys keys={shortcut.keys} className=\"dsh-devtools-terminal-guide-shortcut\" />\n )}\n </div>\n )\n}\n","/**\n * 终端链接的**纯逻辑**:URL 识别、折行拼接、字符→单元格映射。\n *\n * ## 为什么单独放这里(不塞进客户端半)\n *\n * 客户端半进浏览器、只能靠 E2E 验;而这几个函数是纯字符串/坐标运算,\n * 放在 host 可编译进 `lib/`,用 `node --test` 就能把边界钉死\n * (折行、宽字符、中文标点、成对括号……)。客户端分块再 import 同一份代码\n * (tsdown 会内联进去)。\n *\n * ## 只依赖「行」的最小结构\n *\n * xterm 的 `IBufferLine` 在这里只以结构类型出现(`isWrapped` / `length` /\n * `getCell`),不 import xterm —— 于是测试可以喂假行,客户端也照常编译。\n */\n\n/** 一条逻辑行最多往回/往前拼多少行(防病态 scrollback 把主线程卡住)。 */\nexport const MAX_LOGICAL_ROWS = 256\n\n/** 一行里的一个字符落在哪个单元格(`row`/`col` 为 0 基,`width` 为 1 或 2)。 */\nexport interface CellRef {\n readonly row: number\n readonly col: number\n readonly width: number\n}\n\n/** 一条逻辑行:拼好的文本 + 每个字符的单元格映射。 */\nexport interface LogicalLine {\n readonly text: string\n readonly cells: readonly CellRef[]\n}\n\n/** 文本里命中的一条 URL(下标是**文本下标**,不是列号)。 */\nexport interface UrlMatch {\n readonly url: string\n readonly start: number\n readonly end: number\n}\n\n/** 按行切开的一段链接范围(`startCol`/`endCol` 0 基,`endCol` 是最后一个被占单元格)。 */\nexport interface LinkSegment {\n readonly row: number\n readonly startCol: number\n readonly endCol: number\n}\n\n/** xterm `IBufferCell` 里我们用到的两个方法。 */\nexport interface LineCellLike {\n getWidth(): number\n getChars(): string\n}\n\n/** xterm `IBufferLine` 里我们用到的三个成员。 */\nexport interface LineLike {\n readonly isWrapped: boolean\n readonly length: number\n getCell(col: number): LineCellLike | undefined\n}\n\n/**\n * URL 字符集。\n *\n * 停止符比一般实现多两类:\n * - `\\u3000-\\u303f`、`\\u4e00-\\u9fff`、`\\uff00-\\uffef`:中文/全角标点。终端输出\n * 常见「地址:https://x 然后紧跟中文」,不排掉就会把后半句吃进 URL。\n * - 引号/反引号/尖括号/反斜杠:shell 引号、markdown 包裹的常见形式。\n * 末尾的标点另外在 {@link trimTrailing} 里剥,因为 `)`/`。` 有时属于 URL 本身。\n */\nconst URL_PATTERN =\n /https?:\\/\\/[^\\s\\u0000-\\u001f\\u007f<>\"'`\\\\\\u2000-\\u206f\\u3000-\\u303f\\u4e00-\\u9fff\\uff00-\\uffef]+/gi\n\n/** 一律剥掉的收尾标点(中英文都算)。 */\nconst TRAILING_PUNCTUATION = /[.,;:!?,。;:!?、'\"'`)】》」』…]/\n\n/** `http://a` 的长度:短于它的候选不可能是合法 URL。 */\nconst MIN_URL_LENGTH = 'http://a'.length\n\n/**\n * 判断字符串是不是可打开的 http(s) 地址。\n *\n * @param text - 候选文本。\n * @returns http/https 且能被 URL 解析时为 true。\n */\nexport function isHttpUrl(text: string): boolean {\n try {\n const parsed = new URL(text)\n return parsed.protocol === 'http:' || parsed.protocol === 'https:'\n } catch {\n return false\n }\n}\n\n/** 统计子串出现次数(判断收尾的 `)` 有没有配对的 `(`)。 */\nfunction countOf(text: string, char: string): number {\n let count = 0\n for (let index = 0; index < text.length; index++) if (text[index] === char) count++\n return count\n}\n\n/**\n * 剥掉 URL 末尾不属于它的标点。\n *\n * 两种规则:常见中英文句读一律剥;收尾的 `)`/`]`/`}` 只有在**没有配对的开括号**\n * 时才剥(`https://en.wikipedia.org/wiki/Foo_(bar)` 保持完整)。\n *\n * @param url - 正则刚匹配出来的候选。\n * @returns 剥过尾巴的候选(可能为空串)。\n */\nexport function trimTrailing(url: string): string {\n let candidate = url\n while (candidate.length > 0) {\n const last = candidate[candidate.length - 1] ?? ''\n if (TRAILING_PUNCTUATION.test(last)) {\n candidate = candidate.slice(0, -1)\n continue\n }\n if (last === ')' && countOf(candidate, '(') < countOf(candidate, ')')) {\n candidate = candidate.slice(0, -1)\n continue\n }\n if (last === ']' && countOf(candidate, '[') < countOf(candidate, ']')) {\n candidate = candidate.slice(0, -1)\n continue\n }\n if (last === '}' && countOf(candidate, '{') < countOf(candidate, '}')) {\n candidate = candidate.slice(0, -1)\n continue\n }\n break\n }\n return candidate\n}\n\n/**\n * 找出一段文本里的所有 http(s) URL。\n *\n * @param text - 一条逻辑行的纯文本。\n * @returns 命中列表(`end` 开区间)。\n */\nexport function findUrls(text: string): UrlMatch[] {\n const urls: UrlMatch[] = []\n if (typeof text !== 'string' || text.length < MIN_URL_LENGTH) return urls\n URL_PATTERN.lastIndex = 0\n let match: RegExpExecArray | null\n while ((match = URL_PATTERN.exec(text)) !== null) {\n const candidate = trimTrailing(match[0])\n // 正则已经吃掉尾部标点,下一轮从候选末尾继续,避免重叠匹配。\n URL_PATTERN.lastIndex = match.index + Math.max(candidate.length, 1)\n if (candidate.length < MIN_URL_LENGTH || !isHttpUrl(candidate)) continue\n urls.push({ url: candidate, start: match.index, end: match.index + candidate.length })\n }\n return urls\n}\n\n/**\n * 从请求行往回走到逻辑行开头,再把续行拼起来。\n *\n * 终端里的「一行」在 buffer 里可能是多行:**只有续行**的 `isWrapped` 为 true。\n * URL 被折成两行是常见情况,所以识别必须以逻辑行为单位。\n *\n * @param getLine - `(row0) => 行 | undefined`,row0 为 0 基行号。\n * @param row - 请求行(0 基)。\n * @returns 逻辑行;拿不到行时返回 null。\n */\nexport function readLogicalLine(getLine: (row: number) => LineLike | undefined, row: number): LogicalLine | null {\n let line = getLine(row)\n if (line === undefined) return null\n\n // 1) 往回找逻辑行开头:请求行自己是续行时,前面还有内容。\n let start = row\n let back = 0\n while (start > 0 && line.isWrapped && back < MAX_LOGICAL_ROWS) {\n start--\n back++\n const previous = getLine(start)\n if (previous === undefined) break\n line = previous\n }\n\n // 2) 从开头往后把所有续行拼起来,同时保留「文本下标 → 单元格」的表。\n let text = ''\n const cells: CellRef[] = []\n let current = start\n let guard = 0\n while (guard < MAX_LOGICAL_ROWS) {\n const currentLine = getLine(current)\n if (currentLine === undefined) break\n // current > start 时,非续行说明逻辑行到此为止。\n if (current > start && !currentLine.isWrapped) break\n for (let col = 0; col < currentLine.length; col++) {\n const cell = currentLine.getCell(col)\n if (cell === undefined) continue\n const width = cell.getWidth()\n // 宽字符的第二个单元格是占位符(width 0),跳过:它在文本里只算一个字符。\n if (width === 0) continue\n const chars = cell.getChars() === '' ? ' ' : cell.getChars()\n for (let index = 0; index < chars.length; index++) cells.push({ row: current, col, width })\n text += chars\n }\n current++\n guard++\n }\n return { text, cells }\n}\n\n/**\n * 把「文本下标区间」切成按行分段的单元格区间。\n *\n * xterm 的 ILink 可以跨行,但渲染器按行画下划线;按行切段最省事,每段都带完整\n * URL 文本(点任何一段打开的都是整个 URL)。\n *\n * @param cells - {@link readLogicalLine} 的 cells。\n * @param start - 起始文本下标(含)。\n * @param end - 结束文本下标(不含)。\n * @returns 按行的分段。\n */\nexport function segmentsOf(cells: readonly CellRef[], start: number, end: number): LinkSegment[] {\n const segments: LinkSegment[] = []\n let current: { row: number; startCol: number; endCol: number } | null = null\n for (let index = start; index < end; index++) {\n const cell = cells[index]\n if (cell === undefined) continue\n const lastCol = cell.col + cell.width - 1\n if (current !== null && current.row === cell.row) {\n current.endCol = lastCol\n } else {\n if (current !== null) segments.push(current)\n current = { row: cell.row, startCol: cell.col, endCol: lastCol }\n }\n }\n if (current !== null) segments.push(current)\n return segments\n}\n\n/**\n * 生成请求行上的链接范围(1 基、含首尾,xterm ILink 的坐标约定)。\n *\n * @param getLine - `(row0) => 行 | undefined`。\n * @param row - 请求行(0 基)。\n * @param makeLink - `(url, range) => T`,由调用方决定 ILink 的 activate 实现。\n * @returns 请求行上的链接。\n */\nexport function linksForRow<T>(\n getLine: (row: number) => LineLike | undefined,\n row: number,\n makeLink: (url: string, range: { start: { x: number; y: number }; end: { x: number; y: number } }) => T,\n): T[] {\n const logical = readLogicalLine(getLine, row)\n if (logical === null) return []\n const links: T[] = []\n for (const entry of findUrls(logical.text)) {\n for (const segment of segmentsOf(logical.cells, entry.start, entry.end)) {\n // 只回答被问的那一行:xterm 是按行查询的,返回别行会被判成重叠链接。\n if (segment.row !== row) continue\n links.push(\n makeLink(entry.url, {\n start: { x: segment.startCol + 1, y: segment.row + 1 },\n end: { x: segment.endCol + 1, y: segment.row + 1 },\n }),\n )\n }\n }\n return links\n}\n","/**\n * 终端链接的**落点决策**:Ctrl/Cmd+点击输出里的 URL,交给谁打开。\n *\n * ## 为什么单独一个纯模块\n *\n * 「面板优先、不可用回退系统浏览器」这条规则本身只有几行,但它是本功能唯一的\n * 行为契约:面板临时抛错要能回退、非 http(s) 一律不碰、普通点击根本走不到这里\n * (修饰键判断在 `link-provider.ts`)。这些分支在浏览器里验证成本高(要真 xterm +\n * 真鼠标 + 真右栏),放到 host 编译(`lib/link-target.js`)就能用 `node --test`\n * 把每条分支钉死 —— 与 `links.ts` 同一个理由。\n *\n * ## 落点只有两个\n *\n * - `panel`:DSH 右栏的**浏览器插件页签**(`@deepseek-ai/dsh-client-ui-sidebar-browser`,\n * web 是 iframe、桌面端 carrier 是原生 `<webview>`、移动端在 WebView 里同样是\n * iframe)。打开的 URL 由平台校验,非法地址它自己会拒绝。\n * - `external`:`window.open(..., 'noopener,noreferrer')` —— 浏览器里是新标签页,\n * 桌面端经 carrier 的 `linkBridge` 交给系统浏览器。这是**回退**:profile 没启用\n * 右栏浏览器页签(`sidebarRightTabs.get('browser')` 为 undefined)、或者面板这次\n * 真的没打开成功时走它。\n *\n * 模块本身**不碰 DOM / cordis**:`window.open` 与 `ctx.sidebarRight` 都由调用方\n * 以端口({@link LinkOpenPorts})注入,于是这里只剩判断。\n */\nimport { isHttpUrl } from './links.js'\n\n/** 链接最终落到哪儿。 */\nexport type LinkDestination = 'panel' | 'external'\n\n/**\n * 决定一次打开该落到哪儿。\n *\n * 面板可用就优先面板 —— 这是本插件要的产品行为(用户要的是\"在 DSH 插件浏览器\n * 里打开\",而不是跳出去)。不可用(profile 关掉了那一行 / 平台版本没有这个页签\n * 类型)才回退系统浏览器。\n *\n * @param panelAvailable - 右栏浏览器页签当前是否可用。\n * @returns 落点。\n */\nexport function linkDestination(panelAvailable: boolean): LinkDestination {\n return panelAvailable ? 'panel' : 'external'\n}\n\n/** 打开链接需要的外部动作(由客户端注入;host 侧测试喂假的)。 */\nexport interface LinkOpenPorts {\n /**\n * 右栏浏览器页签现在可用吗。\n *\n * 每次**点击时**都问一次(不是启动时问一次):`browser` 页签类型由平台插件\n * 注册/卸载,和本插件同批加载、顺序不保证;而且 profile 的 patch 可能随时改。\n *\n * @returns 页签类型在注册表里存在时为 true。\n */\n panelAvailable(): boolean\n\n /**\n * 在右栏浏览器里打开一个地址。\n *\n * 地址已经过 {@link isHttpUrl} 校验;实现方不必再验。**允许抛错**(页签刚被\n * 关掉、面板没有会话作用域等),抛了就回退 {@link LinkOpenPorts.openExternal}。\n *\n * @param url - http(s) 地址。\n */\n openInPanel(url: string): void\n\n /**\n * 回退:交给系统 / 默认浏览器。\n *\n * @param url - http(s) 地址。\n */\n openExternal(url: string): void\n}\n\n/**\n * 造一个「打开终端链接」的动作。\n *\n * 语义(与 VSCode 终端一致):只认 http(s);面板优先;面板这次没开成功就回退\n * 系统浏览器 —— **不能因为面板抛错就什么都不做**,那等于点击没反应。\n *\n * @param ports - 外部动作。\n * @returns 打开动作(非 http(s) 静默忽略)。\n */\nexport function createLinkOpener(ports: LinkOpenPorts): (url: string) => void {\n return (url) => {\n // 防御:调用方(link-provider)已经验过一次,但这是公开导出的动作,\n // 别让 `file:` / `javascript:` 之类从别的调用点漏进来。\n if (!isHttpUrl(url)) return\n let destination: LinkDestination = 'external'\n try {\n destination = linkDestination(ports.panelAvailable())\n } catch {\n // 探测本身抛(服务正在卸载):按不可用处理,不要连累回退。\n destination = 'external'\n }\n if (destination === 'panel') {\n try {\n ports.openInPanel(url)\n return\n } catch {\n // 面板这次没开起来:继续往下走回退,不能把这一下点击吞掉。\n }\n }\n ports.openExternal(url)\n }\n}\n","/**\n * 把插件手里的服务(终端视图 / 主题)变成**页签正文可以直接用的注入面**。\n *\n * ## 为什么走注入面,而不是在组件里 `ctx.get()`\n *\n * `sidebar.right.pane.tab` 是 **keyed + session 作用域**的槽:同一个组件要服务多个\n * 会话、多个页签,而「这个页签对应哪个会话」只有在注册时(`inject(sessionId)`)\n * 才知道。槽运行时会把 `inject` 的返回值作为 props 交给组件,并把 `keyedHooks` /\n * `hooks` 转成 `useTerminal` / `useTheme` 钩子(见 `@deepseek-ai/dsh-client-ui-slots`\n * 的 `InjectFace`)。平台的终端正文就是这么写的,我们照抄同一套契约。\n *\n * ## 为什么类型是结构化的\n *\n * `@deepseek-ai/dsh-api-terminal-controller/client` 不在 9 个种子模块里(不能 import,\n * 浏览器里会变成裸 require 直接失败),它导出的类型对我们也用不上 —— 我们只用\n * `ctx.webTerminals` 上的**实例方法**。所以这里用结构类型描述用到的字段,并做\n * **能力探测**(`view` 是不是函数),而不是版本比较。\n */\nimport type { Context } from '@deepseek-ai/cordis'\nimport type { SessionId } from '@deepseek-ai/dsh-session/types'\nimport type { HostObservable, InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'\nimport { createLinkOpener } from '../link-target.js'\n\n/** 一帧渲染数据(宿主帧协议)。 */\nexport interface TerminalRenderFrame {\n readonly type: 'snapshot' | 'output'\n readonly screen?: string\n readonly data?: string\n readonly info?: { readonly cols: number; readonly rows: number }\n}\n\n/** 终端状态里正文读到的字段(宿主 `TerminalViewState` 的结构化子集)。 */\nexport interface TerminalStateSnapshot {\n readonly phase?: string\n readonly writable?: boolean\n readonly error?: string\n readonly title?: string\n readonly issue?: string\n readonly info?: {\n readonly state?: string\n readonly exitCode?: number\n readonly error?: string\n readonly title?: string\n }\n readonly render?: { readonly revision: number; readonly frame: TerminalRenderFrame }\n readonly environment?: {\n readonly maxCols?: number\n readonly maxRows?: number\n readonly scrollback?: number\n }\n}\n\n/** 可订阅的只读快照(`SnapshotStore` 的结构化形状)。 */\nexport interface TerminalStateSource {\n getSnapshot(): TerminalStateSnapshot\n subscribe(listener: () => void): () => void\n}\n\n/** 终端视图(宿主 `TerminalView` 的结构化子集)。 */\nexport interface TerminalViewLike {\n readonly id: string\n readonly state: HostObservable<TerminalStateSnapshot>\n /** 打开输出流;返回 detach 函数。**不调就一帧数据都不会来。** */\n mount(): () => void\n /** 重试/恢复(宿主的 recovery 逻辑)。 */\n refresh(): Promise<void>\n /** 重新接管输入。 */\n connect(): void\n /** 背压确认:必须在「画完这一帧」之后调,否则宿主不再发下一帧。 */\n acknowledge(revision: number): void\n write(data: string): void\n resize(cols: number, rows: number): void\n}\n\n/** 主题快照里正文用到的字段(平台 `ThemeSnapshot` 的结构化子集)。 */\nexport interface TerminalThemeSnapshot {\n readonly fontFamilies: { readonly terminal: string }\n readonly fontSizes: { readonly terminal: number }\n}\n\n/** 主题的 observable 形状(正文通过 `useTheme` 读)。 */\nexport type TerminalThemeSource = HostObservable<TerminalThemeSnapshot>\n\n/** 正文注入面(与平台终端正文同名同形状)。 */\nexport interface TerminalBodyInjected {\n readonly view: (key: string) => TerminalViewLike\n readonly keyedHooks: {\n readonly terminal: (key: string) => HostObservable<TerminalStateSnapshot>\n }\n readonly hooks: {\n readonly theme: TerminalThemeSource\n }\n /**\n * 打开终端输出里的链接(Ctrl/Cmd+点击时由 `link-provider.ts` 调)。\n *\n * 不是 hook:正文把它原样交给懒加载块里的 link provider。**每次点击时**才解析\n * 落点(面板可用性可能变),见 `src/link-target.ts`。\n */\n readonly openUrl: (url: string) => void\n}\n\n/** 正文组件的 props:槽运行时给的运行时 props + 注入面(文案走模块级 `t`)。 */\nexport type TerminalBodyProps = PropsRuntime<'sidebar.right.pane.tab'> & InjectFace<TerminalBodyInjected>\n\n/** 宿主发现的 shell 清单(`webTerminals.launchShells` 的返回结构)。 */\nexport interface TerminalShellsLike {\n readonly shells: readonly { readonly path: string; readonly name: string }[]\n /** 宿主记住的「上次选择」;新环境或没选过时没有。 */\n readonly selectedShell?: string\n}\n\n/** 终端服务的最小结构(`ctx.webTerminals`)。 */\nexport interface WebTerminalsLike {\n view(\n sessionId: string,\n key: string,\n contentId: string,\n terminalId?: string,\n shellPath?: string,\n ): TerminalViewLike\n launchShells?(sessionId: string, signal: AbortSignal): Promise<TerminalShellsLike>\n selectShell?(path: string): void\n}\n\n/** 主题服务的最小结构(`ctx.theme`)。 */\nexport interface ThemeServiceLike {\n getTheme(): TerminalThemeSnapshot\n}\n\n/**\n * 探测终端服务在不在。\n *\n * 不能 `inject`:终端控制器可能没加载(用户关掉了那一行),插件仍应正常加载,\n * 只是正文显示「这个环境没有终端」。也不能读到一次 `undefined` 就放弃 —— 平台\n * 那个插件和我们同批加载,谁先跑完没有保证,所以正文在注册时再探一次。\n *\n * @param ctx - 客户端根上下文。\n * @returns 终端服务,或 undefined。\n */\nexport function getTerminals(ctx: Context): WebTerminalsLike | undefined {\n const service = ctx.get('webTerminals') as WebTerminalsLike | undefined\n return service !== undefined && typeof service.view === 'function' ? service : undefined\n}\n\n/**\n * 探测主题服务。\n *\n * @param ctx - 客户端根上下文。\n * @returns 主题服务,或 undefined。\n */\nexport function getTheme(ctx: Context): ThemeServiceLike | undefined {\n const service = ctx.get('theme') as ThemeServiceLike | undefined\n return service !== undefined && typeof service.getTheme === 'function' ? service : undefined\n}\n\n/**\n * 取一个页签 occurrence 的导航参数。\n *\n * @param ctx - 客户端根上下文。\n * @param sessionId - 会话 id。\n * @param key - 页签 occurrence key。\n * @returns 内容地址与参数。\n */\nfunction occurrenceOf(\n ctx: Context,\n sessionId: SessionId,\n key: string,\n): { contentId: string; params: unknown } {\n const occurrence = ctx.sidebarRight.tabDomain.occurrence(sessionId, { id: key })\n const navigation = occurrence.navigation.getSnapshot()\n return { contentId: navigation.address, params: navigation.params }\n}\n\n/**\n * 造正文的 `inject` 回调。\n *\n * @param ctx - 客户端根上下文。\n * @returns 槽注册时的 `inject(sessionId)`。\n */\nexport function createTerminalInjection(ctx: Context): (sessionId: string) => TerminalBodyInjected {\n return (sessionId) => ({\n view: (key) => {\n const terminals = getTerminals(ctx)\n if (terminals === undefined) throw new Error('devtools-terminal: 终端服务不在')\n const { contentId, params } = occurrenceOf(ctx, sessionId as unknown as SessionId, key)\n const terminalId =\n params !== undefined && params !== null && typeof params === 'object' && 'terminalId' in params\n ? (params as { terminalId: string }).terminalId\n : undefined\n const shellPath =\n params !== undefined && params !== null && typeof params === 'object' && 'shellPath' in params\n ? (params as { shellPath: string }).shellPath\n : undefined\n return terminals.view(sessionId, key, contentId, terminalId, shellPath)\n },\n keyedHooks: {\n // 宿主自己的 `state` 就是 HostObservable;直接转发(与平台终端正文一致)。\n terminal: (key) => {\n const terminals = getTerminals(ctx)\n if (terminals === undefined) throw new Error('devtools-terminal: 终端服务不在')\n const { contentId, params } = occurrenceOf(ctx, sessionId as unknown as SessionId, key)\n const terminalId =\n params !== undefined && params !== null && typeof params === 'object' && 'terminalId' in params\n ? (params as { terminalId: string }).terminalId\n : undefined\n const shellPath =\n params !== undefined && params !== null && typeof params === 'object' && 'shellPath' in params\n ? (params as { shellPath: string }).shellPath\n : undefined\n return terminals.view(sessionId, key, contentId, terminalId, shellPath).state\n },\n },\n hooks: {\n theme: {\n getSnapshot: () => (getTheme(ctx)?.getTheme() ?? DEFAULT_THEME),\n subscribe: (listener) => (ctx.on as (event: string, callback: () => void) => () => void)('theme/change', listener),\n },\n },\n openUrl: createUrlOpener(ctx),\n })\n}\n\n/**\n * 右栏浏览器页签的最小调用面。\n *\n * ## 为什么用结构类型 + 一次断言,而不是 import 平台的浏览器包\n *\n * `openTab(kind, options)` 的 `params` 类型来自平台包里 `SidebarRightTabParamsMap`\n * 的模块增强;我们**故意不** import `@deepseek-ai/dsh-client-ui-sidebar-browser`\n * 的类型:\n *\n * 1. 那是**可选的**平台包(profile 可以关掉那一行,某些 dsh 安装里甚至没有),\n * 而本仓库的构建脚本是先 `rm -rf client` 再 `tsc` —— 类型一红(TS2307)就再也\n * 产不出客户端 bundle,dsh 直接把整个插件判为起不来(见 commit 0ab729f)。\n * 2. 那种\"自己 declare module 增强同名字段\"的写法更脆:平台哪天给 `browser` 的\n * params 加字段,两份声明就冲突,构建在别人的机器上红。\n *\n * 运行时只用到一个方法,所以按结构描述它;`browser` 这个 kind 是平台契约里的\n * 字面量(见 `dsh-client-ui-sidebar-browser` 的 README:「Client plugins can open\n * a tab through `ctx.sidebarRight.openTab('browser', { params: { url } })`」)。\n */\ninterface BrowserTabFace {\n /**\n * 打开一个浏览器页签。\n *\n * @param kind - 固定 `'browser'`。\n * @param options - 导航参数:初始地址。\n */\n openTab(kind: 'browser', options: { readonly params: { readonly url: string } }): void\n}\n\n/**\n * 造正文用的「打开链接」动作。\n *\n * 落点规则全部在纯模块 `src/link-target.ts` 里(可 `node --test`),这里只把\n * 平台服务接成端口:\n *\n * - 面板可用性:`sidebarRightTabs.get('browser')` —— 页签类型注册了就可用;\n * profile 里 `disabled: true` / 安装没有这个包时是 `undefined`,自动回退;\n * - 面板:`sidebarRight.openTab('browser', { params: { url } })` —— 展开右栏并把\n * 浏览器页签切到前台。**它不收地址、也不去重**:参数只记进 navigation.params,\n * 由页签首次挂载时自己去校验/加载(非法地址是面板里的一条报错提示,不抛错),\n * 而 `browser` kind 是 `multiple: true`,所以每点一次是一个**新页签**\n * (与平台自己的聊天链接行为一致;平台没有\"按 URL 找页签\"的公开接口);\n * - 回退:`window.open(..., 'noopener,noreferrer')` —— 浏览器里是新标签页,\n * 桌面端 carrier 会把它交给系统浏览器(`dsh-electron` 的 `linkBridge`)。\n *\n * @param ctx - 客户端根上下文(`sidebarRight` / `sidebarRightTabs` 在静态 inject 里)。\n * @returns 打开动作。\n */\nfunction createUrlOpener(ctx: Context): (url: string) => void {\n return createLinkOpener({\n panelAvailable: () => ctx.sidebarRightTabs.get('browser') !== undefined,\n openInPanel: (url) => {\n ;(ctx.sidebarRight as unknown as BrowserTabFace).openTab('browser', { params: { url } })\n },\n openExternal: (url) => {\n window.open(url, '_blank', 'noopener,noreferrer')\n },\n })\n}\n\n/** 快捷键目录里本卡片用到的那几项(`ctx.shortcuts.catalog` 的结构化子集)。 */\nexport interface ShortcutEntryLike {\n readonly id: string\n readonly keys: readonly string[]\n readonly aria?: string | undefined\n}\n\n/** 快捷键服务的最小结构(`ctx.shortcuts`)。 */\nexport interface ShortcutsLike {\n readonly catalog: HostObservable<readonly ShortcutEntryLike[]>\n}\n\n/**\n * 快捷键服务缺席时的空目录。\n *\n * ⚠️ 不能\"没有就不注入 hooks\":`hooks` 是**注入面形状**的一部分,少一个成员会让\n * `useShortcuts` 这个 prop 从有到无,而组件里的 Hook 调用不能跟着变。所以统一注入\n * 一个空目录 —— 卡片照常渲染,只是不显示快捷键提示。\n */\nconst EMPTY_SHORTCUTS: HostObservable<readonly ShortcutEntryLike[]> = {\n getSnapshot: () => [],\n subscribe: () => () => undefined,\n}\n\n/**\n * 探测快捷键服务。\n *\n * `shortcuts` 是平台自带的浏览器命令服务(引导页本身的快捷键提示也用它);它不在\n * 我们的静态 `inject` 里 —— 缺了不该让插件加载失败,只是少一行快捷键提示。\n *\n * @param ctx - 客户端根上下文。\n * @returns 快捷键服务,或 undefined。\n */\nexport function getShortcuts(ctx: Context): ShortcutsLike | undefined {\n const service = ctx.get('shortcuts') as ShortcutsLike | undefined\n return service !== undefined && service.catalog !== undefined ? service : undefined\n}\n\n/**\n * 引导卡注入面。\n *\n * 宿主 shell 服务缺席时两个成员都不给 —— 卡片据此**不画**下拉菜单,而不是画一个\n * 点开永远是空的菜单(平台自己那张卡也是\"有 shell 服务才有 chevron\")。\n * `hooks.shortcuts` 则**永远**给(见 `EMPTY_SHORTCUTS`)。\n */\nexport interface TerminalGuideInjected {\n readonly loadShells?: (signal: AbortSignal) => Promise<TerminalShellsLike>\n readonly selectShell?: (path: string) => void\n readonly hooks: {\n readonly shortcuts: HostObservable<readonly ShortcutEntryLike[]>\n }\n}\n\n/**\n * 造引导卡的 `inject` 回调。\n *\n * 引导卡是 `sidebar.right.tab.guide.entry`(session 作用域 keyed 槽):注入面在\n * **渲染时**按会话求值,所以这里可以现探终端服务 —— 用户看到引导页时平台早就加载完了。\n * 能力探测也在这里做:宿主没有 `launchShells` 就不给 `loadShells`。\n *\n * @param ctx - 客户端根上下文。\n * @param sessionId - 当前会话 id(宿主 `launchShells` 按会话解析默认 shell)。\n * @returns 引导卡注入面。\n */\nexport function createGuideInjection(ctx: Context, sessionId: string): TerminalGuideInjected {\n const terminals = getTerminals(ctx)\n const launchShells = terminals?.launchShells\n const shortcuts = getShortcuts(ctx)?.catalog ?? EMPTY_SHORTCUTS\n if (terminals === undefined || launchShells === undefined) {\n return { hooks: { shortcuts } }\n }\n return {\n hooks: { shortcuts },\n loadShells: (signal) => launchShells.call(terminals, sessionId, signal),\n selectShell: (path) => {\n getTerminals(ctx)?.selectShell?.(path)\n },\n }\n}\n\n/**\n * 主题服务缺席时的兜底值。\n *\n * 只影响终端字号/字体;颜色由 CSS 令牌决定,不会变成黑底白字。\n */\nconst DEFAULT_THEME: TerminalThemeSnapshot = {\n fontFamilies: { terminal: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace' },\n fontSizes: { terminal: 13 },\n}\n\n","/**\n * xterm 懒加载块的加载器。\n *\n * 宿主用 `GET /devtools-terminal/bundle/chunk-terminal.js` 发文件(见\n * `src/routes.ts` 的白名单),这里插一个 `<script>`,再取块自己挂在全局上的\n * 工厂函数。\n *\n * ## 为什么不能 `require` 平台的 xterm\n *\n * DSH 的客户端加载器只认 `__ModuleLoader__.load({ id, factory })`,factory 里的\n * `require` 只能拿到 9 个种子模块和已加载的插件行 —— **拿不到网络上的块**。\n * 平台把 xterm 内联在自己的私有块里,也不给第三方插件,所以只能自己打一份、\n * 自己搭这条懒加载通道。\n *\n * ## 稳态开销与失败重试\n *\n * 加载过的块记在 `pending` 里(同一个块只插一次 script,多次打开终端共用一次\n * 网络请求)。**失败必须把记录删掉**:否则一次网络抖动会让这个块永远加载不了\n * (下一次重试直接命中失败的缓存,看起来就是\"终端永远起不来\")。\n */\n\n/** 终端视口尺寸(列 × 行)。 */\nexport interface TerminalGeometry {\n readonly cols: number\n readonly rows: number\n}\n\n/** 建终端时的一次性选项。 */\nexport interface TerminalChunkOptions {\n /** 等宽字体栈(已由正文拼好兜底字体)。 */\n readonly fontFamily: string\n /** 字号(px)。 */\n readonly fontSize: number\n /** 宿主的回滚行数配额;宿主没给就按 0(平台行为,回滚由宿主管)。 */\n readonly scrollback: number\n /** 只读(宿主说不可写时,禁掉键盘输入)。 */\n readonly readOnly: boolean\n /** 无障碍标签(读屏用)。 */\n readonly label: string\n /**\n * 打开输出里的链接(Ctrl/Cmd+点击)。\n *\n * 正文注入:**不能在块里直接 `window.open`** —— 「面板优先、回退系统浏览器」\n * 的落点规则在 `src/link-target.ts`,而 `ctx.sidebarRight` 只有正文那一侧拿得到。\n *\n * @param url - 已校验是 http(s) 的地址。\n */\n openUrl(url: string): void\n /**\n * 用户键盘输入。\n *\n * @param data - 交给宿主 `view.write()` 的原始字节串。\n */\n onData(data: string): void\n}\n\n/** 一个已经打开的 xterm 实例(正文只通过这个面操作它)。 */\nexport interface TerminalChunkHandle {\n /**\n * 往终端写数据。\n *\n * @param data - VT 序列。\n * @param onDone - **解析完**的回调。宿主的帧协议是背压的:必须在写完之后\n * `acknowledge`,否则它再也不发下一帧(终端看起来就是永久冻住)。\n */\n write(data: string, onDone?: () => void): void\n /** 全量重置并按宿主给的行列开屏(收到 snapshot 帧时)。 */\n reset(cols: number, rows: number): void\n /** 强制设置行列。 */\n resize(cols: number, rows: number): void\n /**\n * 按容器当前尺寸算行列并应用。\n *\n * @returns 算出来的行列;容器不可见/没有盒子尺寸时为 `undefined`。\n */\n fit(): TerminalGeometry | undefined\n /** 切换只读(不可写时禁掉输入)。 */\n setReadOnly(readOnly: boolean): void\n /**\n * 改字体。\n *\n * @returns 真的变了才为 `true`(调用方据此决定要不要重新 fit)。\n */\n setFont(fontFamily: string, fontSize: number): boolean\n /** 更新无障碍标签。 */\n setLabel(label: string): void\n /** 按容器当前计算样式换主题色(不重建终端)。 */\n applyTheme(): void\n /** 拿键盘焦点。 */\n focus(): void\n /** 销毁(卸载监听、释放 DOM)。 */\n dispose(): void\n}\n\n/** 懒加载块向全局注册的实现表。 */\nexport interface TerminalChunkRegistry {\n /**\n * 建一个终端。\n *\n * @param host - 挂载元素(必须已经在文档里)。\n * @param options - 选项。\n * @returns 句柄。\n */\n terminal(host: HTMLElement, options: TerminalChunkOptions): TerminalChunkHandle\n}\n\n/** 建终端工厂的简写。 */\nexport type TerminalChunkFactory = TerminalChunkRegistry['terminal']\n\ndeclare global {\n /** 懒加载块往这里挂自己。 */\n // eslint-disable-next-line no-var\n var __devtoolsTerminalChunk__: Partial<TerminalChunkRegistry> | undefined\n}\n\n/** 块名 → 产物文件名(必须与 `tsdown.config.ts` 和 `src/routes.ts` 白名单一致)。 */\nconst SCRIPTS: Record<keyof TerminalChunkRegistry, string> = {\n terminal: 'chunk-terminal.js',\n}\n\n/** 路由前缀(必须与 `src/routes.ts` 的 `PREFIX` 一致)。 */\nconst BUNDLE_PREFIX = '/devtools-terminal/bundle'\n\n/** 正在加载/已加载的块。 */\nconst pending = new Map<keyof TerminalChunkRegistry, Promise<void>>()\n\n/**\n * 加载一个块(幂等)。\n *\n * @param name - 块名。\n * @returns 加载完成。\n */\nexport function loadChunk(name: keyof TerminalChunkRegistry): Promise<void> {\n if (typeof globalThis.__devtoolsTerminalChunk__?.[name] === 'function') return Promise.resolve()\n const existing = pending.get(name)\n if (existing !== undefined) return existing\n const promise = new Promise<void>((resolve, reject) => {\n const script = document.createElement('script')\n script.src = `${BUNDLE_PREFIX}/${SCRIPTS[name]}`\n script.async = true\n script.onload = () => {\n if (typeof globalThis.__devtoolsTerminalChunk__?.[name] === 'function') resolve()\n else reject(new Error(`分块 ${name} 加载了但没有注册`))\n }\n script.onerror = () => {\n reject(new Error(`分块 ${name} 加载失败`))\n }\n document.head.append(script)\n })\n // ⚠️ 失败要把记录删掉:否则一次抖动会让这个块**永远**加载不了。\n const guarded = promise.catch((error: unknown) => {\n pending.delete(name)\n throw error\n })\n pending.set(name, guarded)\n return guarded\n}\n\n/**\n * 加载并取回实现。\n *\n * @param name - 块名。\n * @returns 实现。\n */\nexport async function useChunk<K extends keyof TerminalChunkRegistry>(name: K): Promise<TerminalChunkRegistry[K]> {\n await loadChunk(name)\n const implementation = globalThis.__devtoolsTerminalChunk__?.[name]\n if (implementation === undefined) throw new Error(`分块 ${name} 未注册`)\n return implementation as TerminalChunkRegistry[K]\n}\n","/**\n * 终端正文自己的样式(注入 + 类名字符串)。\n *\n * ## 为什么和平台终端长得像\n *\n * 我们这个组件接管的是平台内置的 `terminal` 页签 —— 用户看到的还是同一个面板。\n * 所以几何照着平台那份 CSS 抄(flex 列、屏幕区撑满、状态行只在需要时出现),\n * 只换类名前缀;否则同一个页签在两种实现之间切换时会跳一下。\n *\n * ## 只写令牌,不写颜色\n *\n * 皮肤契约(根仓库 `test/design-tokens.test.mjs`)要求颜色来自 `--dsw-alias-*`。\n * 这里唯一\"写死\"的颜色是光标对比色,由懒加载块按单元格背景实时算出来,经 CSS\n * 变量传进来(见 `chunks/terminal.ts` 的 `observeTerminalCursor`)—— 它必须\n * 是具体色值,没法用令牌表达。\n *\n * ## 注入时机\n *\n * 模块加载时注入一次(`injectStyle` 按 id 幂等,热重载不会叠样式表)。正文是\n * 本包唯一使用这些类名的组件,不需要入口再记得调一次安装函数。\n */\nimport { injectStyle } from '../styles.js'\n\n/** 样式表 id(与 `src/client/title.tsx` 的 id 风格一致)。 */\nexport const STYLE_ID = 'dsh-devtools-terminal/body'\n\n/** 类名(前缀统一 `dsh-devtools-terminal-`,避免和别的插件抢样式)。 */\nexport const cls = {\n /** 正文根节点(带 `data-devtools-terminal`)。 */\n root: 'dsh-devtools-terminal-root',\n /** 状态行(loading / 重连 / 只读 / 已退出)。 */\n status: 'dsh-devtools-terminal-status',\n /** 终端屏幕区(xterm 的容器)。 */\n screen: 'dsh-devtools-terminal-screen',\n /** xterm 真正挂载的元素(跟随屏幕区撑满)。 */\n host: 'dsh-devtools-terminal-host',\n /** 临时状态文字。 */\n muted: 'dsh-devtools-terminal-muted',\n /** 占位(把右侧按钮推到行尾)。 */\n spacer: 'dsh-devtools-terminal-spacer',\n /** 状态行 / 空态里的按钮。 */\n button: 'dsh-devtools-terminal-button',\n /** 错误段落。 */\n error: 'dsh-devtools-terminal-error',\n /** 空态(缺终端 / 缺服务)。 */\n empty: 'dsh-devtools-terminal-empty',\n /** 空态主文案。 */\n emptyMessage: 'dsh-devtools-terminal-empty-message',\n /** 空态次要说明(宿主错误原文)。 */\n emptyDetail: 'dsh-devtools-terminal-empty-detail',\n /** xterm 初始化失败的覆盖层。 */\n failure: 'dsh-devtools-terminal-failure',\n} as const\n\n/** 全部 CSS。 */\nconst CSS = `\n.dsh-devtools-terminal-root {\n box-sizing: border-box;\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n height: 100%;\n min-height: 0;\n padding-top: 8px;\n color: var(--dsw-alias-label-primary);\n font-size: 13px;\n}\n\n/* ─────────────────────── 状态行 ─────────────────────── */\n/* 只在\"还没连上 / 连上但不可写 / 已经结束\"时出现,连上以后一行都不画(平台同款:\n 右侧栏本来就不宽,常驻工具条是纯浪费)。 */\n.dsh-devtools-terminal-status {\n display: flex;\n flex: none;\n flex-wrap: wrap;\n align-items: center;\n gap: 8px;\n padding: 5px 12px;\n background: var(--dsw-alias-bg-layer-2);\n}\n.dsh-devtools-terminal-spacer { flex: 1 1 auto; }\n.dsh-devtools-terminal-muted { color: var(--dsw-alias-label-tertiary); font-size: 11.5px; }\n.dsh-devtools-terminal-button {\n flex: none;\n padding: 2px 10px;\n border: 1px solid var(--dsw-alias-border-l2);\n border-radius: var(--dsw-radius-sm);\n background: var(--dsw-alias-bg-layer-1);\n color: var(--dsw-alias-label-primary);\n font: inherit;\n font-size: 12px;\n line-height: 1.6;\n cursor: pointer;\n}\n.dsh-devtools-terminal-button:hover { background: var(--dsw-alias-interactive-bg-hover); }\n\n/* ─────────────────────── 终端屏幕 ─────────────────────── */\n/* 屏幕区必须真的撑满:xterm 靠容器的盒子尺寸算行列,高度塌了就是 0 行。 */\n.dsh-devtools-terminal-screen {\n position: relative;\n display: flex;\n flex: 1 1 auto;\n min-width: 0;\n min-height: 0;\n padding: 8px;\n overflow: hidden;\n background: var(--dsw-alias-bg-base);\n color: var(--dsw-alias-label-primary);\n}\n/* 主题色也从这里读(懒加载块读这个元素的计算样式),所以背景写在这一层。 */\n.dsh-devtools-terminal-host {\n position: relative;\n width: 100%;\n height: 100%;\n background: var(--dsw-alias-bg-base);\n}\n.dsh-devtools-terminal-host .xterm { height: 100%; }\n/* xterm 自己的 .xterm-viewport 默认是 #000,不盖掉会在浅色主题下露黑边。 */\n.dsh-devtools-terminal-host .xterm-viewport { background: var(--dsw-alias-bg-base); }\n\n/* ─────────────────────── 光标 ─────────────────────── */\n/* 光标颜色由懒加载块逐帧算(保证在任何单元格背景上都看得见),这里是消费端。\n --dsh-devtools-terminal-cursor-* 由 chunks/terminal.ts 写在 host 的行内样式上。 */\n.dsh-devtools-terminal-host .xterm-cursor { opacity: 1; }\n.dsh-devtools-terminal-host .xterm .xterm-rows .xterm-cursor.xterm-cursor-block {\n background-color: var(--dsh-devtools-terminal-cursor);\n color: var(--dsh-devtools-terminal-cursor-accent);\n}\n.dsh-devtools-terminal-host .xterm .xterm-rows .xterm-cursor.xterm-cursor-block:not(.xterm-cursor-blink) {\n background-color: var(--dsh-devtools-terminal-cursor) !important;\n color: var(--dsh-devtools-terminal-cursor-accent) !important;\n}\n.dsh-devtools-terminal-host .xterm .xterm-rows .xterm-cursor.xterm-cursor-outline {\n outline-color: var(--dsh-devtools-terminal-cursor);\n}\n.dsh-devtools-terminal-host .xterm .xterm-rows .xterm-cursor.xterm-cursor-bar {\n box-shadow: 1px 0 0 var(--dsh-devtools-terminal-cursor) inset;\n}\n.dsh-devtools-terminal-host .xterm .xterm-rows .xterm-cursor.xterm-cursor-underline {\n border-bottom-color: var(--dsh-devtools-terminal-cursor);\n}\n@keyframes dsh-devtools-terminal-cursor-blink {\n 0% {\n background-color: var(--dsh-devtools-terminal-cursor);\n color: var(--dsh-devtools-terminal-cursor-accent);\n }\n 50% {\n background-color: var(--dsh-devtools-terminal-cursor-cell-background);\n color: var(--dsh-devtools-terminal-cursor-cell-foreground);\n }\n}\n.dsh-devtools-terminal-host .xterm .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-block.xterm-cursor-blink {\n animation: 1s step-end infinite dsh-devtools-terminal-cursor-blink;\n}\n\n/* ─────────────────────── 错误与空态 ─────────────────────── */\n.dsh-devtools-terminal-error {\n flex: none;\n margin: 0;\n padding: 10px 12px;\n overflow-wrap: anywhere;\n background: var(--dsw-alias-bg-layer-2);\n color: var(--dsw-alias-label-primary);\n}\n.dsh-devtools-terminal-empty {\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n gap: 16px;\n min-height: 0;\n padding: 24px;\n overflow: auto;\n text-align: center;\n}\n.dsh-devtools-terminal-empty-message {\n max-width: 28ch;\n margin: 0;\n line-height: 1.6;\n overflow-wrap: anywhere;\n}\n.dsh-devtools-terminal-empty-detail {\n max-width: 40ch;\n margin: 0;\n color: var(--dsw-alias-label-tertiary);\n font-size: 11.5px;\n line-height: 1.5;\n overflow-wrap: anywhere;\n}\n/* xterm 建不起来(懒加载块 404 / 浏览器不兼容)时的覆盖层:终端区不能留一块\n 黑屏让人猜,要给错误原文和重试。 */\n.dsh-devtools-terminal-failure {\n position: absolute;\n inset: 0;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n gap: 12px;\n padding: 24px;\n text-align: center;\n background: var(--dsw-alias-bg-base);\n color: var(--dsw-alias-label-primary);\n}\n`\n\ninjectStyle(STYLE_ID, CSS)\n","/**\n * 右侧栏终端正文:接管平台内置的 `terminal` 页签。\n *\n * ## 为什么是\"接管\"而不是\"新写一个页签类型\"\n *\n * 平台的终端正文只做「PTY → xterm」,没有 link provider —— 输出里的 URL 只是\n * 普通文字。平台把那份实现做成**私有**懒加载块,第三方插件既拿不到 xterm 实例,\n * 也换不掉它的 UI。`sidebar-right` 的页签类型注册表允许 `priority: 'extension'`\n * 顶替同 kind 的内置类型(`dsh-devtools-files` 已用同一手法接管 `files`),\n * 所以这里注册同一个 key、自己渲染正文,PTY 仍然用平台的 `ctx.webTerminals`。\n *\n * ## 和平台正文的对应关系\n *\n * 状态栏、只读 / 接管输入、重试、缺失终端、帧泵(snapshot 重置、output 追加、\n * 写完 `acknowledge`)、fit 夹宿主上限、隐藏页签不 fit、主题变化换色、卸载\n * dispose —— 全部照平台 `TerminalBody` / `TerminalScreen` 的行为搬过来。\n *\n * ## 两条不能破的规矩\n *\n * 1. **帧必须 ack**:宿主是背压发送的,不 ack 就再也不发下一帧(终端看起来\n * 永久冻住)。ack 只能写在 `xterm.write` 的回调里 —— 那才是\"真的解析完\"。\n * 2. **终端服务可能不在**(用户关掉了那一行):`injection` 的 `view` 会抛。\n * 正文必须自己接住并显示成空态,不能让一个页签把渲染打挂 —— 而且**不能**\n * 用「服务不在就少调一个 hook」的写法:hook 数量随服务在不在变化,React 会\n * 在下一次渲染时直接报 hook 顺序错误。所以状态统一从 `model.state` 读。\n */\nimport {\n useCallback,\n useEffect,\n useLayoutEffect,\n useRef,\n useState,\n useSyncExternalStore,\n type ReactNode,\n} from 'react'\n// 纯类型导入:拉进 sidebar-right 的 SlotMap / Context 模块增强(`useTabInfo`、\n// `TerminalBodyProps` 里的槽位类型都靠它)。运行时会被擦掉,不会进 bundle。\nimport type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'\nimport type {\n TerminalBodyProps,\n TerminalStateSnapshot,\n TerminalStateSource,\n TerminalThemeSnapshot,\n TerminalViewLike,\n} from '../injection.js'\nimport { t, zh, type TerminalLocaleKey } from '../locale/index.js'\nimport { useChunk, type TerminalChunkFactory, type TerminalChunkHandle } from './chunk-loader.js'\nimport { cls } from './styles.js'\n\n/** 平台终端也用的等宽字体栈;用户的主题字体栈接在它前面。 */\nconst TERMINAL_FONT_STACK = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'\n\n/** 宿主的帧(`state.render` 的非空形状)。 */\ntype RenderState = NonNullable<TerminalStateSnapshot['render']>\n\n/** 服务缺席时的空快照(同一引用,不会触发重渲染)。 */\nconst EMPTY_STATE: TerminalStateSnapshot = {}\n\n/**\n * 服务缺席时给 uSES 的空源。\n *\n * 为什么不直接用注入的 `useTerminal`:它在服务缺席时**直接抛**,而 hook 不能\n * 因为服务在不在而少调/多调(React 会报 hook 顺序变化)。这里把「状态源」当成\n * 普通值:服务不在就拿这份空源,hook 数量永远一致,服务恢复后也能正常接上。\n */\nconst ABSENT_STATE: TerminalStateSource = {\n getSnapshot: () => EMPTY_STATE,\n subscribe: () => () => undefined,\n}\n\n/**\n * 终端页签正文。\n *\n * @param props - 槽运行时 props + 注入面(见 `injection.ts`)。\n * @returns 渲染结果。\n */\nexport function TerminalBody(props: TerminalBodyProps): ReactNode {\n const { useTabInfo, useTheme, view: resolveView, openUrl } = props\n const { tab } = useTabInfo()\n const theme = useTheme((value) => value)\n\n // 服务缺席时 `view` 会抛(见 injection.ts 顶部说明):接住,退化成空态。\n let model: TerminalViewLike | undefined\n let unavailable: string | undefined\n try {\n model = resolveView(tab.id)\n } catch (error) {\n unavailable = messageOf(error)\n }\n\n // 状态源:正常接 view.state;缺席接空源。方法型 observable 必须重新绑 this,\n // 所以包一层箭头函数(同一个源只绑一次,避免每次渲染都退订/重订)。\n const source = model?.state ?? ABSENT_STATE\n const subscribe = useCallback((listener: () => void) => source.subscribe(listener), [source])\n const getSnapshot = useCallback(() => source.getSnapshot(), [source])\n const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)\n\n // `mount()` 是输出流的开关:不调它,PTY 建起来了也一帧数据都不会来。\n // 返回值是 detach,页面/页签卸载时摘掉(**不**调 view.dispose():那会永久\n // 中止视图,但进程还活着,留下一个够不着的僵尸终端)。\n useEffect(() => {\n if (model === undefined) return\n return model.mount()\n }, [model])\n\n if (unavailable !== undefined || model === undefined) {\n const reason = unavailable\n return (\n <section className={cls.root} data-devtools-terminal>\n <div className={cls.empty} role=\"status\">\n <p className={cls.emptyMessage}>{t('unavailable')}</p>\n {reason === undefined ? null : <p className={cls.emptyDetail}>{reason}</p>}\n </div>\n </section>\n )\n }\n\n // 收窄成常量:下面的回调/JSX 闭包里 TS 不再信任 `let` 的收窄结果。\n const view: TerminalViewLike = model\n\n /** \"新建终端\"动作(缺失终端 / 已退出时都要给)。 */\n const newTerminal = (\n <button\n type=\"button\"\n className={cls.button}\n onClick={() => {\n tab.actions.openTab('terminal', { replaceTab: true })\n }}\n >\n {t('new')}\n </button>\n )\n\n // 终端已经不存在(宿主侧被回收/被别的标签页关掉):引导用户新建一个,\n // 而不是让他对着一个永远 loading 的空白屏。\n if (state.issue === 'missingTerminal') {\n return (\n <section className={cls.root} data-devtools-terminal>\n <div className={cls.empty}>\n <p className={cls.emptyMessage} role=\"alert\">\n {t('missingTerminal')}\n </p>\n {newTerminal}\n </div>\n </section>\n )\n }\n\n const error =\n state.phase === 'disconnected'\n ? undefined\n : state.issue === undefined\n ? (state.error ?? state.info?.error)\n : issueText(state.issue)\n let status: string | undefined\n if (state.phase === 'idle' || state.phase === 'loading') status = t('loading')\n else if (state.phase === 'creating' || state.phase === 'connecting' || state.phase === 'disconnected') status = t(state.phase)\n else if (state.info?.state === 'exited') status = t('exited', { code: String(state.info.exitCode ?? '—') })\n else if (state.info?.state === 'failed') status = t('unavailable')\n else if (state.phase === 'closed') status = t('closed')\n\n const ended = state.info?.state === 'exited' || state.phase === 'closed'\n const retry = !ended && (state.phase === 'failed' || state.phase === 'disconnected')\n const readOnly = state.phase === 'connected' && state.info?.state === 'running' && state.writable !== true\n\n return (\n <section className={cls.root} data-devtools-terminal>\n {status !== undefined || retry || readOnly ? (\n <div className={cls.status} role=\"status\">\n {status === undefined ? null : <span>{status}</span>}\n {readOnly ? (\n <>\n <span>{t('readonly')}</span>\n <button\n type=\"button\"\n className={cls.button}\n onClick={() => {\n view.connect()\n }}\n >\n {t('control')}\n </button>\n </>\n ) : null}\n {retry ? (\n state.info === undefined ? (\n <button\n type=\"button\"\n className={cls.button}\n onClick={() => {\n void view.refresh()\n }}\n >\n {t(state.phase === 'disconnected' ? 'reconnect' : 'retry')}\n </button>\n ) : (\n <button\n type=\"button\"\n className={cls.button}\n onClick={() => {\n view.connect()\n }}\n >\n {t('reconnect')}\n </button>\n )\n ) : null}\n {ended ? newTerminal : null}\n </div>\n ) : null}\n {/* 只有拿到宿主 info(终端的行列/状态)才建屏幕;在那之前状态行就够了。 */}\n {state.info === undefined ? null : (\n <TerminalScreen\n state={state}\n model={view}\n visible={tab.visible}\n label={t('title')}\n theme={theme}\n openUrl={openUrl}\n />\n )}\n {error === undefined ? null : (\n <p className={cls.error} role=\"alert\">\n {t('failed', { message: error })}\n </p>\n )}\n </section>\n )\n}\n\n/** {@link TerminalScreen} 的 props。 */\ninterface TerminalScreenProps {\n /** 当前状态快照。 */\n readonly state: TerminalStateSnapshot\n /** 宿主视图。 */\n readonly model: TerminalViewLike\n /** 页签是当前显示的那个吗(隐藏时不要 fit,也不要抢焦点)。 */\n readonly visible: boolean\n /** 无障碍标签。 */\n readonly label: string\n /** 主题(字体族/字号来自它,颜色走 CSS 令牌)。 */\n readonly theme: TerminalThemeSnapshot\n /**\n * 打开输出里的链接(Ctrl/Cmd+点击)。由正文注入面给,块里不直接开窗。\n */\n readonly openUrl: (url: string) => void\n}\n\n/**\n * 终端屏幕:xterm 实例 + 帧泵 + 尺寸同步。\n *\n * xterm 几百 KB,走懒加载块(`chunk-loader.ts`)。所以创建是**异步**的;在块\n * 到位之前帧照常会来,创建完成后用\"最新一帧\"把屏幕补上(`lastRevision` 是\n * 跨渲染的 ref,重复帧不会画第二遍,也不会重复 ack)。\n *\n * @param props - 见 {@link TerminalScreenProps}。\n * @returns 屏幕容器。\n */\nfunction TerminalScreen({ state, model, visible, label, theme, openUrl }: TerminalScreenProps): ReactNode {\n const host = useRef<HTMLDivElement | null>(null)\n /** 懒加载块到位后建出来的句柄。 */\n const handle = useRef<TerminalChunkHandle | undefined>(undefined)\n /** 已经画到第几版(用来丢重复帧 / 补帧)。 */\n const lastRevision = useRef(0)\n const [factory, setFactory] = useState<TerminalChunkFactory | undefined>(undefined)\n /** 懒加载或建 xterm 失败时的原文。 */\n const [failure, setFailure] = useState<string | undefined>(undefined)\n /** 创建代数:下游 effect 靠它知道\"句柄刚建好,该补一次\"。 */\n const [ready, setReady] = useState(0)\n /** 用户点重试时 +1(作为 effect 依赖重新触发)。 */\n const [attempt, setAttempt] = useState(0)\n\n const fontFamily = terminalFontFamily(theme.fontFamilies.terminal)\n const fontSize = theme.fontSizes.terminal\n const size = infoSize(state.info)\n\n /**\n * 最新一份\"渲染时数据\"。\n *\n * 懒加载、ResizeObserver 的回调都发生在两次渲染之间;闭包里的 props 会过期,\n * 所以统一从这里读最新值(平台正文同样的做法)。\n */\n const current = useRef({ state, visible, fontFamily, fontSize, label, openUrl })\n current.current = { state, visible, fontFamily, fontSize, label, openUrl }\n\n // ① 懒加载 xterm 块(失败可重试:chunk-loader 会把失败缓存删掉)。\n useEffect(() => {\n if (factory !== undefined) return\n let alive = true\n void useChunk('terminal')\n .then((create) => {\n if (alive) setFactory(() => create)\n })\n .catch((error: unknown) => {\n if (alive) setFailure(messageOf(error))\n })\n return () => {\n alive = false\n }\n }, [factory, attempt])\n\n // ② 建 / 销毁 xterm。头像(host)始终在 DOM 里,即使先失败,重试时也有地方挂。\n useLayoutEffect(() => {\n if (factory === undefined) return\n const element = host.current\n if (element === null) return\n const snapshot = current.current\n let created: TerminalChunkHandle\n try {\n created = factory(element, {\n fontFamily: snapshot.fontFamily,\n fontSize: snapshot.fontSize,\n // 宿主的回滚配额;没有就不留回滚(平台行为,回滚由宿主管)。\n scrollback: snapshot.state.environment?.scrollback ?? 0,\n readOnly: snapshot.state.writable !== true,\n label: snapshot.label,\n openUrl: (url) => {\n // 走 ref 读**最新**注入的动作:xterm 只在建的时候拿一次选项,而注入面\n // 可能随会话/重渲染换新(落点规则本身每次点击再解析,见 link-target.ts)。\n current.current.openUrl(url)\n },\n onData: (data) => {\n model.write(data)\n },\n })\n } catch (error) {\n setFailure(messageOf(error))\n return\n }\n handle.current = created\n lastRevision.current = 0\n // 建 xterm 之前可能已经来过帧(懒加载那几百毫秒里输出照常在来):\n // 补上最新一帧,否则屏幕会停在空白,一直等到下一帧才亮。\n const render = snapshot.state.render\n if (render !== undefined) {\n lastRevision.current = render.revision\n paintFrame(created, render, model)\n }\n created.setReadOnly(snapshot.state.writable !== true)\n if (snapshot.visible && snapshot.state.writable === true) fitScreen(element, created, snapshot.state, model)\n else if (snapshot.state.writable !== true) resizeToHost(created, snapshot.state)\n setFailure(undefined)\n setReady((value) => value + 1)\n return () => {\n created.dispose()\n if (handle.current === created) handle.current = undefined\n }\n }, [factory, model, attempt])\n\n // ③ 帧泵:每来一帧写一次 xterm,写完 ack(背压)。\n useLayoutEffect(() => {\n const instance = handle.current\n if (instance === undefined) return\n const render = state.render\n if (render === undefined || render.revision <= lastRevision.current) return\n lastRevision.current = render.revision\n paintFrame(instance, render, model)\n }, [state.render, model, ready])\n\n // ④ 主题变化:只换颜色,不重建终端(程序自己发过的 OSC 覆盖会被保留)。\n useLayoutEffect(() => {\n handle.current?.applyTheme()\n }, [theme, model, ready])\n\n // ⑤ 字体变化:xterm 需要重新测字宽,所以改完还要 fit 一次。\n useLayoutEffect(() => {\n const instance = handle.current\n if (instance === undefined) return\n if (!instance.setFont(fontFamily, fontSize)) return\n const element = host.current\n if (element === null) return\n const snapshot = current.current\n if (!snapshot.visible || snapshot.state.writable !== true) return\n fitScreen(element, instance, snapshot.state, model)\n }, [fontFamily, fontSize, model, ready])\n\n // ⑥ 只读切换与尺寸同步。\n // 隐藏页签**不 fit**:容器可能没有盒子尺寸,而且宿主那边并不需要我们改行列。\n // 只读(别的客户端在控制)时反过来跟随宿主给的行列,否则输出会错位。\n useLayoutEffect(() => {\n const instance = handle.current\n if (instance === undefined) return\n instance.setReadOnly(state.writable !== true)\n const element = host.current\n if (element === null) return\n if (visible && state.writable === true) fitScreen(element, instance, state, model)\n else if (state.writable !== true) resizeToHost(instance, state)\n }, [visible, state.writable, size?.cols, size?.rows, model, ready])\n\n // ⑦ 标签、焦点。\n useEffect(() => {\n handle.current?.setLabel(label)\n }, [label, ready])\n\n useEffect(() => {\n if (!visible || state.writable !== true) return\n handle.current?.focus()\n }, [visible, state.writable, ready])\n\n // ⑧ 容器尺寸变化:只有\"可见且可写\"才量,否则会把隐藏页签量成 0×0。\n useLayoutEffect(() => {\n const element = host.current\n if (element === null) return\n const observer = new ResizeObserver(() => {\n const instance = handle.current\n if (instance === undefined) return\n const snapshot = current.current\n if (!snapshot.visible || snapshot.state.writable !== true) return\n if (element.clientWidth === 0 || element.clientHeight === 0) return\n fitScreen(element, instance, snapshot.state, model)\n })\n observer.observe(element)\n return () => {\n observer.disconnect()\n }\n }, [model, ready])\n\n return (\n <div className={cls.screen}>\n <div className={cls.host} ref={host} />\n {failure === undefined ? null : (\n <div className={cls.failure} role=\"alert\">\n <p className={cls.emptyMessage}>{t('failed', { message: failure })}</p>\n <button\n type=\"button\"\n className={cls.button}\n onClick={() => {\n // 先清错误,让头像(host)回到可挂载状态,再触发一次创建。\n setFailure(undefined)\n setAttempt((value) => value + 1)\n }}\n >\n {t('retry')}\n </button>\n </div>\n )}\n </div>\n )\n}\n\n/**\n * 画一帧。\n *\n * snapshot 是\"全量\":先 reset 再按宿主给的行列开屏,然后写整屏文本;\n * output 是增量,直接追加。**两种情况都必须写完后 ack**,否则宿主不发下一帧。\n *\n * @param handle - xterm 句柄。\n * @param render - 宿主帧。\n * @param model - 宿主视图(ack 用)。\n */\nfunction paintFrame(handle: TerminalChunkHandle, render: RenderState, model: TerminalViewLike): void {\n const frame = render.frame\n if (frame.type === 'snapshot') {\n // 契约里 info 是可选的(只有 snapshot 帧才一定带);缺了就只写内容不重置尺寸。\n if (frame.info !== undefined) handle.reset(frame.info.cols, frame.info.rows)\n handle.write(frame.screen ?? '', () => {\n model.acknowledge(render.revision)\n })\n return\n }\n handle.write(frame.data ?? '', () => {\n model.acknowledge(render.revision)\n })\n}\n\n/**\n * 按容器尺寸同步行列,并用宿主环境上限夹住。\n *\n * 不夹的话 PTY 会收到超范围的行列(宿主的 PTY 侧有上限,越界会直接拒)。\n *\n * @param host - 容器(有真实盒子尺寸才量)。\n * @param handle - xterm 句柄。\n * @param state - 当前状态(读 `environment.maxCols/maxRows`)。\n * @param model - 宿主视图(同步行列)。\n */\nfunction fitScreen(\n host: HTMLElement,\n handle: TerminalChunkHandle,\n state: TerminalStateSnapshot,\n model: TerminalViewLike,\n): void {\n if (host.clientWidth === 0 || host.clientHeight === 0) return\n const proposed = handle.fit()\n if (proposed === undefined) return\n const environment = state.environment\n const cols = environment?.maxCols === undefined ? proposed.cols : Math.min(proposed.cols, environment.maxCols)\n const rows = environment?.maxRows === undefined ? proposed.rows : Math.min(proposed.rows, environment.maxRows)\n // 太小的尺寸宿主会拒绝(PTY 侧也有下限)。\n if (cols < 2 || rows < 1) return\n // `fit()` 已经把 xterm 调到 proposed;被夹过才需要再调一次。\n if (cols !== proposed.cols || rows !== proposed.rows) handle.resize(cols, rows)\n model.resize(cols, rows)\n}\n\n/**\n * 只读时跟随宿主给的行列(不量容器,也不反向 notify 宿主)。\n *\n * @param handle - xterm 句柄。\n * @param state - 当前状态。\n */\nfunction resizeToHost(handle: TerminalChunkHandle, state: TerminalStateSnapshot): void {\n const size = infoSize(state.info)\n if (size !== undefined) handle.resize(size.cols, size.rows)\n}\n\n/**\n * 从宿主 info 里读行列。\n *\n * 契约类型(`injection.ts`)只列了正文**直接**读的字段,宿主的 `TerminalInfo`\n * 实际还带 `cols/rows`(平台正文用它做只读尺寸对齐)。这里按能力探测取,\n * 拿不到就跳过 —— 不影响可写路径。\n *\n * @param info - 状态里的 info。\n * @returns 行列,或 undefined。\n */\nfunction infoSize(info: TerminalStateSnapshot['info']): { readonly cols: number; readonly rows: number } | undefined {\n if (info === undefined) return undefined\n const probe = info as { readonly cols?: unknown; readonly rows?: unknown }\n if (typeof probe.cols !== 'number' || typeof probe.rows !== 'number') return undefined\n return { cols: probe.cols, rows: probe.rows }\n}\n\n/**\n * 拼字体族:用户的终端字体栈 + 平台兜底栈。\n *\n * @param list - 主题给的字体族(可能为空)。\n * @returns xterm 的 `fontFamily`。\n */\nfunction terminalFontFamily(list: string): string {\n return list === '' ? TERMINAL_FONT_STACK : `${list}, ${TERMINAL_FONT_STACK}`\n}\n\n/**\n * 宿主 issue → 文案。\n *\n * @param issue - 宿主给的 issue(契约里是 string,实际是文案键的并集)。\n * @returns 认得的键取当前语言文案,不认得就原样显示(好过空白)。\n */\nfunction issueText(issue: string): string {\n return Object.hasOwn(zh, issue) ? t(issue as TerminalLocaleKey) : issue\n}\n\n/**\n * 取错误原文。\n *\n * @param error - 任意抛出物。\n * @returns 可显示的文字。\n */\nfunction messageOf(error: unknown): string {\n return error instanceof Error ? error.message : String(error)\n}\n","/**\n * 页签标题(终端 chip 上显示的文字)。\n *\n * 标题是**活**的:终端进程起来之后宿主会给一个 shell 名(`zsh` / `pwsh`),\n * 用户改过名就用改过的。所以这里订阅终端状态而不是用页签打开时捕获的那份\n * `title` —— 后者永远是「终端」两个字。\n *\n * 没有改名入口(平台终端双击 chip 可改名):改名要写宿主的会话布局,\n * 属于「平台特性」,我们这一版先不做,宁可少一个功能也不去碰别人的存储。\n */\nimport type { ReactNode } from 'react'\nimport type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'\nimport type { TerminalBodyInjected, TerminalStateSnapshot } from './injection.js'\nimport { t } from './locale/index.js'\nimport { injectStyle } from './styles.js'\n\n/** 注入一次标题的样式。 */\ninjectStyle(\n 'dsh-devtools-terminal/title',\n '.dsh-devtools-terminal-title{display:inline-flex;align-items:center;gap:6px;min-width:0;overflow:hidden;' +\n 'text-overflow:ellipsis;white-space:nowrap}' +\n '.dsh-devtools-terminal-title-icon{flex:none;display:inline-flex;color:currentColor;opacity:.8}',\n)\n\n/** 标题组件 props:槽运行时给的运行时 props + 注入面。 */\nexport type TerminalTitleProps = PropsRuntime<'sidebar.right.pane.tab.title'> & InjectFace<TerminalBodyInjected>\n\n/**\n * 终端页签标题。\n *\n * @param props - 槽运行时 props。\n * @returns 图标 + 当前名称。\n */\nexport function TerminalTitle(props: TerminalTitleProps): ReactNode {\n const { tab } = props.useTabInfo()\n const live = props.useTerminal(tab.id, (state: TerminalStateSnapshot | undefined) => state?.info?.title ?? state?.title)\n const title = (live !== undefined && live !== '' ? live : undefined) ?? tab.title ?? t('title')\n return (\n <span className=\"dsh-devtools-terminal-title\">\n <span className=\"dsh-devtools-terminal-title-icon\" aria-hidden=\"true\">\n {/* 终端提示符图形:与平台终端页签的图标同样式,换掉会让页签栏看起来混搭。 */}\n <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\">\n <path d=\"M3 4L7 8L3 12\" stroke=\"currentColor\" />\n <path d=\"M9 12H13\" stroke=\"currentColor\" />\n </svg>\n </span>\n {title}\n </span>\n )\n}\n","/**\n * `dsh-devtools-terminal` 的客户端半:接管右侧栏的 `terminal` 页签。\n *\n * ## 它注册了什么\n *\n * 1. **页签类型**(`sidebarRightTabs.register`):`kind` 与平台内置终端相同\n * (`terminal`),`priority: 'extension'` —— 注册表里「extension 顶掉 builtin」是\n * 明文契约(见 `dsh-client-ui-sidebar-right` 的 `contract/slots.d.ts`),这也是\n * 仓库里 `dsh-devtools-files` 接管内置 `files` 的同一条路。\n * 2. **正文**(`sidebar.right.pane.tab`,key = 本包 id);\n * 3. **页签标题**(`sidebar.right.pane.tab.title`);\n * 4. **引导页卡片**(`sidebar.right.tab.guide.entry`)。\n *\n * 页签的**生命周期**仍然由平台负责:关闭时平台终端注册的 close handler 按 kind\n * `terminal` 关掉宿主的 PTY(`ctx.webTerminals.close`),`retainTabs` 也按 kind\n * 保活 —— 我们接管的是「画什么」,不是「终端进程归谁管」。\n *\n * ## 依赖门\n *\n * 静态 inject 只写四个**必有**的服务:`slots` / `locale` / `sidebarRight` /\n * `sidebarRightTabs`。终端服务(`webTerminals`)和主题(`theme`)**不能**写进\n * inject:前者是可选的(没有它正文显示提示,插件仍要加载),后者缺了就走兜底值。\n * 两者都在**注册时**用 `ctx.get()` 再探一次,而不是在 apply 里读一次就记住 ——\n * 平台插件和我们同批加载,没有顺序保证。\n */\nimport type { Context } from '@deepseek-ai/cordis'\n// 纯类型导入:拉进各服务的模块增强(`slots` / `locale` / `sidebarRight` / `sidebarRightTabs`)。\nimport { PluginArtworkTerminal } from '@deepseek-ai/dsh-client-ui-primitives'\nimport type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'\nimport type {} from '@deepseek-ai/dsh-client-ui-renderer/client'\nimport type {} from '@deepseek-ai/dsh-client-ui-slots'\nimport { TerminalGuide } from './guide.js'\nimport { createGuideInjection, createTerminalInjection, getTerminals } from './injection.js'\nimport { en as localeEn, installLocale, NS as LOCALE_NS, t, zh as localeZh } from './locale/index.js'\nimport { TerminalBody } from './terminal/body.js'\nimport { TerminalTitle } from './title.js'\n\n/** Cordis 插件名(= 包名)。 */\nexport const name = 'dsh-devtools-terminal'\n\n/** 静态依赖门(见文件头)。 */\nexport const inject = ['slots', 'locale', 'sidebarRight', 'sidebarRightTabs']\n\n/** 页签类型的 id。**必须唯一**,用包名最省心(slot 注册的 key 也是它)。 */\nconst TAB_ID = '@zhangzhian/dsh-devtools-terminal'\n\n/** 接管的 kind(平台内置终端的 discriminator)。 */\nconst TAB_KIND = 'terminal'\n\n/** 引导页里本卡片的排序(平台终端是 20;我们接替它,保持同一位置)。 */\nconst GUIDE_ORDER = 20\n\n/**\n * 挂载客户端半。\n *\n * @param ctx - 客户端根上下文。\n */\nexport function apply(ctx: Context): void {\n const ui = ctx.inject(['slots', 'locale', 'sidebarRight', 'sidebarRightTabs'], (scoped) => {\n installLocale(scoped.locale)\n scoped.effect(\n () => scoped.locale.register(LOCALE_NS, { zh: localeZh, en: localeEn }),\n 'dsh-devtools-terminal: locale',\n )\n\n /**\n * 正文/标题的注入面。\n *\n * ⚠️ 必须在**注册时**造,不能提到外面:`ctx.inject` 的回调跑起来时\n * `sidebarRight` 才就位;而 `inject` 回调里的 `ctx.get('webTerminals')`\n * 是每次渲染调用时再探的(见 `injection.ts`)。\n */\n const injectFace = createTerminalInjection(scoped as unknown as Context)\n\n // ① 页签类型:extension 顶掉内置 terminal。\n scoped.effect(\n () =>\n scoped.sidebarRightTabs.register({\n id: TAB_ID,\n kind: TAB_KIND,\n multiple: true,\n // `extension` 是最高优先级带:同 kind 里它赢,内置终端在我们卸载后恢复。\n priority: 'extension',\n title: () => t('title'),\n guide: [\n {\n id: TAB_ID,\n order: GUIDE_ORDER,\n title: () => t('new'),\n description: () => t('description'),\n // 图标给平台插画:即使我们的引导卡组件没加载(插件半途失败),退回\n // 平台自带卡片时也不会画一个占位方块,和旁边「文件」「Git」卡一致。\n icon: PluginArtworkTerminal,\n },\n ],\n }),\n 'dsh-devtools-terminal: tab type',\n )\n\n // ② 正文。\n scoped.effect(\n () =>\n scoped.slots.inject('sidebar.right.pane.tab', () =>\n scoped.slots.register(\n { name: 'sidebar.right.pane.tab', key: TAB_ID, inject: injectFace },\n TerminalBody,\n ),\n ),\n 'dsh-devtools-terminal: body',\n )\n\n // ③ 标题。\n scoped.effect(\n () =>\n scoped.slots.inject('sidebar.right.pane.tab.title', () =>\n scoped.slots.register(\n { name: 'sidebar.right.pane.tab.title', key: TAB_ID, inject: injectFace },\n TerminalTitle,\n ),\n ),\n 'dsh-devtools-terminal: title',\n )\n\n // ④ 引导页卡片。注入面在渲染时按会话求值:宿主 shell 服务在,卡片才带 Shell 下拉菜单。\n scoped.effect(\n () =>\n scoped.slots.inject('sidebar.right.tab.guide.entry', () =>\n scoped.slots.register(\n {\n name: 'sidebar.right.tab.guide.entry',\n key: TAB_ID,\n inject: (sessionId) => createGuideInjection(scoped as unknown as Context, sessionId),\n },\n TerminalGuide,\n ),\n ),\n 'dsh-devtools-terminal: guide',\n )\n\n // 终端服务缺席时留一条能查的日志:正文会有提示,但 `console` 里得看得出原因。\n if (getTerminals(scoped as unknown as Context) === undefined) {\n console.warn('[devtools-terminal] 没有 webTerminals 服务:终端正文会显示「不可用」')\n }\n })\n void ui\n}\n\n/**\n * 页签 id(导出给测试/调试用)。\n */\nexport { TAB_ID, TAB_KIND }\n"],"mappings":";;;;;;;;;;;;AAYA,MAAa,KAAK;;AAGlB,MAAa,KAAK;CAEhB,OAAO;CACP,KAAK;CACL,aAAa;CACb,QAAQ;CACR,OAAO;CACP,cAAc;CACd,YAAY;CAGZ,SAAS;CACT,UAAU;CACV,YAAY;CACZ,cAAc;CACd,WAAW;CACX,UAAU;CACV,SAAS;CACT,QAAQ;CACR,QAAQ;CACR,QAAQ;CACR,aAAa;CACb,OAAO;CACP,iBAAiB;CACjB,eAAe;AACjB;;AAMA,MAAa,KAAwC;CACnD,OAAO;CACP,KAAK;CACL,aAAa;CACb,QAAQ;CACR,OAAO;CACP,cAAc;CACd,YAAY;CAEZ,SAAS;CACT,UAAU;CACV,YAAY;CACZ,cAAc;CACd,WAAW;CACX,UAAU;CACV,SAAS;CACT,QAAQ;CACR,QAAQ;CACR,QAAQ;CACR,aAAa;CACb,OAAO;CACP,iBAAiB;CACjB,eACE;AACJ;;AAUA,IAAI;;;;;;AAOJ,SAAgB,cAAc,SAA8B;CAC1D,QAAQ,QAAQ,KAAK,EAAE;AACzB;;;;;;;;AASA,SAAgB,EAAE,KAAwB,QAA0C;CAClF,IAAI,UAAU,KAAA,GAAW,OAAO,GAAG;CACnC,OAAO,MAAM,KAAK,MAAM;AAC1B;;;;;;;;;;;;;;ACzFA,MAAM,2BAAW,IAAI,IAAY;;;;;;;AAQjC,SAAgB,YAAY,IAAY,KAAmB;CACzD,IAAI,SAAS,IAAI,EAAE,KAAK,OAAO,aAAa,aAAa;CACzD,IAAI,SAAS,cAAc,sCAAsC,KAAK,UAAU,EAAE,EAAE,EAAE,MAAM,MAAM;EAChG,SAAS,IAAI,EAAE;EACf;CACF;CACA,MAAM,QAAQ,SAAS,cAAc,OAAO;CAC5C,MAAM,QAAQ,SAAS;CACvB,MAAM,QAAQ,wBAAwB;CACtC,MAAM,cAAc;CACpB,SAAS,KAAK,YAAY,KAAK;CAC/B,SAAS,IAAI,EAAE;AACjB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AC8BA,YACE,+BACA,mpDAoBF;;AAGA,MAAM,eAAe;;;;;;;AAqBrB,SAAgB,cAAc,OAAsC;CAClE,MAAM,EAAE,YAAY,cAAc,OAAO,aAAa,YAAY,gBAAgB;CAClF,MAAM,EAAE,QAAQ,WAAW;CAE3B,MAAM,WAAW,cAAc,YAAY,QAAQ,MAAM,UAAU,MAAM,OAAO,YAAY,CAAC;CAC7F,MAAM,CAAC,MAAM,YAAA,GAAWA,MAAAA,SAAAA,CAAS,KAAK;CACtC,MAAM,CAAC,SAAS,eAAA,GAAcA,MAAAA,SAAAA,CAAS,CAAC;CACxC,MAAM,CAAC,OAAO,aAAA,GAAYA,MAAAA,SAAAA,CAAyB,EAAE,OAAO,UAAU,CAAC;CAGvE,CAAA,GAAA,MAAA,UAAA,OAAgB;EACd,IAAI,CAAC,QAAQ,eAAe,KAAA,GAAW,OAAO,KAAA;EAC9C,MAAM,WAAW,IAAI,gBAAgB;EACrC,WAAW,SAAS,MAAM,CAAC,CAAC,MACzB,YAAY;GACX,IAAI,SAAS,OAAO,SAAS;GAC7B,SACE,QAAQ,kBAAkB,KAAA,IACtB;IAAE,OAAO;IAAS,QAAQ,QAAQ;GAAO,IACzC;IAAE,OAAO;IAAS,QAAQ,QAAQ;IAAQ,UAAU,QAAQ;GAAc,CAChF;EACF,IACC,UAAmB;GAClB,IAAI,SAAS,OAAO,SAAS;GAC7B,SAAS;IAAE,OAAO;IAAU,SAAS,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;GAAE,CAAC;EAC/F,CACF;EACA,aAAa;GACX,SAAS,MAAM;EACjB;CACF,GAAG;EAAC;EAAM;EAAS;CAAU,CAAC;CAE9B,MAAM,eAA4B;EAChC,IAAI,MAAM,UAAU,WAAW,OAAO,CAAC;GAAE,IAAI;GAAW,OAAO,EAAE,cAAc;GAAG,UAAU;EAAK,CAAC;EAClG,IAAI,MAAM,UAAU,UAClB,OAAO,CACL;GAAE,IAAI;GAAS,OAAO,EAAE,UAAU,EAAE,SAAS,MAAM,QAAQ,CAAC;GAAG,UAAU;EAAK,GAC9E;GAAE,IAAI;GAAS,OAAO,EAAE,OAAO;EAAE,CACnC;EAEF,IAAI,MAAM,OAAO,WAAW,GAAG,OAAO,CAAC;GAAE,IAAI;GAAS,OAAO,EAAE,YAAY;GAAG,UAAU;EAAK,CAAC;EAC9F,OAAO,MAAM,OAAO,KAAK,WAAW;GAAE,IAAI,MAAM;GAAM,OAAO,MAAM;EAAK,EAAE;CAC5E,EAAA,CAAG;;CAGH,MAAM,UAAU,SAAuB;EACrC,IAAI,MAAM,UAAU,UAAU;GAC5B,SAAS,EAAE,OAAO,UAAU,CAAC;GAC7B,YAAY,UAAU,QAAQ,CAAC;GAC/B;EACF;EACA,cAAc,IAAI;EAClB,QAAQ,KAAK;EACb,IAAI,QAAQ,QAAQ,YAAY;GAAE,YAAY;GAAM,QAAQ,EAAE,WAAW,KAAK;EAAE,CAAC;CACnF;CAEA,MAAM,YACJ,eAAe,KAAA,IAAY,OACzB,iBAAA,GAAA,kBAAA,IAAA,CAACC,sCAAAA,MAAD;EACQ;EACN,QAAA;EACA,WAAA;EACA,OAAM;EACN,WAAU;EACH;EACP,YAAY,MAAM,UAAU,UAAU,MAAM,WAAW,KAAA;EACvD,eAAe;GACb,QAAQ,KAAK;EACf;EACA,UAAU;EACV,QACE,iBAAA,GAAA,kBAAA,IAAA,CAACC,sCAAAA,QAAD;GACE,SAAQ;GACR,WAAU;GACV,cAAY,EAAE,OAAO;GACrB,iBAAc;GACd,iBAAe;GACf,eAAe;IACb,SAAS,EAAE,OAAO,UAAU,CAAC;IAC7B,SAAS,UAAU,CAAC,KAAK;GAC3B;GAEA,UAAA,iBAAA,GAAA,kBAAA,IAAA,CAACC,sCAAAA,+BAAD,CAAgC,CAAA;EAC1B,CAAA;CAEX,CAAA;CAGL,OACE,iBAAA,GAAA,kBAAA,KAAA,CAAC,OAAD;EAAK,WAAU;EAAf,UAAA;GAEE,iBAAA,GAAA,kBAAA,IAAA,CAACD,sCAAAA,QAAD;IACE,SAAQ;IACR,WAAU;IACV,cAAY,gBAAgB,KAAA,IAAY,QAAQ,GAAG,MAAM,GAAG;IAC5D,qBAAmB,UAAU;IAC7B,eAAe;KAEb,IAAI,QAAQ,QAAQ,YAAY,EAAE,YAAY,KAAK,CAAC;IACtD;GACD,CAAA;GACD,iBAAA,GAAA,kBAAA,IAAA,CAAC,QAAD;IAAM,WAAU;IAAmC,eAAY;IAE7D,UAAA,iBAAA,GAAA,kBAAA,IAAA,CAACE,sCAAAA,uBAAD,EAAuB,MAAM,gBAAgB,KAAA,IAAY,KAAK,GAAK,CAAA;GAC/D,CAAA;GACN,iBAAA,GAAA,kBAAA,KAAA,CAAC,QAAD;IAAM,WAAU;IAAhB,UAAA,CACE,iBAAA,GAAA,kBAAA,KAAA,CAAC,QAAD;KAAM,WAAU;KAAhB,UAAA,CACE,iBAAA,GAAA,kBAAA,IAAA,CAAC,QAAD;MAAM,WAAU;MAAoC,eAAY;MAC7D,UAAA;KACG,CAAA,GACL,SACG;IACL,CAAA,GAAA,gBAAgB,KAAA,KACf,iBAAA,GAAA,kBAAA,IAAA,CAAC,QAAD;KAAM,WAAU;KAA0C,eAAY;KACnE,UAAA;IACG,CAAA,CAEJ;;GACL,aAAa,KAAA,KAAa,SAAS,KAAK,SAAS,KAChD,iBAAA,GAAA,kBAAA,IAAA,CAACC,sCAAAA,cAAD;IAAc,MAAM,SAAS;IAAM,WAAU;GAAwC,CAAA;EAEpF;;AAET;;;;;;;;;ACpJA,SAAgB,UAAU,MAAuB;CAC/C,IAAI;EACF,MAAM,SAAS,IAAI,IAAI,IAAI;EAC3B,OAAO,OAAO,aAAa,WAAW,OAAO,aAAa;CAC5D,QAAQ;EACN,OAAO;CACT;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ACnDA,SAAgB,gBAAgB,gBAA0C;CACxE,OAAO,iBAAiB,UAAU;AACpC;;;;;;;;;;AAyCA,SAAgB,iBAAiB,OAA6C;CAC5E,QAAQ,QAAQ;EAGd,IAAI,CAAC,UAAU,GAAG,GAAG;EACrB,IAAI,cAA+B;EACnC,IAAI;GACF,cAAc,gBAAgB,MAAM,eAAe,CAAC;EACtD,QAAQ;GAEN,cAAc;EAChB;EACA,IAAI,gBAAgB,SAClB,IAAI;GACF,MAAM,YAAY,GAAG;GACrB;EACF,QAAQ,CAER;EAEF,MAAM,aAAa,GAAG;CACxB;AACF;;;;;;;;;;;;;ACmCA,SAAgB,aAAa,KAA4C;CACvE,MAAM,UAAU,IAAI,IAAI,cAAc;CACtC,OAAO,YAAY,KAAA,KAAa,OAAO,QAAQ,SAAS,aAAa,UAAU,KAAA;AACjF;;;;;;;AAQA,SAAgB,SAAS,KAA4C;CACnE,MAAM,UAAU,IAAI,IAAI,OAAO;CAC/B,OAAO,YAAY,KAAA,KAAa,OAAO,QAAQ,aAAa,aAAa,UAAU,KAAA;AACrF;;;;;;;;;AAUA,SAAS,aACP,KACA,WACA,KACwC;CAExC,MAAM,aADa,IAAI,aAAa,UAAU,WAAW,WAAW,EAAE,IAAI,IAAI,CAClD,CAAC,CAAC,WAAW,YAAY;CACrD,OAAO;EAAE,WAAW,WAAW;EAAS,QAAQ,WAAW;CAAO;AACpE;;;;;;;AAQA,SAAgB,wBAAwB,KAA2D;CACjG,QAAQ,eAAe;EACrB,OAAO,QAAQ;GACb,MAAM,YAAY,aAAa,GAAG;GAClC,IAAI,cAAc,KAAA,GAAW,MAAM,IAAI,MAAM,2BAA2B;GACxE,MAAM,EAAE,WAAW,WAAW,aAAa,KAAK,WAAmC,GAAG;GACtF,MAAM,aACJ,WAAW,KAAA,KAAa,WAAW,QAAQ,OAAO,WAAW,YAAY,gBAAgB,SACpF,OAAkC,aACnC,KAAA;GACN,MAAM,YACJ,WAAW,KAAA,KAAa,WAAW,QAAQ,OAAO,WAAW,YAAY,eAAe,SACnF,OAAiC,YAClC,KAAA;GACN,OAAO,UAAU,KAAK,WAAW,KAAK,WAAW,YAAY,SAAS;EACxE;EACA,YAAY,EAEV,WAAW,QAAQ;GACjB,MAAM,YAAY,aAAa,GAAG;GAClC,IAAI,cAAc,KAAA,GAAW,MAAM,IAAI,MAAM,2BAA2B;GACxE,MAAM,EAAE,WAAW,WAAW,aAAa,KAAK,WAAmC,GAAG;GACtF,MAAM,aACJ,WAAW,KAAA,KAAa,WAAW,QAAQ,OAAO,WAAW,YAAY,gBAAgB,SACpF,OAAkC,aACnC,KAAA;GACN,MAAM,YACJ,WAAW,KAAA,KAAa,WAAW,QAAQ,OAAO,WAAW,YAAY,eAAe,SACnF,OAAiC,YAClC,KAAA;GACN,OAAO,UAAU,KAAK,WAAW,KAAK,WAAW,YAAY,SAAS,CAAC,CAAC;EAC1E,EACF;EACA,OAAO,EACL,OAAO;GACL,mBAAoB,SAAS,GAAG,CAAC,EAAE,SAAS,KAAK;GACjD,YAAY,aAAc,IAAI,GAA2D,gBAAgB,QAAQ;EACnH,EACF;EACA,SAAS,gBAAgB,GAAG;CAC9B;AACF;;;;;;;;;;;;;;;;;;;;AAkDA,SAAS,gBAAgB,KAAqC;CAC5D,OAAO,iBAAiB;EACtB,sBAAsB,IAAI,iBAAiB,IAAI,SAAS,MAAM,KAAA;EAC9D,cAAc,QAAQ;GACnB,IAAK,aAA2C,QAAQ,WAAW,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;EACzF;EACA,eAAe,QAAQ;GACrB,OAAO,KAAK,KAAK,UAAU,qBAAqB;EAClD;CACF,CAAC;AACH;;;;;;;;AAqBA,MAAM,kBAAgE;CACpE,mBAAmB,CAAC;CACpB,uBAAuB,KAAA;AACzB;;;;;;;;;;AAWA,SAAgB,aAAa,KAAyC;CACpE,MAAM,UAAU,IAAI,IAAI,WAAW;CACnC,OAAO,YAAY,KAAA,KAAa,QAAQ,YAAY,KAAA,IAAY,UAAU,KAAA;AAC5E;;;;;;;;;;;;AA4BA,SAAgB,qBAAqB,KAAc,WAA0C;CAC3F,MAAM,YAAY,aAAa,GAAG;CAClC,MAAM,eAAe,WAAW;CAChC,MAAM,YAAY,aAAa,GAAG,CAAC,EAAE,WAAW;CAChD,IAAI,cAAc,KAAA,KAAa,iBAAiB,KAAA,GAC9C,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE;CAEhC,OAAO;EACL,OAAO,EAAE,UAAU;EACnB,aAAa,WAAW,aAAa,KAAK,WAAW,WAAW,MAAM;EACtE,cAAc,SAAS;GACrB,aAAa,GAAG,CAAC,EAAE,cAAc,IAAI;EACvC;CACF;AACF;;;;;;AAOA,MAAM,gBAAuC;CAC3C,cAAc,EAAE,UAAU,2DAA2D;CACrF,WAAW,EAAE,UAAU,GAAG;AAC5B;;;;AC9PA,MAAM,UAAuD,EAC3D,UAAU,oBACZ;;AAGA,MAAM,gBAAgB;;AAGtB,MAAM,0BAAU,IAAI,IAAgD;;;;;;;AAQpE,SAAgB,UAAU,MAAkD;CAC1E,IAAI,OAAO,WAAW,4BAA4B,UAAU,YAAY,OAAO,QAAQ,QAAQ;CAC/F,MAAM,WAAW,QAAQ,IAAI,IAAI;CACjC,IAAI,aAAa,KAAA,GAAW,OAAO;CAenC,MAAM,UAAU,IAdI,SAAe,SAAS,WAAW;EACrD,MAAM,SAAS,SAAS,cAAc,QAAQ;EAC9C,OAAO,MAAM,GAAG,cAAc,GAAG,QAAQ;EACzC,OAAO,QAAQ;EACf,OAAO,eAAe;GACpB,IAAI,OAAO,WAAW,4BAA4B,UAAU,YAAY,QAAQ;QAC3E,uBAAO,IAAI,MAAM,MAAM,KAAK,UAAU,CAAC;EAC9C;EACA,OAAO,gBAAgB;GACrB,uBAAO,IAAI,MAAM,MAAM,KAAK,MAAM,CAAC;EACrC;EACA,SAAS,KAAK,OAAO,MAAM;CAC7B,CAEsB,CAAC,CAAC,OAAO,UAAmB;EAChD,QAAQ,OAAO,IAAI;EACnB,MAAM;CACR,CAAC;CACD,QAAQ,IAAI,MAAM,OAAO;CACzB,OAAO;AACT;;;;;;;AAQA,eAAsB,SAAgD,MAA4C;CAChH,MAAM,UAAU,IAAI;CACpB,MAAM,iBAAiB,WAAW,4BAA4B;CAC9D,IAAI,mBAAmB,KAAA,GAAW,MAAM,IAAI,MAAM,MAAM,KAAK,KAAK;CAClE,OAAO;AACT;;;;;;;;;;;;;;;;;;;;;;;;;ACjJA,MAAa,WAAW;;AAGxB,MAAa,MAAM;;CAEjB,MAAM;;CAEN,QAAQ;;CAER,QAAQ;;CAER,MAAM;;CAEN,OAAO;;CAEP,QAAQ;;CAER,QAAQ;;CAER,OAAO;;CAEP,OAAO;;CAEP,cAAc;;CAEd,aAAa;;CAEb,SAAS;AACX;AA2JA,YAAY,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AC7JzB,MAAM,sBAAsB;;AAM5B,MAAM,cAAqC,CAAC;;;;;;;;AAS5C,MAAM,eAAoC;CACxC,mBAAmB;CACnB,uBAAuB,KAAA;AACzB;;;;;;;AAQA,SAAgB,aAAa,OAAqC;CAChE,MAAM,EAAE,YAAY,UAAU,MAAM,aAAa,YAAY;CAC7D,MAAM,EAAE,QAAQ,WAAW;CAC3B,MAAM,QAAQ,UAAU,UAAU,KAAK;CAGvC,IAAI;CACJ,IAAI;CACJ,IAAI;EACF,QAAQ,YAAY,IAAI,EAAE;CAC5B,SAAS,OAAO;EACd,cAAc,UAAU,KAAK;CAC/B;CAIA,MAAM,SAAS,OAAO,SAAS;CAC/B,MAAM,aAAA,GAAYC,MAAAA,YAAAA,EAAa,aAAyB,OAAO,UAAU,QAAQ,GAAG,CAAC,MAAM,CAAC;CAC5F,MAAM,eAAA,GAAcA,MAAAA,YAAAA,OAAkB,OAAO,YAAY,GAAG,CAAC,MAAM,CAAC;CACpE,MAAM,SAAA,GAAQC,MAAAA,qBAAAA,CAAqB,WAAW,aAAa,WAAW;CAKtE,CAAA,GAAA,MAAA,UAAA,OAAgB;EACd,IAAI,UAAU,KAAA,GAAW;EACzB,OAAO,MAAM,MAAM;CACrB,GAAG,CAAC,KAAK,CAAC;CAEV,IAAI,gBAAgB,KAAA,KAAa,UAAU,KAAA,GAAW;EACpD,MAAM,SAAS;EACf,OACE,iBAAA,GAAA,kBAAA,IAAA,CAAC,WAAD;GAAS,WAAW,IAAI;GAAM,0BAAA;GAC5B,UAAA,iBAAA,GAAA,kBAAA,KAAA,CAAC,OAAD;IAAK,WAAW,IAAI;IAAO,MAAK;IAAhC,UAAA,CACE,iBAAA,GAAA,kBAAA,IAAA,CAAC,KAAD;KAAG,WAAW,IAAI;KAAe,UAAA,EAAE,aAAa;IAAK,CAAA,GACpD,WAAW,KAAA,IAAY,OAAO,iBAAA,GAAA,kBAAA,IAAA,CAAC,KAAD;KAAG,WAAW,IAAI;KAAc,UAAA;IAAU,CAAA,CACtE;;EACE,CAAA;CAEb;CAGA,MAAM,OAAyB;;CAG/B,MAAM,cACJ,iBAAA,GAAA,kBAAA,IAAA,CAAC,UAAD;EACE,MAAK;EACL,WAAW,IAAI;EACf,eAAe;GACb,IAAI,QAAQ,QAAQ,YAAY,EAAE,YAAY,KAAK,CAAC;EACtD;EAEC,UAAA,EAAE,KAAK;CACF,CAAA;CAKV,IAAI,MAAM,UAAU,mBAClB,OACE,iBAAA,GAAA,kBAAA,IAAA,CAAC,WAAD;EAAS,WAAW,IAAI;EAAM,0BAAA;EAC5B,UAAA,iBAAA,GAAA,kBAAA,KAAA,CAAC,OAAD;GAAK,WAAW,IAAI;GAApB,UAAA,CACE,iBAAA,GAAA,kBAAA,IAAA,CAAC,KAAD;IAAG,WAAW,IAAI;IAAc,MAAK;IAClC,UAAA,EAAE,iBAAiB;GACnB,CAAA,GACF,WACE;;CACE,CAAA;CAIb,MAAM,QACJ,MAAM,UAAU,iBACZ,KAAA,IACA,MAAM,UAAU,KAAA,IACb,MAAM,SAAS,MAAM,MAAM,QAC5B,UAAU,MAAM,KAAK;CAC7B,IAAI;CACJ,IAAI,MAAM,UAAU,UAAU,MAAM,UAAU,WAAW,SAAS,EAAE,SAAS;MACxE,IAAI,MAAM,UAAU,cAAc,MAAM,UAAU,gBAAgB,MAAM,UAAU,gBAAgB,SAAS,EAAE,MAAM,KAAK;MACxH,IAAI,MAAM,MAAM,UAAU,UAAU,SAAS,EAAE,UAAU,EAAE,MAAM,OAAO,MAAM,KAAK,YAAY,GAAG,EAAE,CAAC;MACrG,IAAI,MAAM,MAAM,UAAU,UAAU,SAAS,EAAE,aAAa;MAC5D,IAAI,MAAM,UAAU,UAAU,SAAS,EAAE,QAAQ;CAEtD,MAAM,QAAQ,MAAM,MAAM,UAAU,YAAY,MAAM,UAAU;CAChE,MAAM,QAAQ,CAAC,UAAU,MAAM,UAAU,YAAY,MAAM,UAAU;CACrE,MAAM,WAAW,MAAM,UAAU,eAAe,MAAM,MAAM,UAAU,aAAa,MAAM,aAAa;CAEtG,OACE,iBAAA,GAAA,kBAAA,KAAA,CAAC,WAAD;EAAS,WAAW,IAAI;EAAM,0BAAA;EAA9B,UAAA;GACG,WAAW,KAAA,KAAa,SAAS,WAChC,iBAAA,GAAA,kBAAA,KAAA,CAAC,OAAD;IAAK,WAAW,IAAI;IAAQ,MAAK;IAAjC,UAAA;KACG,WAAW,KAAA,IAAY,OAAO,iBAAA,GAAA,kBAAA,IAAA,CAAC,QAAD,EAAA,UAAO,OAAa,CAAA;KAClD,WACC,iBAAA,GAAA,kBAAA,KAAA,CAAA,kBAAA,UAAA,EAAA,UAAA,CACE,iBAAA,GAAA,kBAAA,IAAA,CAAC,QAAD,EAAA,UAAO,EAAE,UAAU,EAAQ,CAAA,GAC3B,iBAAA,GAAA,kBAAA,IAAA,CAAC,UAAD;MACE,MAAK;MACL,WAAW,IAAI;MACf,eAAe;OACb,KAAK,QAAQ;MACf;MAEC,UAAA,EAAE,SAAS;KACN,CAAA,CACR,EAAA,CAAA,IACA;KACH,QACC,MAAM,SAAS,KAAA,IACb,iBAAA,GAAA,kBAAA,IAAA,CAAC,UAAD;MACE,MAAK;MACL,WAAW,IAAI;MACf,eAAe;OACb,KAAU,QAAQ;MACpB;MAEC,UAAA,EAAE,MAAM,UAAU,iBAAiB,cAAc,OAAO;KACnD,CAAA,IAER,iBAAA,GAAA,kBAAA,IAAA,CAAC,UAAD;MACE,MAAK;MACL,WAAW,IAAI;MACf,eAAe;OACb,KAAK,QAAQ;MACf;MAEC,UAAA,EAAE,WAAW;KACR,CAAA,IAER;KACH,QAAQ,cAAc;IACpB;GACH,CAAA,IAAA;GAEH,MAAM,SAAS,KAAA,IAAY,OAC1B,iBAAA,GAAA,kBAAA,IAAA,CAAC,gBAAD;IACS;IACP,OAAO;IACP,SAAS,IAAI;IACb,OAAO,EAAE,OAAO;IACT;IACE;GACV,CAAA;GAEF,UAAU,KAAA,IAAY,OACrB,iBAAA,GAAA,kBAAA,IAAA,CAAC,KAAD;IAAG,WAAW,IAAI;IAAO,MAAK;IAC3B,UAAA,EAAE,UAAU,EAAE,SAAS,MAAM,CAAC;GAC9B,CAAA;EAEE;;AAEb;;;;;;;;;;;AA8BA,SAAS,eAAe,EAAE,OAAO,OAAO,SAAS,OAAO,OAAO,WAA2C;CACxG,MAAM,QAAA,GAAOC,MAAAA,OAAAA,CAA8B,IAAI;;CAE/C,MAAM,UAAA,GAASA,MAAAA,OAAAA,CAAwC,KAAA,CAAS;;CAEhE,MAAM,gBAAA,GAAeA,MAAAA,OAAAA,CAAO,CAAC;CAC7B,MAAM,CAAC,SAAS,eAAA,GAAcC,MAAAA,SAAAA,CAA2C,KAAA,CAAS;;CAElF,MAAM,CAAC,SAAS,eAAA,GAAcA,MAAAA,SAAAA,CAA6B,KAAA,CAAS;;CAEpE,MAAM,CAAC,OAAO,aAAA,GAAYA,MAAAA,SAAAA,CAAS,CAAC;;CAEpC,MAAM,CAAC,SAAS,eAAA,GAAcA,MAAAA,SAAAA,CAAS,CAAC;CAExC,MAAM,aAAa,mBAAmB,MAAM,aAAa,QAAQ;CACjE,MAAM,WAAW,MAAM,UAAU;CACjC,MAAM,OAAO,SAAS,MAAM,IAAI;;;;;;;CAQhC,MAAM,WAAA,GAAUD,MAAAA,OAAAA,CAAO;EAAE;EAAO;EAAS;EAAY;EAAU;EAAO;CAAQ,CAAC;CAC/E,QAAQ,UAAU;EAAE;EAAO;EAAS;EAAY;EAAU;EAAO;CAAQ;CAGzE,CAAA,GAAA,MAAA,UAAA,OAAgB;EACd,IAAI,YAAY,KAAA,GAAW;EAC3B,IAAI,QAAQ;EACZ,SAAc,UAAU,CAAC,CACtB,MAAM,WAAW;GAChB,IAAI,OAAO,iBAAiB,MAAM;EACpC,CAAC,CAAC,CACD,OAAO,UAAmB;GACzB,IAAI,OAAO,WAAW,UAAU,KAAK,CAAC;EACxC,CAAC;EACH,aAAa;GACX,QAAQ;EACV;CACF,GAAG,CAAC,SAAS,OAAO,CAAC;CAGrB,CAAA,GAAA,MAAA,gBAAA,OAAsB;EACpB,IAAI,YAAY,KAAA,GAAW;EAC3B,MAAM,UAAU,KAAK;EACrB,IAAI,YAAY,MAAM;EACtB,MAAM,WAAW,QAAQ;EACzB,IAAI;EACJ,IAAI;GACF,UAAU,QAAQ,SAAS;IACzB,YAAY,SAAS;IACrB,UAAU,SAAS;IAEnB,YAAY,SAAS,MAAM,aAAa,cAAc;IACtD,UAAU,SAAS,MAAM,aAAa;IACtC,OAAO,SAAS;IAChB,UAAU,QAAQ;KAGhB,QAAQ,QAAQ,QAAQ,GAAG;IAC7B;IACA,SAAS,SAAS;KAChB,MAAM,MAAM,IAAI;IAClB;GACF,CAAC;EACH,SAAS,OAAO;GACd,WAAW,UAAU,KAAK,CAAC;GAC3B;EACF;EACA,OAAO,UAAU;EACjB,aAAa,UAAU;EAGvB,MAAM,SAAS,SAAS,MAAM;EAC9B,IAAI,WAAW,KAAA,GAAW;GACxB,aAAa,UAAU,OAAO;GAC9B,WAAW,SAAS,QAAQ,KAAK;EACnC;EACA,QAAQ,YAAY,SAAS,MAAM,aAAa,IAAI;EACpD,IAAI,SAAS,WAAW,SAAS,MAAM,aAAa,MAAM,UAAU,SAAS,SAAS,SAAS,OAAO,KAAK;OACtG,IAAI,SAAS,MAAM,aAAa,MAAM,aAAa,SAAS,SAAS,KAAK;EAC/E,WAAW,KAAA,CAAS;EACpB,UAAU,UAAU,QAAQ,CAAC;EAC7B,aAAa;GACX,QAAQ,QAAQ;GAChB,IAAI,OAAO,YAAY,SAAS,OAAO,UAAU,KAAA;EACnD;CACF,GAAG;EAAC;EAAS;EAAO;CAAO,CAAC;CAG5B,CAAA,GAAA,MAAA,gBAAA,OAAsB;EACpB,MAAM,WAAW,OAAO;EACxB,IAAI,aAAa,KAAA,GAAW;EAC5B,MAAM,SAAS,MAAM;EACrB,IAAI,WAAW,KAAA,KAAa,OAAO,YAAY,aAAa,SAAS;EACrE,aAAa,UAAU,OAAO;EAC9B,WAAW,UAAU,QAAQ,KAAK;CACpC,GAAG;EAAC,MAAM;EAAQ;EAAO;CAAK,CAAC;CAG/B,CAAA,GAAA,MAAA,gBAAA,OAAsB;EACpB,OAAO,SAAS,WAAW;CAC7B,GAAG;EAAC;EAAO;EAAO;CAAK,CAAC;CAGxB,CAAA,GAAA,MAAA,gBAAA,OAAsB;EACpB,MAAM,WAAW,OAAO;EACxB,IAAI,aAAa,KAAA,GAAW;EAC5B,IAAI,CAAC,SAAS,QAAQ,YAAY,QAAQ,GAAG;EAC7C,MAAM,UAAU,KAAK;EACrB,IAAI,YAAY,MAAM;EACtB,MAAM,WAAW,QAAQ;EACzB,IAAI,CAAC,SAAS,WAAW,SAAS,MAAM,aAAa,MAAM;EAC3D,UAAU,SAAS,UAAU,SAAS,OAAO,KAAK;CACpD,GAAG;EAAC;EAAY;EAAU;EAAO;CAAK,CAAC;CAKvC,CAAA,GAAA,MAAA,gBAAA,OAAsB;EACpB,MAAM,WAAW,OAAO;EACxB,IAAI,aAAa,KAAA,GAAW;EAC5B,SAAS,YAAY,MAAM,aAAa,IAAI;EAC5C,MAAM,UAAU,KAAK;EACrB,IAAI,YAAY,MAAM;EACtB,IAAI,WAAW,MAAM,aAAa,MAAM,UAAU,SAAS,UAAU,OAAO,KAAK;OAC5E,IAAI,MAAM,aAAa,MAAM,aAAa,UAAU,KAAK;CAChE,GAAG;EAAC;EAAS,MAAM;EAAU,MAAM;EAAM,MAAM;EAAM;EAAO;CAAK,CAAC;CAGlE,CAAA,GAAA,MAAA,UAAA,OAAgB;EACd,OAAO,SAAS,SAAS,KAAK;CAChC,GAAG,CAAC,OAAO,KAAK,CAAC;CAEjB,CAAA,GAAA,MAAA,UAAA,OAAgB;EACd,IAAI,CAAC,WAAW,MAAM,aAAa,MAAM;EACzC,OAAO,SAAS,MAAM;CACxB,GAAG;EAAC;EAAS,MAAM;EAAU;CAAK,CAAC;CAGnC,CAAA,GAAA,MAAA,gBAAA,OAAsB;EACpB,MAAM,UAAU,KAAK;EACrB,IAAI,YAAY,MAAM;EACtB,MAAM,WAAW,IAAI,qBAAqB;GACxC,MAAM,WAAW,OAAO;GACxB,IAAI,aAAa,KAAA,GAAW;GAC5B,MAAM,WAAW,QAAQ;GACzB,IAAI,CAAC,SAAS,WAAW,SAAS,MAAM,aAAa,MAAM;GAC3D,IAAI,QAAQ,gBAAgB,KAAK,QAAQ,iBAAiB,GAAG;GAC7D,UAAU,SAAS,UAAU,SAAS,OAAO,KAAK;EACpD,CAAC;EACD,SAAS,QAAQ,OAAO;EACxB,aAAa;GACX,SAAS,WAAW;EACtB;CACF,GAAG,CAAC,OAAO,KAAK,CAAC;CAEjB,OACE,iBAAA,GAAA,kBAAA,KAAA,CAAC,OAAD;EAAK,WAAW,IAAI;EAApB,UAAA,CACE,iBAAA,GAAA,kBAAA,IAAA,CAAC,OAAD;GAAK,WAAW,IAAI;GAAM,KAAK;EAAO,CAAA,GACrC,YAAY,KAAA,IAAY,OACvB,iBAAA,GAAA,kBAAA,KAAA,CAAC,OAAD;GAAK,WAAW,IAAI;GAAS,MAAK;GAAlC,UAAA,CACE,iBAAA,GAAA,kBAAA,IAAA,CAAC,KAAD;IAAG,WAAW,IAAI;IAAe,UAAA,EAAE,UAAU,EAAE,SAAS,QAAQ,CAAC;GAAK,CAAA,GACtE,iBAAA,GAAA,kBAAA,IAAA,CAAC,UAAD;IACE,MAAK;IACL,WAAW,IAAI;IACf,eAAe;KAEb,WAAW,KAAA,CAAS;KACpB,YAAY,UAAU,QAAQ,CAAC;IACjC;IAEC,UAAA,EAAE,OAAO;GACJ,CAAA,CACL;EAEJ,CAAA,CAAA;;AAET;;;;;;;;;;;AAYA,SAAS,WAAW,QAA6B,QAAqB,OAA+B;CACnG,MAAM,QAAQ,OAAO;CACrB,IAAI,MAAM,SAAS,YAAY;EAE7B,IAAI,MAAM,SAAS,KAAA,GAAW,OAAO,MAAM,MAAM,KAAK,MAAM,MAAM,KAAK,IAAI;EAC3E,OAAO,MAAM,MAAM,UAAU,UAAU;GACrC,MAAM,YAAY,OAAO,QAAQ;EACnC,CAAC;EACD;CACF;CACA,OAAO,MAAM,MAAM,QAAQ,UAAU;EACnC,MAAM,YAAY,OAAO,QAAQ;CACnC,CAAC;AACH;;;;;;;;;;;AAYA,SAAS,UACP,MACA,QACA,OACA,OACM;CACN,IAAI,KAAK,gBAAgB,KAAK,KAAK,iBAAiB,GAAG;CACvD,MAAM,WAAW,OAAO,IAAI;CAC5B,IAAI,aAAa,KAAA,GAAW;CAC5B,MAAM,cAAc,MAAM;CAC1B,MAAM,OAAO,aAAa,YAAY,KAAA,IAAY,SAAS,OAAO,KAAK,IAAI,SAAS,MAAM,YAAY,OAAO;CAC7G,MAAM,OAAO,aAAa,YAAY,KAAA,IAAY,SAAS,OAAO,KAAK,IAAI,SAAS,MAAM,YAAY,OAAO;CAE7G,IAAI,OAAO,KAAK,OAAO,GAAG;CAE1B,IAAI,SAAS,SAAS,QAAQ,SAAS,SAAS,MAAM,OAAO,OAAO,MAAM,IAAI;CAC9E,MAAM,OAAO,MAAM,IAAI;AACzB;;;;;;;AAQA,SAAS,aAAa,QAA6B,OAAoC;CACrF,MAAM,OAAO,SAAS,MAAM,IAAI;CAChC,IAAI,SAAS,KAAA,GAAW,OAAO,OAAO,KAAK,MAAM,KAAK,IAAI;AAC5D;;;;;;;;;;;AAYA,SAAS,SAAS,MAAmG;CACnH,IAAI,SAAS,KAAA,GAAW,OAAO,KAAA;CAC/B,MAAM,QAAQ;CACd,IAAI,OAAO,MAAM,SAAS,YAAY,OAAO,MAAM,SAAS,UAAU,OAAO,KAAA;CAC7E,OAAO;EAAE,MAAM,MAAM;EAAM,MAAM,MAAM;CAAK;AAC9C;;;;;;;AAQA,SAAS,mBAAmB,MAAsB;CAChD,OAAO,SAAS,KAAK,sBAAsB,GAAG,KAAK,IAAI;AACzD;;;;;;;AAQA,SAAS,UAAU,OAAuB;CACxC,OAAO,OAAO,OAAO,IAAI,KAAK,IAAI,EAAE,KAA0B,IAAI;AACpE;;;;;;;AAQA,SAAS,UAAU,OAAwB;CACzC,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AAC9D;;;;ACrhBA,YACE,+BACA,kPAGF;;;;;;;AAWA,SAAgB,cAAc,OAAsC;CAClE,MAAM,EAAE,QAAQ,MAAM,WAAW;CACjC,MAAM,OAAO,MAAM,YAAY,IAAI,KAAK,UAA6C,OAAO,MAAM,SAAS,OAAO,KAAK;CACvH,MAAM,SAAS,SAAS,KAAA,KAAa,SAAS,KAAK,OAAO,KAAA,MAAc,IAAI,SAAS,EAAE,OAAO;CAC9F,OACE,iBAAA,GAAA,kBAAA,KAAA,CAAC,QAAD;EAAM,WAAU;EAAhB,UAAA,CACE,iBAAA,GAAA,kBAAA,IAAA,CAAC,QAAD;GAAM,WAAU;GAAmC,eAAY;GAE7D,UAAA,iBAAA,GAAA,kBAAA,KAAA,CAAC,OAAD;IAAK,OAAM;IAAK,QAAO;IAAK,SAAQ;IAAY,MAAK;IAArD,UAAA,CACE,iBAAA,GAAA,kBAAA,IAAA,CAAC,QAAD;KAAM,GAAE;KAAgB,QAAO;IAAgB,CAAA,GAC/C,iBAAA,GAAA,kBAAA,IAAA,CAAC,QAAD;KAAM,GAAE;KAAW,QAAO;IAAgB,CAAA,CACvC;;EACD,CAAA,GACL,KACG;;AAEV;;;;ACXA,MAAa,OAAO;;AAGpB,MAAa,SAAS;CAAC;CAAS;CAAU;CAAgB;AAAkB;;AAG5E,MAAM,SAAS;;AAGf,MAAM,WAAW;;AAGjB,MAAM,cAAc;;;;;;AAOpB,SAAgB,MAAM,KAAoB;CAC7B,IAAI,OAAO;EAAC;EAAS;EAAU;EAAgB;CAAkB,IAAI,WAAW;EACzF,cAAc,OAAO,MAAM;EAC3B,OAAO,aACC,OAAO,OAAO,SAASE,IAAW;GAAMC;GAAcC;EAAS,CAAC,GACtE,+BACF;;;;;;;;EASA,MAAM,aAAa,wBAAwB,MAA4B;EAGvE,OAAO,aAEH,OAAO,iBAAiB,SAAS;GAC/B,IAAI;GACJ,MAAM;GACN,UAAU;GAEV,UAAU;GACV,aAAa,EAAE,OAAO;GACtB,OAAO,CACL;IACE,IAAI;IACJ,OAAO;IACP,aAAa,EAAE,KAAK;IACpB,mBAAmB,EAAE,aAAa;IAGlC,MAAMC,sCAAAA;GACR,CACF;EACF,CAAC,GACH,iCACF;EAGA,OAAO,aAEH,OAAO,MAAM,OAAO,gCAClB,OAAO,MAAM,SACX;GAAE,MAAM;GAA0B,KAAK;GAAQ,QAAQ;EAAW,GAClE,YACF,CACF,GACF,6BACF;EAGA,OAAO,aAEH,OAAO,MAAM,OAAO,sCAClB,OAAO,MAAM,SACX;GAAE,MAAM;GAAgC,KAAK;GAAQ,QAAQ;EAAW,GACxE,aACF,CACF,GACF,8BACF;EAGA,OAAO,aAEH,OAAO,MAAM,OAAO,uCAClB,OAAO,MAAM,SACX;GACE,MAAM;GACN,KAAK;GACL,SAAS,cAAc,qBAAqB,QAA8B,SAAS;EACrF,GACA,aACF,CACF,GACF,8BACF;EAGA,IAAI,aAAa,MAA4B,MAAM,KAAA,GACjD,QAAQ,KAAK,qDAAqD;CAEtE,CAAC;AAEH"}
package/lib/index.js ADDED
@@ -0,0 +1,11 @@
1
+ import { registerRoutes } from './routes.js';
2
+ /** 依赖门:只有 `webServer`(懒加载块路由)。 */
3
+ export const inject = ['webServer'];
4
+ /**
5
+ * 挂载 host 半。
6
+ *
7
+ * @param ctx - host 根上下文。
8
+ */
9
+ export function apply(ctx) {
10
+ ctx.effect(() => registerRoutes(ctx), 'dsh-devtools-terminal: bundle route');
11
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * 终端链接的**落点决策**:Ctrl/Cmd+点击输出里的 URL,交给谁打开。
3
+ *
4
+ * ## 为什么单独一个纯模块
5
+ *
6
+ * 「面板优先、不可用回退系统浏览器」这条规则本身只有几行,但它是本功能唯一的
7
+ * 行为契约:面板临时抛错要能回退、非 http(s) 一律不碰、普通点击根本走不到这里
8
+ * (修饰键判断在 `link-provider.ts`)。这些分支在浏览器里验证成本高(要真 xterm +
9
+ * 真鼠标 + 真右栏),放到 host 编译(`lib/link-target.js`)就能用 `node --test`
10
+ * 把每条分支钉死 —— 与 `links.ts` 同一个理由。
11
+ *
12
+ * ## 落点只有两个
13
+ *
14
+ * - `panel`:DSH 右栏的**浏览器插件页签**(`@deepseek-ai/dsh-client-ui-sidebar-browser`,
15
+ * web 是 iframe、桌面端 carrier 是原生 `<webview>`、移动端在 WebView 里同样是
16
+ * iframe)。打开的 URL 由平台校验,非法地址它自己会拒绝。
17
+ * - `external`:`window.open(..., 'noopener,noreferrer')` —— 浏览器里是新标签页,
18
+ * 桌面端经 carrier 的 `linkBridge` 交给系统浏览器。这是**回退**:profile 没启用
19
+ * 右栏浏览器页签(`sidebarRightTabs.get('browser')` 为 undefined)、或者面板这次
20
+ * 真的没打开成功时走它。
21
+ *
22
+ * 模块本身**不碰 DOM / cordis**:`window.open` 与 `ctx.sidebarRight` 都由调用方
23
+ * 以端口({@link LinkOpenPorts})注入,于是这里只剩判断。
24
+ */
25
+ import { isHttpUrl } from './links.js';
26
+ /**
27
+ * 决定一次打开该落到哪儿。
28
+ *
29
+ * 面板可用就优先面板 —— 这是本插件要的产品行为(用户要的是"在 DSH 插件浏览器
30
+ * 里打开",而不是跳出去)。不可用(profile 关掉了那一行 / 平台版本没有这个页签
31
+ * 类型)才回退系统浏览器。
32
+ *
33
+ * @param panelAvailable - 右栏浏览器页签当前是否可用。
34
+ * @returns 落点。
35
+ */
36
+ export function linkDestination(panelAvailable) {
37
+ return panelAvailable ? 'panel' : 'external';
38
+ }
39
+ /**
40
+ * 造一个「打开终端链接」的动作。
41
+ *
42
+ * 语义(与 VSCode 终端一致):只认 http(s);面板优先;面板这次没开成功就回退
43
+ * 系统浏览器 —— **不能因为面板抛错就什么都不做**,那等于点击没反应。
44
+ *
45
+ * @param ports - 外部动作。
46
+ * @returns 打开动作(非 http(s) 静默忽略)。
47
+ */
48
+ export function createLinkOpener(ports) {
49
+ return (url) => {
50
+ // 防御:调用方(link-provider)已经验过一次,但这是公开导出的动作,
51
+ // 别让 `file:` / `javascript:` 之类从别的调用点漏进来。
52
+ if (!isHttpUrl(url))
53
+ return;
54
+ let destination = 'external';
55
+ try {
56
+ destination = linkDestination(ports.panelAvailable());
57
+ }
58
+ catch {
59
+ // 探测本身抛(服务正在卸载):按不可用处理,不要连累回退。
60
+ destination = 'external';
61
+ }
62
+ if (destination === 'panel') {
63
+ try {
64
+ ports.openInPanel(url);
65
+ return;
66
+ }
67
+ catch {
68
+ // 面板这次没开起来:继续往下走回退,不能把这一下点击吞掉。
69
+ }
70
+ }
71
+ ports.openExternal(url);
72
+ };
73
+ }
package/lib/links.js ADDED
@@ -0,0 +1,222 @@
1
+ /**
2
+ * 终端链接的**纯逻辑**:URL 识别、折行拼接、字符→单元格映射。
3
+ *
4
+ * ## 为什么单独放这里(不塞进客户端半)
5
+ *
6
+ * 客户端半进浏览器、只能靠 E2E 验;而这几个函数是纯字符串/坐标运算,
7
+ * 放在 host 可编译进 `lib/`,用 `node --test` 就能把边界钉死
8
+ * (折行、宽字符、中文标点、成对括号……)。客户端分块再 import 同一份代码
9
+ * (tsdown 会内联进去)。
10
+ *
11
+ * ## 只依赖「行」的最小结构
12
+ *
13
+ * xterm 的 `IBufferLine` 在这里只以结构类型出现(`isWrapped` / `length` /
14
+ * `getCell`),不 import xterm —— 于是测试可以喂假行,客户端也照常编译。
15
+ */
16
+ /** 一条逻辑行最多往回/往前拼多少行(防病态 scrollback 把主线程卡住)。 */
17
+ export const MAX_LOGICAL_ROWS = 256;
18
+ /**
19
+ * URL 字符集。
20
+ *
21
+ * 停止符比一般实现多两类:
22
+ * - `\u3000-\u303f`、`\u4e00-\u9fff`、`\uff00-\uffef`:中文/全角标点。终端输出
23
+ * 常见「地址:https://x 然后紧跟中文」,不排掉就会把后半句吃进 URL。
24
+ * - 引号/反引号/尖括号/反斜杠:shell 引号、markdown 包裹的常见形式。
25
+ * 末尾的标点另外在 {@link trimTrailing} 里剥,因为 `)`/`。` 有时属于 URL 本身。
26
+ */
27
+ const URL_PATTERN = /https?:\/\/[^\s\u0000-\u001f\u007f<>"'`\\\u2000-\u206f\u3000-\u303f\u4e00-\u9fff\uff00-\uffef]+/gi;
28
+ /** 一律剥掉的收尾标点(中英文都算)。 */
29
+ const TRAILING_PUNCTUATION = /[.,;:!?,。;:!?、'"'`)】》」』…]/;
30
+ /** `http://a` 的长度:短于它的候选不可能是合法 URL。 */
31
+ const MIN_URL_LENGTH = 'http://a'.length;
32
+ /**
33
+ * 判断字符串是不是可打开的 http(s) 地址。
34
+ *
35
+ * @param text - 候选文本。
36
+ * @returns http/https 且能被 URL 解析时为 true。
37
+ */
38
+ export function isHttpUrl(text) {
39
+ try {
40
+ const parsed = new URL(text);
41
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:';
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ }
47
+ /** 统计子串出现次数(判断收尾的 `)` 有没有配对的 `(`)。 */
48
+ function countOf(text, char) {
49
+ let count = 0;
50
+ for (let index = 0; index < text.length; index++)
51
+ if (text[index] === char)
52
+ count++;
53
+ return count;
54
+ }
55
+ /**
56
+ * 剥掉 URL 末尾不属于它的标点。
57
+ *
58
+ * 两种规则:常见中英文句读一律剥;收尾的 `)`/`]`/`}` 只有在**没有配对的开括号**
59
+ * 时才剥(`https://en.wikipedia.org/wiki/Foo_(bar)` 保持完整)。
60
+ *
61
+ * @param url - 正则刚匹配出来的候选。
62
+ * @returns 剥过尾巴的候选(可能为空串)。
63
+ */
64
+ export function trimTrailing(url) {
65
+ let candidate = url;
66
+ while (candidate.length > 0) {
67
+ const last = candidate[candidate.length - 1] ?? '';
68
+ if (TRAILING_PUNCTUATION.test(last)) {
69
+ candidate = candidate.slice(0, -1);
70
+ continue;
71
+ }
72
+ if (last === ')' && countOf(candidate, '(') < countOf(candidate, ')')) {
73
+ candidate = candidate.slice(0, -1);
74
+ continue;
75
+ }
76
+ if (last === ']' && countOf(candidate, '[') < countOf(candidate, ']')) {
77
+ candidate = candidate.slice(0, -1);
78
+ continue;
79
+ }
80
+ if (last === '}' && countOf(candidate, '{') < countOf(candidate, '}')) {
81
+ candidate = candidate.slice(0, -1);
82
+ continue;
83
+ }
84
+ break;
85
+ }
86
+ return candidate;
87
+ }
88
+ /**
89
+ * 找出一段文本里的所有 http(s) URL。
90
+ *
91
+ * @param text - 一条逻辑行的纯文本。
92
+ * @returns 命中列表(`end` 开区间)。
93
+ */
94
+ export function findUrls(text) {
95
+ const urls = [];
96
+ if (typeof text !== 'string' || text.length < MIN_URL_LENGTH)
97
+ return urls;
98
+ URL_PATTERN.lastIndex = 0;
99
+ let match;
100
+ while ((match = URL_PATTERN.exec(text)) !== null) {
101
+ const candidate = trimTrailing(match[0]);
102
+ // 正则已经吃掉尾部标点,下一轮从候选末尾继续,避免重叠匹配。
103
+ URL_PATTERN.lastIndex = match.index + Math.max(candidate.length, 1);
104
+ if (candidate.length < MIN_URL_LENGTH || !isHttpUrl(candidate))
105
+ continue;
106
+ urls.push({ url: candidate, start: match.index, end: match.index + candidate.length });
107
+ }
108
+ return urls;
109
+ }
110
+ /**
111
+ * 从请求行往回走到逻辑行开头,再把续行拼起来。
112
+ *
113
+ * 终端里的「一行」在 buffer 里可能是多行:**只有续行**的 `isWrapped` 为 true。
114
+ * URL 被折成两行是常见情况,所以识别必须以逻辑行为单位。
115
+ *
116
+ * @param getLine - `(row0) => 行 | undefined`,row0 为 0 基行号。
117
+ * @param row - 请求行(0 基)。
118
+ * @returns 逻辑行;拿不到行时返回 null。
119
+ */
120
+ export function readLogicalLine(getLine, row) {
121
+ let line = getLine(row);
122
+ if (line === undefined)
123
+ return null;
124
+ // 1) 往回找逻辑行开头:请求行自己是续行时,前面还有内容。
125
+ let start = row;
126
+ let back = 0;
127
+ while (start > 0 && line.isWrapped && back < MAX_LOGICAL_ROWS) {
128
+ start--;
129
+ back++;
130
+ const previous = getLine(start);
131
+ if (previous === undefined)
132
+ break;
133
+ line = previous;
134
+ }
135
+ // 2) 从开头往后把所有续行拼起来,同时保留「文本下标 → 单元格」的表。
136
+ let text = '';
137
+ const cells = [];
138
+ let current = start;
139
+ let guard = 0;
140
+ while (guard < MAX_LOGICAL_ROWS) {
141
+ const currentLine = getLine(current);
142
+ if (currentLine === undefined)
143
+ break;
144
+ // current > start 时,非续行说明逻辑行到此为止。
145
+ if (current > start && !currentLine.isWrapped)
146
+ break;
147
+ for (let col = 0; col < currentLine.length; col++) {
148
+ const cell = currentLine.getCell(col);
149
+ if (cell === undefined)
150
+ continue;
151
+ const width = cell.getWidth();
152
+ // 宽字符的第二个单元格是占位符(width 0),跳过:它在文本里只算一个字符。
153
+ if (width === 0)
154
+ continue;
155
+ const chars = cell.getChars() === '' ? ' ' : cell.getChars();
156
+ for (let index = 0; index < chars.length; index++)
157
+ cells.push({ row: current, col, width });
158
+ text += chars;
159
+ }
160
+ current++;
161
+ guard++;
162
+ }
163
+ return { text, cells };
164
+ }
165
+ /**
166
+ * 把「文本下标区间」切成按行分段的单元格区间。
167
+ *
168
+ * xterm 的 ILink 可以跨行,但渲染器按行画下划线;按行切段最省事,每段都带完整
169
+ * URL 文本(点任何一段打开的都是整个 URL)。
170
+ *
171
+ * @param cells - {@link readLogicalLine} 的 cells。
172
+ * @param start - 起始文本下标(含)。
173
+ * @param end - 结束文本下标(不含)。
174
+ * @returns 按行的分段。
175
+ */
176
+ export function segmentsOf(cells, start, end) {
177
+ const segments = [];
178
+ let current = null;
179
+ for (let index = start; index < end; index++) {
180
+ const cell = cells[index];
181
+ if (cell === undefined)
182
+ continue;
183
+ const lastCol = cell.col + cell.width - 1;
184
+ if (current !== null && current.row === cell.row) {
185
+ current.endCol = lastCol;
186
+ }
187
+ else {
188
+ if (current !== null)
189
+ segments.push(current);
190
+ current = { row: cell.row, startCol: cell.col, endCol: lastCol };
191
+ }
192
+ }
193
+ if (current !== null)
194
+ segments.push(current);
195
+ return segments;
196
+ }
197
+ /**
198
+ * 生成请求行上的链接范围(1 基、含首尾,xterm ILink 的坐标约定)。
199
+ *
200
+ * @param getLine - `(row0) => 行 | undefined`。
201
+ * @param row - 请求行(0 基)。
202
+ * @param makeLink - `(url, range) => T`,由调用方决定 ILink 的 activate 实现。
203
+ * @returns 请求行上的链接。
204
+ */
205
+ export function linksForRow(getLine, row, makeLink) {
206
+ const logical = readLogicalLine(getLine, row);
207
+ if (logical === null)
208
+ return [];
209
+ const links = [];
210
+ for (const entry of findUrls(logical.text)) {
211
+ for (const segment of segmentsOf(logical.cells, entry.start, entry.end)) {
212
+ // 只回答被问的那一行:xterm 是按行查询的,返回别行会被判成重叠链接。
213
+ if (segment.row !== row)
214
+ continue;
215
+ links.push(makeLink(entry.url, {
216
+ start: { x: segment.startCol + 1, y: segment.row + 1 },
217
+ end: { x: segment.endCol + 1, y: segment.row + 1 },
218
+ }));
219
+ }
220
+ }
221
+ return links;
222
+ }