dsh-oh-my-terminal 0.2.7 → 0.2.8

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.
package/CLAUDE.md CHANGED
@@ -65,6 +65,8 @@ src/
65
65
  │ ├── dropdown.tsx # +号旁下拉菜单组件(新建/拆分/按种类新建)
66
66
  │ ├── side-list.tsx # 右侧终端列表面板(树形前缀 + 右键重命名 + useMemo 缓存)
67
67
  │ ├── shortcut-bridge.ts # shortcuts 服务接入信号桥(命令注册状态 + 面板切换回调,软探测兼容旧宿主)
68
+ │ ├── compat.ts # DSH-better-sidebar 兼容模式检测(软探测 ctx.betterSidebar + 服务最小接口声明)
69
+ │ ├── sidebar-tab.tsx # 侧边栏终端 tab 组件(兼容模式:嵌入 DSH-better-sidebar 底部工作台,不绑定 DSH sessionId)
68
70
  │ ├── terminal/
69
71
  │ │ ├── reducer.ts # 终端状态 reducer(terminalReducer 纯函数 + createInitialState)
70
72
  │ │ ├── use-terminal-state.ts # 终端状态管理 Hook(useTerminalState:useReducer 封装 + 启动恢复)
@@ -117,9 +119,13 @@ src/
117
119
  client.tsx → client/types.ts, client/icons.tsx, client/clipboard.ts,
118
120
  client/dropdown.tsx, client/side-list.tsx, client/hooks.ts,
119
121
  client/styles.ts, client/term-pane.tsx, client/settings/,
120
- client/shortcut-bridge.ts, logger.ts
122
+ client/shortcut-bridge.ts, client/compat.ts, client/sidebar-tab.tsx,
123
+ logger.ts
121
124
  client/hooks.ts → client/terminal/*(re-export 兼容壳:reducer/use-terminal-state/use-panel-geometry/use-tabs)
122
125
  client/shortcut-bridge.ts → client/types.ts, constants.ts, logger.ts
126
+ client/compat.ts → logger.ts(软探测 ctx.betterSidebar,声明服务最小接口)
127
+ client/sidebar-tab.tsx → client/hooks.ts, client/term-pane.tsx, client/dropdown.tsx,
128
+ client/side-list.tsx, client/icons.tsx, client/compat.ts, logger.ts
123
129
  client/terminal/reducer.ts → client/types.ts, logger.ts
124
130
  client/terminal/use-terminal-state.ts → client/terminal/reducer.ts, client/types.ts, logger.ts
125
131
  client/terminal/use-panel-geometry.ts → client/types.ts, constants.ts, logger.ts
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-oh-my-terminal
2
2
 
3
- [![version](https://img.shields.io/badge/version-0.2.7-blue)](package.json)
3
+ [![version](https://img.shields.io/badge/version-0.2.8-blue)](package.json)
4
4
  [![license](https://img.shields.io/badge/license-Apache--2.0-green)](LICENSE)
5
5
  [![node](https://img.shields.io/badge/node-%5E20.19.0%20%7C%7C%20%3E%3D22.0.0-brightgreen)](package.json)
6
6
 
@@ -18,6 +18,7 @@ A bottom terminal panel plugin for DSH Web GUI. Powered by `@lydell/node-pty`, i
18
18
  - **Cross-platform**: Windows ConPTY and POSIX openpty are selected automatically by `@lydell/node-pty`; pre-compiled binaries ship with the package, nothing to compile
19
19
  - **Profile-driven terminals**: a configurable terminal profile table (type / name / path) drives new-terminal creation; profiles are auto-detected at startup and can be added, renamed, and edited in the settings UI
20
20
  - **Configurable fonts**: font family, size, and line height are adjustable and hot-reload on open terminals
21
+ - **DSH-better-sidebar compatibility**: auto-detects [DSH-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar) at runtime and switches to compat mode — registers a "Terminal" tab in its bottom workbench instead of the standalone dock; PTY sessions are not bound to the DSH session write handle, eliminating conflicts with DSH-better-sidebar's own terminal. When the host supports `TabDescriptor.rightActions` (v0.25.0+), the terminal's action buttons (new / split / restart) are injected into the tab strip's right end, VSCode-style; otherwise it falls back to a header row inside the tab content area
21
22
 
22
23
  ## Installation
23
24
 
@@ -96,6 +97,8 @@ src/
96
97
  │ ├── icons.tsx # SVG icon components
97
98
  │ ├── clipboard.ts # Clipboard utility functions
98
99
  │ ├── shortcut-bridge.ts # Shortcuts service integration (command registration + toggle bridge)
100
+ │ ├── compat.ts # DSH-better-sidebar compat mode detection (soft-probe ctx.betterSidebar + service interface)
101
+ │ ├── sidebar-tab.tsx # Sidebar terminal tab (compat mode: embed in DSH-better-sidebar bottom workbench, no DSH session binding)
99
102
  │ ├── settings/ # Settings UI (types / store / card / profile-table / api / styles)
100
103
  │ └── terminal/ # Terminal tab state (use-tabs / reducer / use-terminal-state / geometry)
101
104
  ├── persistence.ts # Session persistence (log storage, metadata, startup restore)
package/README.zh.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-oh-my-terminal
2
2
 
3
- [![version](https://img.shields.io/badge/version-0.2.7-blue)](package.json)
3
+ [![version](https://img.shields.io/badge/version-0.2.8-blue)](package.json)
4
4
  [![license](https://img.shields.io/badge/license-Apache--2.0-green)](LICENSE)
5
5
  [![node](https://img.shields.io/badge/node-%5E20.19.0%20%7C%7C%20%3E%3D22.0.0-brightgreen)](package.json)
6
6
 
@@ -16,6 +16,7 @@ DSH Web GUI 的底部终端面板插件,基于 `@lydell/node-pty` 提供多终
16
16
  - **跨平台**:Windows ConPTY / POSIX openpty,由 `@lydell/node-pty` 自动选择,二进制随 tarball 分发,安装即用
17
17
  - **配置表驱动终端**:终端配置表(type / name / path)驱动新建终端;启动时自动探测 $PATH 中的终端,可在设置界面新增、改名、编辑路径
18
18
  - **字体可配置**:字体族、字号、行高可配置,配置变更对已打开终端即时生效
19
+ - **DSH-better-sidebar 兼容**:运行时自动检测 [DSH-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar),检测到时切换为兼容模式——通过 `ctx.betterSidebar.registerTab()` 注册"终端"tab 嵌入其底部工作台,替代独立底部面板;PTY 会话不绑定 DSH session 的 write handle,从根源消除与 DSH-better-sidebar 自带终端的冲突。当宿主支持 `TabDescriptor.rightActions`(v0.25.0+)时,终端操作按钮(新建 / 拆分 / 重启)注入 tab 标签条右端,VSCode 风格单层栏;不支持时回退为 tab 内容区顶部头部栏(两层显示)
19
20
 
20
21
  ## 安装
21
22
 
@@ -94,6 +95,8 @@ src/
94
95
  │ ├── icons.tsx # SVG 图标组件
95
96
  │ ├── clipboard.ts # 剪贴板工具函数
96
97
  │ ├── shortcut-bridge.ts # shortcuts 服务接入桥(命令注册 + 面板切换回调)
98
+ │ ├── compat.ts # DSH-better-sidebar 兼容模式检测(软探测 ctx.betterSidebar + 服务最小接口声明)
99
+ │ ├── sidebar-tab.tsx # 侧边栏终端 tab 组件(兼容模式:嵌入 DSH-better-sidebar 底部工作台,不绑定 DSH sessionId)
97
100
  │ ├── settings/ # 设置界面(types / store / card / profile-table / api / styles)
98
101
  │ └── terminal/ # 终端标签页状态(use-tabs / reducer / use-terminal-state / geometry)
99
102
  ├── persistence.ts # 会话持久化层(日志落盘 / 元数据 / 启动恢复)
package/lib/client.js CHANGED
@@ -166,7 +166,7 @@ __export(client_exports, {
166
166
  name: () => name
167
167
  });
168
168
  module.exports = __toCommonJS(client_exports);
169
- var React10 = __toESM(require("react"), 1);
169
+ var React11 = __toESM(require("react"), 1);
170
170
 
171
171
  // src/client/icons.tsx
172
172
  var React = __toESM(require("react"), 1);
@@ -1203,6 +1203,11 @@ var TERM_THEME = {
1203
1203
  brightWhite: "#f2f2f2"
1204
1204
  };
1205
1205
  var PANEL_CSS = `.dshTermRoot{position:fixed;bottom:0;z-index:50;font-family:Inter,var(--dsw-font-family)}
1206
+ /* \u517C\u5BB9\u6A21\u5F0F\u6839\u5BB9\u5668\uFF1A\u5D4C\u5165 DSH-better-sidebar \u5E95\u90E8\u5DE5\u4F5C\u53F0 tab \u5185\u5BB9\u533A\u3002
1207
+ * \u8986\u76D6 .dshTermRoot \u7684 position:fixed/bottom:0/z-index\u2014\u2014\u8FD9\u4E9B\u662F\u72EC\u7ACB\u6A21\u5F0F
1208
+ * \u5E95\u90E8\u56FA\u5B9A\u9762\u677F\u7684\u5B9A\u4F4D\uFF0C\u5728 tab \u5185\u5BB9\u533A\u91CC\u4F1A\u5BFC\u81F4\u7EC4\u4EF6\u8131\u79BB\u6587\u6863\u6D41\u8986\u76D6\u6574\u4E2A\u4FA7\u8FB9\u680F\u3002
1209
+ * \u6539\u4E3A position:relative \u586B\u6EE1 tab \u5185\u5BB9\u533A\uFF08flex column \u5E03\u5C40\uFF09 */
1210
+ .dshTermSidebarRoot{position:relative;bottom:auto;z-index:auto;width:100%;height:100%;display:flex;flex-direction:column;min-height:0;overflow:hidden;background:var(--dsw-specific-tip)}
1206
1211
  .dshTermBar{box-sizing:border-box;width:100%;height:34px;display:flex;align-items:center;gap:10px;padding:0 14px;background:var(--dsw-specific-tip);border-top:1px solid var(--dsw-alias-border-l1);cursor:pointer;color:var(--dsw-alias-label-primary);text-align:left;user-select:none;-webkit-user-select:none}
1207
1212
  .dshTermBar:focus-visible{outline:2px solid var(--dsw-alias-label-tertiary);outline-offset:-2px}
1208
1213
  .dshTermBarLead{color:var(--dsw-alias-label-tertiary);flex:none;place-items:center;display:grid}
@@ -11598,19 +11603,327 @@ function injectSettingsStyles() {
11598
11603
  document.head.appendChild(tag);
11599
11604
  }
11600
11605
 
11606
+ // src/client/compat.ts
11607
+ var BETTER_SIDEBAR_SERVICE = "betterSidebar";
11608
+ function detectBetterSidebar(ctx) {
11609
+ if (typeof ctx.get !== "function") return void 0;
11610
+ const service = ctx.get(BETTER_SIDEBAR_SERVICE);
11611
+ if (service === void 0) return void 0;
11612
+ if (typeof service.registerTab !== "function") return void 0;
11613
+ return service;
11614
+ }
11615
+
11616
+ // src/client/sidebar-tab.tsx
11617
+ var React10 = __toESM(require("react"), 1);
11618
+ var log4 = createLogger("terminal-sidebar-tab");
11619
+ var SIDEBAR_TAB_ORDER = 40;
11620
+ function TerminalSidebarTab(props) {
11621
+ const { scope, visible, useHeader = false } = props;
11622
+ const { useEffect, useRef, useCallback } = React10;
11623
+ const sessionId = void 0;
11624
+ const workspaceCwd = scope.cwd;
11625
+ const rootRef = useRef(null);
11626
+ const { state, dispatch } = useTerminalState(sessionId);
11627
+ const { instances, groups, activeInstanceId, busy, bootReady } = state;
11628
+ const activeInstance = instances.find((t) => t.id === activeInstanceId) ?? null;
11629
+ const activeGroup = groups.find((g) => g.instances.some((i) => i.id === activeInstanceId)) ?? null;
11630
+ const { newTab, closeTab, restartActive, splitTerminal, onExit } = useTerminalTabs({
11631
+ state,
11632
+ dispatch,
11633
+ activeInstance,
11634
+ activeGroup,
11635
+ workspaceCwd,
11636
+ sessionId
11637
+ });
11638
+ const { fontFamily, fontSize, lineHeight, terminalProfiles } = useConfig(
11639
+ /* setOpen 在侧边栏模式下无意义(展开/折叠由 DSH-better-sidebar 工作台控制),
11640
+ * 传一个空操作 setter 满足 useConfig 签名 */
11641
+ () => {
11642
+ }
11643
+ );
11644
+ const openHandled = useRef(false);
11645
+ useEffect(() => {
11646
+ if (!visible) {
11647
+ openHandled.current = false;
11648
+ return;
11649
+ }
11650
+ if (!bootReady || instances.length > 0 || openHandled.current) return;
11651
+ openHandled.current = true;
11652
+ void newTab();
11653
+ }, [visible, bootReady, instances.length, newTab]);
11654
+ const stateLabel = busy ? "\u542F\u52A8\u4E2D\u2026" : activeInstance === null ? instances.length === 0 ? "\u65E0\u4F1A\u8BDD" : "\u7A7A\u95F2" : activeInstance.exited ? instanceLabel(activeInstance) + " \u5DF2\u9000\u51FA\uFF0C\u70B9 \u27F3 \u91CD\u542F" : instanceLabel(activeInstance);
11655
+ const handleNewByType = useCallback((profileId) => {
11656
+ const profile = terminalProfiles.find((p) => p.id === profileId);
11657
+ if (profile === void 0) {
11658
+ log4.warn(`\u672A\u77E5\u7684\u7EC8\u7AEF\u914D\u7F6E id\uFF1A${profileId}`);
11659
+ return;
11660
+ }
11661
+ void newTab(profile.id);
11662
+ }, [newTab, terminalProfiles]);
11663
+ const handleRename = useCallback((instanceId, newName) => {
11664
+ dispatch({ type: "RENAME_INSTANCE", id: instanceId, title: newName });
11665
+ }, [dispatch]);
11666
+ const handleSelectInstance = useCallback((instanceId) => {
11667
+ dispatch({ type: "SET_ACTIVE", id: instanceId });
11668
+ }, [dispatch]);
11669
+ return React10.createElement(
11670
+ "div",
11671
+ {
11672
+ className: "dshTermSidebarRoot",
11673
+ ref: rootRef
11674
+ },
11675
+ /* 降级模式头部栏:引导符 + 状态 + 右侧按钮组(rightActions 不可用时回退) */
11676
+ useHeader ? React10.createElement(
11677
+ "div",
11678
+ { className: "dshTermHeader", style: { flexShrink: 0 } },
11679
+ React10.createElement("span", { className: "dshTermHeaderState", title: stateLabel }, stateLabel),
11680
+ React10.createElement(
11681
+ "div",
11682
+ { className: "dshTermHeaderActions" },
11683
+ React10.createElement(
11684
+ "div",
11685
+ { className: "dshTermNewWrap" },
11686
+ React10.createElement(
11687
+ "button",
11688
+ {
11689
+ className: "dshTermNew",
11690
+ title: "\u65B0\u5EFA\u7EC8\u7AEF",
11691
+ "aria-label": "\u65B0\u5EFA\u7EC8\u7AEF",
11692
+ disabled: busy,
11693
+ onClick: () => {
11694
+ void newTab();
11695
+ }
11696
+ },
11697
+ Plus12()
11698
+ ),
11699
+ React10.createElement("div", { className: "dshTermNewSep" }),
11700
+ React10.createElement(DropdownMenu, {
11701
+ busy,
11702
+ onNewTerminal: () => {
11703
+ void newTab();
11704
+ },
11705
+ onSplitTerminal: () => {
11706
+ void splitTerminal();
11707
+ },
11708
+ terminalProfiles,
11709
+ onNewByType: handleNewByType
11710
+ })
11711
+ ),
11712
+ React10.createElement(RestartButton, { active: activeInstance, busy, onRestart: () => {
11713
+ void restartActive();
11714
+ } })
11715
+ )
11716
+ ) : null,
11717
+ /* Body:左侧终端显示区域 + 右侧终端列表 */
11718
+ React10.createElement(
11719
+ "div",
11720
+ {
11721
+ className: "dshTermBodyWithList",
11722
+ style: { flex: 1, minHeight: 0, overflow: "hidden" }
11723
+ },
11724
+ /* 左侧终端显示区域 */
11725
+ React10.createElement(
11726
+ "div",
11727
+ { className: "dshTermTerminalArea" },
11728
+ ...groups.map((g) => {
11729
+ if (g.instances.length === 1) {
11730
+ const inst = g.instances[0];
11731
+ return React10.createElement(TermPane, {
11732
+ key: inst.id,
11733
+ instance: inst,
11734
+ active: inst.id === activeInstanceId,
11735
+ fontFamily,
11736
+ fontSize,
11737
+ lineHeight,
11738
+ onExit
11739
+ });
11740
+ }
11741
+ return React10.createElement(
11742
+ "div",
11743
+ { key: g.id, className: "dshTermSplitGroup" },
11744
+ ...g.instances.flatMap((inst, idx) => {
11745
+ const elements = [];
11746
+ if (idx > 0) {
11747
+ elements.push(React10.createElement("div", { key: g.id + "-div-" + idx, className: "dshTermSplitDivider" }));
11748
+ }
11749
+ elements.push(
11750
+ React10.createElement(
11751
+ "div",
11752
+ { key: inst.id, className: "dshTermSplitPane" },
11753
+ React10.createElement(TermPane, {
11754
+ instance: inst,
11755
+ active: inst.id === activeInstanceId,
11756
+ fontFamily,
11757
+ fontSize,
11758
+ lineHeight,
11759
+ onExit
11760
+ })
11761
+ )
11762
+ );
11763
+ return elements;
11764
+ })
11765
+ );
11766
+ }),
11767
+ instances.length === 0 ? React10.createElement(
11768
+ "div",
11769
+ { className: "dshTermEmpty" },
11770
+ React10.createElement("span", null, "\u6CA1\u6709\u7EC8\u7AEF\u4F1A\u8BDD"),
11771
+ React10.createElement(
11772
+ "button",
11773
+ { className: "dshTermEmptyBtn", onClick: () => {
11774
+ void newTab();
11775
+ } },
11776
+ Plus12(),
11777
+ "\u65B0\u5EFA\u7EC8\u7AEF"
11778
+ )
11779
+ ) : null
11780
+ ),
11781
+ /* 右侧终端列表(仅多于一个终端时显示) */
11782
+ instances.length > 1 ? React10.createElement(SideList, {
11783
+ groups,
11784
+ activeInstanceId,
11785
+ onSelect: handleSelectInstance,
11786
+ onClose: closeTab,
11787
+ onRename: handleRename
11788
+ }) : null
11789
+ )
11790
+ );
11791
+ }
11792
+ function TerminalSidebarRightActions(props) {
11793
+ const { scope, visible } = props;
11794
+ const { useCallback } = React10;
11795
+ const sessionId = void 0;
11796
+ const workspaceCwd = scope.cwd;
11797
+ const { state, dispatch } = useTerminalState(sessionId);
11798
+ const { instances, activeInstanceId, busy } = state;
11799
+ const activeInstance = instances.find((t) => t.id === activeInstanceId) ?? null;
11800
+ const activeGroup = state.groups.find((g) => g.instances.some((i) => i.id === activeInstanceId)) ?? null;
11801
+ const { newTab, restartActive, splitTerminal } = useTerminalTabs({
11802
+ state,
11803
+ dispatch,
11804
+ activeInstance,
11805
+ activeGroup,
11806
+ workspaceCwd,
11807
+ sessionId
11808
+ });
11809
+ const { terminalProfiles } = useConfig(() => {
11810
+ });
11811
+ const handleNewByType = useCallback((profileId) => {
11812
+ const profile = terminalProfiles.find((p) => p.id === profileId);
11813
+ if (profile === void 0) {
11814
+ log4.warn(`\u672A\u77E5\u7684\u7EC8\u7AEF\u914D\u7F6E id\uFF1A${profileId}`);
11815
+ return;
11816
+ }
11817
+ void newTab(profile.id);
11818
+ }, [newTab, terminalProfiles]);
11819
+ return React10.createElement(
11820
+ "div",
11821
+ { className: "dshTermHeaderActions", style: { padding: "0 4px" } },
11822
+ /* 新建终端组合按钮(+ / 下拉) */
11823
+ React10.createElement(
11824
+ "div",
11825
+ { className: "dshTermNewWrap" },
11826
+ React10.createElement(
11827
+ "button",
11828
+ {
11829
+ className: "dshTermNew",
11830
+ title: "\u65B0\u5EFA\u7EC8\u7AEF",
11831
+ "aria-label": "\u65B0\u5EFA\u7EC8\u7AEF",
11832
+ disabled: busy,
11833
+ onClick: () => {
11834
+ void newTab();
11835
+ }
11836
+ },
11837
+ Plus12()
11838
+ ),
11839
+ React10.createElement("div", { className: "dshTermNewSep" }),
11840
+ React10.createElement(DropdownMenu, {
11841
+ busy,
11842
+ onNewTerminal: () => {
11843
+ void newTab();
11844
+ },
11845
+ onSplitTerminal: () => {
11846
+ void splitTerminal();
11847
+ },
11848
+ terminalProfiles,
11849
+ onNewByType: handleNewByType
11850
+ })
11851
+ ),
11852
+ /* 重启按钮 */
11853
+ React10.createElement(RestartButton, { active: activeInstance, busy, onRestart: () => {
11854
+ void restartActive();
11855
+ } })
11856
+ );
11857
+ }
11858
+ function createTerminalTabDescriptor(supportsRightActions) {
11859
+ return {
11860
+ id: "dsh-oh-my-terminal",
11861
+ title: () => "\u7EC8\u7AEF",
11862
+ icon: (size) => React10.createElement("span", {
11863
+ style: {
11864
+ display: "inline-flex",
11865
+ alignItems: "center",
11866
+ color: "var(--dsw-alias-label-primary)"
11867
+ }
11868
+ }, TerminalGlyph14()),
11869
+ order: SIDEBAR_TAB_ORDER,
11870
+ single: true,
11871
+ component: (props) => React10.createElement(TerminalSidebarTab, { ...props, useHeader: !supportsRightActions }),
11872
+ ...supportsRightActions ? {
11873
+ // rightActions 签名是 (ctx, scope, state) => ReactNode(三个独立参数,
11874
+ // 不是 props 对象——与 DSH-better-sidebar 的 TabDescriptor.rightActions
11875
+ // 调用约定一致)。这里把它们组装成 TabComponentPropsLike 传给组件。
11876
+ rightActions: (ctx, scope, _state) => React10.createElement(TerminalSidebarRightActions, { ctx, scope, visible: true, tab: { id: "", type: "", title: "" } })
11877
+ } : {}
11878
+ };
11879
+ }
11880
+
11601
11881
  // src/client.tsx
11602
- var log4 = createLogger("terminal-client");
11882
+ var log5 = createLogger("terminal-client");
11603
11883
  injectStyles();
11604
11884
  injectSettingsStyles();
11605
11885
  var name = "dsh-oh-my-terminal";
11606
11886
  var inject = ["slots"];
11607
11887
  function apply(ctx) {
11608
- registerToggleShortcut(ctx);
11609
- ctx.slots.inject("conversation.input.dock", () => ctx.slots.register(
11610
- { name: "conversation.input.dock", id: "terminal", order: 10 },
11611
- TerminalPanel
11612
- ));
11613
11888
  registerSettingsPage(ctx);
11889
+ const betterSidebar = detectBetterSidebar(ctx);
11890
+ if (betterSidebar !== void 0) {
11891
+ registerSidebarTab(ctx, betterSidebar);
11892
+ return;
11893
+ }
11894
+ const dockDisposer = registerStandaloneDock(ctx);
11895
+ registerToggleShortcut(ctx);
11896
+ const seat = ctx.inject(["betterSidebar"], (injected) => {
11897
+ const service = detectBetterSidebar(injected);
11898
+ if (service === void 0) return;
11899
+ dockDisposer();
11900
+ registerSidebarTab(ctx, service);
11901
+ });
11902
+ ctx.effect(() => () => {
11903
+ void seat.dispose();
11904
+ }, "dsh-oh-my-terminal.better-sidebar-inject");
11905
+ }
11906
+ function registerStandaloneDock(ctx) {
11907
+ return ctx.slots.inject("conversation.input.dock", () => {
11908
+ const regDispose = ctx.slots.register(
11909
+ { name: "conversation.input.dock", id: "terminal", order: 10 },
11910
+ TerminalPanel
11911
+ );
11912
+ return regDispose;
11913
+ });
11914
+ }
11915
+ function registerSidebarTab(ctx, service) {
11916
+ try {
11917
+ const supportsRightActions = service.features?.includes("rightActions") ?? false;
11918
+ const descriptor = createTerminalTabDescriptor(supportsRightActions);
11919
+ const dispose = service.registerTab(descriptor);
11920
+ ctx.effect(() => dispose, "dsh-oh-my-terminal.sidebar-tab");
11921
+ log5.info(`\u5DF2\u63A5\u5165 DSH-better-sidebar \u517C\u5BB9\u6A21\u5F0F\uFF08\u7EC8\u7AEF tab \u6CE8\u518C\u5230\u5E95\u90E8\u5DE5\u4F5C\u53F0\uFF0CrightActions=${supportsRightActions}\uFF09`);
11922
+ } catch (error) {
11923
+ const msg = error instanceof Error ? error.message : String(error);
11924
+ log5.warn(`betterSidebar registerTab \u5931\u8D25\uFF0C\u964D\u7EA7\u4E3A\u72EC\u7ACB\u6A21\u5F0F\uFF1A${msg}`);
11925
+ registerStandaloneDock(ctx);
11926
+ }
11614
11927
  }
11615
11928
  var SETTINGS_ROW_ID = "terminal-panel";
11616
11929
  function registerSettingsPage(ctx) {
@@ -11639,14 +11952,14 @@ function registerToggleShortcut(ctx) {
11639
11952
  });
11640
11953
  ctx.effect(() => dispose, "dsh-oh-my-terminal.shortcut");
11641
11954
  markShortcutsActive(shortcuts.catalog);
11642
- log4.info(`\u5DF2\u63A5\u5165 DSH shortcuts \u670D\u52A1\uFF08\u547D\u4EE4 ${SHORTCUT_COMMAND_ID}\uFF09`);
11955
+ log5.info(`\u5DF2\u63A5\u5165 DSH shortcuts \u670D\u52A1\uFF08\u547D\u4EE4 ${SHORTCUT_COMMAND_ID}\uFF09`);
11643
11956
  } catch (error) {
11644
11957
  const msg = error instanceof Error ? error.message : String(error);
11645
- log4.warn(`shortcuts \u547D\u4EE4\u6CE8\u518C\u5931\u8D25\uFF0C\u964D\u7EA7\u4E3A\u88F8 keydown \u76D1\u542C\uFF1A${msg}`);
11958
+ log5.warn(`shortcuts \u547D\u4EE4\u6CE8\u518C\u5931\u8D25\uFF0C\u964D\u7EA7\u4E3A\u88F8 keydown \u76D1\u542C\uFF1A${msg}`);
11646
11959
  }
11647
11960
  }
11648
11961
  function TerminalPanel(props) {
11649
- const { useEffect, useRef, useState, useCallback } = React10;
11962
+ const { useEffect, useRef, useState, useCallback } = React11;
11650
11963
  const { sessionId, useWorkspaces } = props ?? {};
11651
11964
  const [open, setOpen] = useState(false);
11652
11965
  const workspaceCwd = useWorkspaces?.((s15) => {
@@ -11690,7 +12003,7 @@ function TerminalPanel(props) {
11690
12003
  const handleNewByType = useCallback((profileId) => {
11691
12004
  const profile = terminalProfiles.find((p) => p.id === profileId);
11692
12005
  if (profile === void 0) {
11693
- log4.warn(`\u672A\u77E5\u7684\u7EC8\u7AEF\u914D\u7F6E id\uFF1A${profileId}`);
12006
+ log5.warn(`\u672A\u77E5\u7684\u7EC8\u7AEF\u914D\u7F6E id\uFF1A${profileId}`);
11694
12007
  return;
11695
12008
  }
11696
12009
  void newTab(profile.id);
@@ -11701,36 +12014,36 @@ function TerminalPanel(props) {
11701
12014
  const handleSelectInstance = useCallback((instanceId) => {
11702
12015
  dispatch({ type: "SET_ACTIVE", id: instanceId });
11703
12016
  }, [dispatch]);
11704
- return React10.createElement(
12017
+ return React11.createElement(
11705
12018
  "div",
11706
12019
  {
11707
12020
  className: "dshTermRoot",
11708
12021
  ref: rootRef,
11709
12022
  style: { left: geo.left + "px", width: geo.width + "px" }
11710
12023
  },
11711
- open ? React10.createElement(
12024
+ open ? React11.createElement(
11712
12025
  "div",
11713
12026
  { className: "dshTermPanel", id: "dshTermPanel", style: { height: height + "px" } },
11714
- React10.createElement("div", {
12027
+ React11.createElement("div", {
11715
12028
  className: "dshTermResize",
11716
12029
  title: "\u62D6\u52A8\u8C03\u6574\u9AD8\u5EA6",
11717
12030
  onPointerDown: startResize
11718
12031
  }),
11719
12032
  /* 头部:引导符 + 状态(flex:1 撑满) + 右侧按钮组(margin-left:auto 右对齐) */
11720
- React10.createElement(
12033
+ React11.createElement(
11721
12034
  "div",
11722
12035
  { className: "dshTermHeader" },
11723
- React10.createElement("span", { className: "dshTermHeaderLead", "aria-hidden": true }, TerminalGlyph14()),
11724
- React10.createElement("span", { className: "dshTermHeaderState", title: stateLabel }, stateLabel),
12036
+ React11.createElement("span", { className: "dshTermHeaderLead", "aria-hidden": true }, TerminalGlyph14()),
12037
+ React11.createElement("span", { className: "dshTermHeaderState", title: stateLabel }, stateLabel),
11725
12038
  /* 右侧按钮组:+号/下拉 + 重启 + 收起,统一右对齐 */
11726
- React10.createElement(
12039
+ React11.createElement(
11727
12040
  "div",
11728
12041
  { className: "dshTermHeaderActions" },
11729
12042
  /* 新建组合按钮:+ 主按钮 + 分隔线 + 下拉箭头 */
11730
- React10.createElement(
12043
+ React11.createElement(
11731
12044
  "div",
11732
12045
  { className: "dshTermNewWrap" },
11733
- React10.createElement(
12046
+ React11.createElement(
11734
12047
  "button",
11735
12048
  {
11736
12049
  className: "dshTermNew",
@@ -11743,8 +12056,8 @@ function TerminalPanel(props) {
11743
12056
  },
11744
12057
  Plus12()
11745
12058
  ),
11746
- React10.createElement("div", { className: "dshTermNewSep" }),
11747
- React10.createElement(DropdownMenu, {
12059
+ React11.createElement("div", { className: "dshTermNewSep" }),
12060
+ React11.createElement(DropdownMenu, {
11748
12061
  busy,
11749
12062
  onNewTerminal: () => {
11750
12063
  void newTab();
@@ -11757,10 +12070,10 @@ function TerminalPanel(props) {
11757
12070
  })
11758
12071
  ),
11759
12072
  /* 重启对已退出历史终端也需可达 */
11760
- React10.createElement(RestartButton, { active: activeInstance, busy, onRestart: () => {
12073
+ React11.createElement(RestartButton, { active: activeInstance, busy, onRestart: () => {
11761
12074
  void restartActive();
11762
12075
  } }),
11763
- React10.createElement(
12076
+ React11.createElement(
11764
12077
  "button",
11765
12078
  {
11766
12079
  className: "dshTermCollapse",
@@ -11773,17 +12086,17 @@ function TerminalPanel(props) {
11773
12086
  )
11774
12087
  ),
11775
12088
  /* Body:左侧终端显示区域 + 右侧终端列表 */
11776
- React10.createElement(
12089
+ React11.createElement(
11777
12090
  "div",
11778
12091
  { className: "dshTermBodyWithList" },
11779
12092
  /* 左侧终端显示区域 */
11780
- React10.createElement(
12093
+ React11.createElement(
11781
12094
  "div",
11782
12095
  { className: "dshTermTerminalArea" },
11783
12096
  ...groups.map((g) => {
11784
12097
  if (g.instances.length === 1) {
11785
12098
  const inst = g.instances[0];
11786
- return React10.createElement(TermPane, {
12099
+ return React11.createElement(TermPane, {
11787
12100
  key: inst.id,
11788
12101
  instance: inst,
11789
12102
  active: inst.id === activeInstanceId,
@@ -11793,19 +12106,19 @@ function TerminalPanel(props) {
11793
12106
  onExit
11794
12107
  });
11795
12108
  }
11796
- return React10.createElement(
12109
+ return React11.createElement(
11797
12110
  "div",
11798
12111
  { key: g.id, className: "dshTermSplitGroup" },
11799
12112
  ...g.instances.flatMap((inst, idx) => {
11800
12113
  const elements = [];
11801
12114
  if (idx > 0) {
11802
- elements.push(React10.createElement("div", { key: g.id + "-div-" + idx, className: "dshTermSplitDivider" }));
12115
+ elements.push(React11.createElement("div", { key: g.id + "-div-" + idx, className: "dshTermSplitDivider" }));
11803
12116
  }
11804
12117
  elements.push(
11805
- React10.createElement(
12118
+ React11.createElement(
11806
12119
  "div",
11807
12120
  { key: inst.id, className: "dshTermSplitPane" },
11808
- React10.createElement(TermPane, {
12121
+ React11.createElement(TermPane, {
11809
12122
  instance: inst,
11810
12123
  active: inst.id === activeInstanceId,
11811
12124
  fontFamily,
@@ -11819,11 +12132,11 @@ function TerminalPanel(props) {
11819
12132
  })
11820
12133
  );
11821
12134
  }),
11822
- instances.length === 0 ? React10.createElement(
12135
+ instances.length === 0 ? React11.createElement(
11823
12136
  "div",
11824
12137
  { className: "dshTermEmpty" },
11825
- React10.createElement("span", null, "\u6CA1\u6709\u7EC8\u7AEF\u4F1A\u8BDD"),
11826
- React10.createElement(
12138
+ React11.createElement("span", null, "\u6CA1\u6709\u7EC8\u7AEF\u4F1A\u8BDD"),
12139
+ React11.createElement(
11827
12140
  "button",
11828
12141
  { className: "dshTermEmptyBtn", onClick: () => {
11829
12142
  void newTab();
@@ -11834,7 +12147,7 @@ function TerminalPanel(props) {
11834
12147
  ) : null
11835
12148
  ),
11836
12149
  /* 右侧终端列表(仅多于一个终端时显示) */
11837
- instances.length > 1 ? React10.createElement(SideList, {
12150
+ instances.length > 1 ? React11.createElement(SideList, {
11838
12151
  groups,
11839
12152
  activeInstanceId,
11840
12153
  onSelect: handleSelectInstance,
@@ -11842,7 +12155,7 @@ function TerminalPanel(props) {
11842
12155
  onRename: handleRename
11843
12156
  }) : null
11844
12157
  )
11845
- ) : React10.createElement(
12158
+ ) : React11.createElement(
11846
12159
  "div",
11847
12160
  {
11848
12161
  className: "dshTermBar",
@@ -11859,18 +12172,18 @@ function TerminalPanel(props) {
11859
12172
  }
11860
12173
  }
11861
12174
  },
11862
- React10.createElement("span", { className: "dshTermBarLead", "aria-hidden": true }, TerminalGlyph14()),
11863
- React10.createElement("span", { className: "dshTermBarTitle" }, "\u7EC8\u7AEF"),
12175
+ React11.createElement("span", { className: "dshTermBarLead", "aria-hidden": true }, TerminalGlyph14()),
12176
+ React11.createElement("span", { className: "dshTermBarTitle" }, "\u7EC8\u7AEF"),
11864
12177
  /* flex:1 spacer 把右侧按钮推到最右 */
11865
- React10.createElement("span", { style: { flex: 1 } }),
11866
- React10.createElement(
12178
+ React11.createElement("span", { style: { flex: 1 } }),
12179
+ React11.createElement(
11867
12180
  "span",
11868
12181
  { className: "dshTermBarActions", onClick: (e) => e.stopPropagation() },
11869
- React10.createElement(RestartButton, { active: activeInstance, busy, onRestart: () => {
12182
+ React11.createElement(RestartButton, { active: activeInstance, busy, onRestart: () => {
11870
12183
  void restartActive();
11871
12184
  } })
11872
12185
  ),
11873
- React10.createElement("span", { className: "dshTermBarChevron", "aria-hidden": true }, ChevronUp14())
12186
+ React11.createElement("span", { className: "dshTermBarChevron", "aria-hidden": true }, ChevronUp14())
11874
12187
  )
11875
12188
  );
11876
12189
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-oh-my-terminal",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "DSH Web GUI 底部终端面板插件——node-pty 驱动的多标签交互式终端(Windows ConPTY / POSIX openpty)",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -0,0 +1,107 @@
1
+ /**
2
+ * @file DSH-better-sidebar 兼容模式检测模块
3
+ * @description 当本插件与 DSH-better-sidebar 同时安装时,两者都会在底部区域管理终端,
4
+ * 竞争同一个 DSH session 的 write handle,导致
5
+ * `session "xxx" is already owned by an active write handle` 报错。
6
+ *
7
+ * 本模块软探测 DSH-better-sidebar 发布的 `ctx.betterSidebar` 服务:
8
+ * 命中时本插件切换为兼容模式——不再注册 `conversation.input.dock`
9
+ * 底部面板,改为通过 `service.registerTab()` 注册"终端"tab 嵌入
10
+ * DSH-better-sidebar 的底部工作台。
11
+ *
12
+ * 兼容模式下的 PTY session 不绑定 DSH sessionId(ownerSessionId = null),
13
+ * 完全由本插件独立管理,不进入 DSH session 的 write handle 体系——
14
+ * 从根源上消除两个终端插件的 session 冲突。
15
+ *
16
+ * 软探测模式与 shortcuts 服务接入(shortcut-bridge.ts)一致:
17
+ * 不写入顶层 inject,旧宿主或未安装 DSH-better-sidebar 时降级为
18
+ * 原有独立模式,面板功能不受影响。
19
+ */
20
+
21
+ import type { Context } from '@deepseek-ai/cordis';
22
+ import type { ReactNode } from 'react';
23
+
24
+ // —— DSH-better-sidebar 服务的最小本地接口声明 ——
25
+ // DSH-better-sidebar 通过 ctx.provide('betterSidebar', service) 发布注册服务,
26
+ // 非 npm 包依赖——独立插件无法 import 它的类型。用本地接口声明其最小形状,
27
+ // 使 ctx.get('betterSidebar') 通过类型检查。esbuild 打包时整体擦除,运行期
28
+ // 服务实例由 DSH-better-sidebar 提供。
29
+
30
+ /** DSH-better-sidebar 的会话作用域(TabComponentProps.scope 的形状) */
31
+ export interface SessionScopeLike {
32
+ /** 当前 DSH 会话 id */
33
+ sessionId: string;
34
+ /** 会话工作目录(可能缺省) */
35
+ cwd?: string;
36
+ }
37
+
38
+ /** tab 组件接收的 props 子集(本插件实际消费的字段) */
39
+ export interface TabComponentPropsLike {
40
+ /** cordis 客户端上下文 */
41
+ ctx: Context;
42
+ /** 会话作用域(含 sessionId 与 cwd) */
43
+ scope: SessionScopeLike;
44
+ /** 当前 tab 是否可见(面板展开且 tab 活跃) */
45
+ visible: boolean;
46
+ /** tab 记录(含 id/type/title) */
47
+ tab: { id: string; type: string; title: string };
48
+ }
49
+
50
+ /** 注册 tab 时需提供的描述符(本插件用到的字段子集) */
51
+ export interface BetterSidebarTabDescriptorLike {
52
+ /** tab 类型唯一 id(也是 SidebarTab.type 值) */
53
+ id: string;
54
+ /** tab 标题(字符串或返回字符串的函数) */
55
+ title: string | (() => string);
56
+ /** tab 图标(ReactNode 或返回 ReactNode 的函数) */
57
+ icon?: ReactNode | ((size: number) => ReactNode);
58
+ /** + 菜单排序权重(小者先) */
59
+ order?: number;
60
+ /** 单实例:true = 同类型只保留一个 tab */
61
+ single?: boolean;
62
+ /** tab 内容渲染组件 */
63
+ component: (props: TabComponentPropsLike) => ReactNode;
64
+ /**
65
+ * tab 栏右侧操作区(v0.25.0+):tab 激活时,返回的 ReactNode 渲染在 tab 栏
66
+ * 右端(+ 按钮右侧、面板关闭按钮左侧,右对齐)。让 tab 把自己的工具栏直接
67
+ * 放进 tab 标签条,不在内容区顶部单独渲染头部行。
68
+ *
69
+ * 签名与 DSH-better-sidebar 的 TabDescriptor.rightActions 一致:
70
+ * `(ctx, scope, state) => ReactNode`,三个独立参数(不是 props 对象)。
71
+ */
72
+ rightActions?: (ctx: unknown, scope: SessionScopeLike, state: unknown) => ReactNode;
73
+ }
74
+
75
+ /** DSH-better-sidebar 注册服务的最小接口 */
76
+ export interface BetterSidebarServiceLike {
77
+ /** 注册一个 tab 类型,返回注销函数 */
78
+ registerTab(descriptor: BetterSidebarTabDescriptorLike): () => void;
79
+ /**
80
+ * 能力列表(v0.12.0+):单调递增,能力只增不删。消费方用 includes 探测
81
+ * 新能力是否可用,不可用时降级。本插件用 'rightActions' 探测 tab 栏
82
+ * 右侧操作注入是否可用,不可用时回退到内容区顶部两层显示方案。
83
+ */
84
+ readonly features?: readonly string[];
85
+ }
86
+
87
+ /** DSH-better-sidebar 发布到 cordis 上下文的服务名 */
88
+ export const BETTER_SIDEBAR_SERVICE = 'betterSidebar';
89
+
90
+ /**
91
+ * 软探测 DSH-better-sidebar 是否已安装并发布服务。
92
+ *
93
+ * 与 shortcuts 服务接入模式一致:不写入顶层 inject(硬依赖会让未安装
94
+ * DSH-better-sidebar 的宿主上插件浏览器半直接不激活),用 ctx.get 软探测。
95
+ * 命中时返回服务实例,未命中时返回 undefined(调用方降级为独立模式)。
96
+ *
97
+ * @param ctx - 远端页面的 cordis 上下文
98
+ * @returns betterSidebar 服务实例,未安装时 undefined
99
+ */
100
+ export function detectBetterSidebar(ctx: Context): BetterSidebarServiceLike | undefined {
101
+ if (typeof ctx.get !== 'function') return undefined;
102
+ const service = ctx.get(BETTER_SIDEBAR_SERVICE);
103
+ if (service === undefined) return undefined;
104
+ // 形状校验:只认有 registerTab 方法的对象,避免误判
105
+ if (typeof (service as BetterSidebarServiceLike).registerTab !== 'function') return undefined;
106
+ return service as BetterSidebarServiceLike;
107
+ }
@@ -0,0 +1,394 @@
1
+ /**
2
+ * @file 侧边栏终端 tab 组件(DSH-better-sidebar 兼容模式)
3
+ * @description 当检测到 DSH-better-sidebar 已安装时,本插件不再注册
4
+ * `conversation.input.dock` 底部面板,改为通过
5
+ * `ctx.betterSidebar.registerTab()` 注册一个"终端"tab,
6
+ * 嵌入 DSH-better-sidebar 的底部工作台。
7
+ *
8
+ * 本组件是该 tab 的内容渲染器:复用现有 hooks
9
+ * (useTerminalState/useConfig/useTerminalTabs),但与独立模式的
10
+ * TerminalPanel 有三处关键差异:
11
+ *
12
+ * 1. 不绑定 DSH sessionId——`useTerminalState(undefined)` 与
13
+ * `useTerminalTabs` 的 sessionId 传 undefined,PTY session 的
14
+ * ownerSessionId 为 null,不进入 DSH session 的 write handle
15
+ * 管理体系,从根源上消除与 DSH-better-sidebar 终端的 session 冲突
16
+ * 2. 工作目录从 tab 接收的 scope.cwd 取值(DSH-better-sidebar
17
+ * 传入当前工作区路径),而非 useWorkspaces 查询
18
+ * 3. 操作按钮通过 descriptor.rightActions 注入 tab 栏右端(+ 按钮
19
+ * 右侧、关闭按钮左侧),不在内容区顶部单独渲染头部行——
20
+ * 与 VSCode 风格一致,操作按钮直接在 tab 标签条上
21
+ *
22
+ * 终端输入/输出数据绝不进日志。
23
+ */
24
+
25
+ import * as React from 'react';
26
+ import type { ReactElement } from 'react';
27
+ import {
28
+ useTerminalState, useConfig, useTerminalTabs,
29
+ } from './hooks.js';
30
+ import { TermPane, RestartButton } from './term-pane.js';
31
+ import { DropdownMenu } from './dropdown.js';
32
+ import { SideList, instanceLabel } from './side-list.js';
33
+ import { TerminalGlyph14, Plus12 } from './icons.js';
34
+ import type { TabComponentPropsLike, BetterSidebarTabDescriptorLike } from './compat.js';
35
+ import { createLogger } from '../logger.js';
36
+
37
+ const log = createLogger('terminal-sidebar-tab');
38
+
39
+ /** 侧边栏终端 tab 在 + 菜单中的排序权重(排在内置 tab 之后) */
40
+ const SIDEBAR_TAB_ORDER = 40;
41
+
42
+ /**
43
+ * 侧边栏终端 tab 内容组件。
44
+ *
45
+ * 复用独立模式 TerminalPanel 的核心 hooks,但适配 DSH-better-sidebar 底部
46
+ * 工作台的 tab 内容区布局,且不绑定 DSH sessionId(防 write handle 冲突)。
47
+ *
48
+ * @param props - DSH-better-sidebar 注入的 tab 组件 props + useHeader 降级标记
49
+ * @returns 终端 tab 内容根元素
50
+ */
51
+ function TerminalSidebarTab(props: TabComponentPropsLike & { useHeader?: boolean }): ReactElement {
52
+ const { scope, visible, useHeader = false } = props;
53
+ const { useEffect, useRef, useCallback } = React;
54
+
55
+ /*
56
+ * 兼容模式防冲突核心:sessionId 传 undefined。
57
+ *
58
+ * useTerminalState(undefined):/sessions 恢复时不按 DSH 会话过滤(恢复全部
59
+ * 存活终端);useTerminalTabs 的 sessionId 为 undefined 时,POST /sessions
60
+ * 不带 sessionId 字段,宿主半 createSession 的 ownerSessionId 为 null——
61
+ * PTY session 完全由本插件独立管理,不进入 DSH session 的 write handle
62
+ * 体系,从根源上消除与 DSH-better-sidebar 终端的 session 冲突。
63
+ */
64
+ const sessionId = undefined;
65
+
66
+ /** 工作目录:从 tab 接收的 scope.cwd 取值(DSH-better-sidebar 传入工作区路径) */
67
+ const workspaceCwd = scope.cwd;
68
+
69
+ /** 根元素 ref */
70
+ const rootRef = useRef<HTMLDivElement | null>(null);
71
+
72
+ /* —— 统一状态管理(useReducer 封装 + 不按会话过滤恢复) —— */
73
+ const { state, dispatch } = useTerminalState(sessionId);
74
+ const { instances, groups, activeInstanceId, busy, bootReady } = state;
75
+
76
+ const activeInstance = instances.find(t => t.id === activeInstanceId) ?? null;
77
+ const activeGroup = groups.find(g => g.instances.some(i => i.id === activeInstanceId)) ?? null;
78
+
79
+ /* —— 终端 CRUD —— */
80
+ const { newTab, closeTab, restartActive, splitTerminal, onExit } = useTerminalTabs({
81
+ state, dispatch,
82
+ activeInstance, activeGroup,
83
+ workspaceCwd, sessionId,
84
+ });
85
+
86
+ /* —— 统一配置拉取(/config) —— */
87
+ const { fontFamily, fontSize, lineHeight, terminalProfiles } = useConfig(
88
+ /* setOpen 在侧边栏模式下无意义(展开/折叠由 DSH-better-sidebar 工作台控制),
89
+ * 传一个空操作 setter 满足 useConfig 签名 */
90
+ () => { /* 侧边栏模式下快捷键不切换面板 */ },
91
+ );
92
+
93
+ /** 首次可见已处理标记(只在首次可见时创建终端,避免每次切 tab 都新建) */
94
+ const openHandled = useRef(false);
95
+
96
+ /* 首次可见且无恢复的终端:创建一个会话。tab 切换隐藏→显示时不重复创建 */
97
+ useEffect(() => {
98
+ if (!visible) {
99
+ openHandled.current = false;
100
+ return;
101
+ }
102
+ if (!bootReady || instances.length > 0 || openHandled.current) return;
103
+ openHandled.current = true;
104
+ void newTab();
105
+ }, [visible, bootReady, instances.length, newTab]);
106
+
107
+ /** 状态标签(降级模式下头部栏显示) */
108
+ const stateLabel = busy
109
+ ? '启动中…'
110
+ : activeInstance === null
111
+ ? (instances.length === 0 ? '无会话' : '空闲')
112
+ : (activeInstance.exited ? instanceLabel(activeInstance) + ' 已退出,点 ⟳ 重启' : instanceLabel(activeInstance));
113
+
114
+ /**
115
+ * 按配置 id 新建终端。
116
+ *
117
+ * 下拉菜单传进来的是配置 id,从 /config 下发的 terminalProfiles 里找到该配置,
118
+ * 调用宿主半的 POST /sessions 新建终端。
119
+ */
120
+ const handleNewByType = useCallback((profileId: string): void => {
121
+ const profile = terminalProfiles.find(p => p.id === profileId);
122
+ if (profile === undefined) {
123
+ log.warn(`未知的终端配置 id:${profileId}`);
124
+ return;
125
+ }
126
+ void newTab(profile.id);
127
+ }, [newTab, terminalProfiles]);
128
+
129
+ /** 重命名终端实例 */
130
+ const handleRename = useCallback((instanceId: string, newName: string): void => {
131
+ dispatch({ type: 'RENAME_INSTANCE', id: instanceId, title: newName });
132
+ }, [dispatch]);
133
+
134
+ /** 选择终端实例(从右侧列表点击) */
135
+ const handleSelectInstance = useCallback((instanceId: string): void => {
136
+ dispatch({ type: 'SET_ACTIVE', id: instanceId });
137
+ }, [dispatch]);
138
+
139
+ /*
140
+ * 侧边栏 tab 布局:填满 DSH-better-sidebar 底部工作台的 tab 内容区。
141
+ * useHeader=true(降级模式,rightActions 不可用):渲染头部栏 + Body(两层)
142
+ * useHeader=false(正常模式,rightActions 可用):只渲染 Body(操作按钮在 tab 栏)
143
+ */
144
+ return React.createElement(
145
+ 'div',
146
+ {
147
+ className: 'dshTermSidebarRoot',
148
+ ref: rootRef,
149
+ },
150
+ /* 降级模式头部栏:引导符 + 状态 + 右侧按钮组(rightActions 不可用时回退) */
151
+ useHeader
152
+ ? React.createElement(
153
+ 'div',
154
+ { className: 'dshTermHeader', style: { flexShrink: 0 } },
155
+ React.createElement('span', { className: 'dshTermHeaderState', title: stateLabel }, stateLabel),
156
+ React.createElement(
157
+ 'div',
158
+ { className: 'dshTermHeaderActions' },
159
+ React.createElement(
160
+ 'div',
161
+ { className: 'dshTermNewWrap' },
162
+ React.createElement(
163
+ 'button',
164
+ {
165
+ className: 'dshTermNew',
166
+ title: '新建终端',
167
+ 'aria-label': '新建终端',
168
+ disabled: busy,
169
+ onClick: () => { void newTab(); },
170
+ },
171
+ Plus12(),
172
+ ),
173
+ React.createElement('div', { className: 'dshTermNewSep' }),
174
+ React.createElement(DropdownMenu, {
175
+ busy,
176
+ onNewTerminal: () => { void newTab(); },
177
+ onSplitTerminal: () => { void splitTerminal(); },
178
+ terminalProfiles,
179
+ onNewByType: handleNewByType,
180
+ }),
181
+ ),
182
+ React.createElement(RestartButton, { active: activeInstance, busy, onRestart: () => { void restartActive(); } }),
183
+ ),
184
+ )
185
+ : null,
186
+ /* Body:左侧终端显示区域 + 右侧终端列表 */
187
+ React.createElement(
188
+ 'div',
189
+ {
190
+ className: 'dshTermBodyWithList',
191
+ style: { flex: 1, minHeight: 0, overflow: 'hidden' },
192
+ },
193
+ /* 左侧终端显示区域 */
194
+ React.createElement(
195
+ 'div',
196
+ { className: 'dshTermTerminalArea' },
197
+ ...groups.map(g => {
198
+ if (g.instances.length === 1) {
199
+ const inst = g.instances[0];
200
+ return React.createElement(TermPane, {
201
+ key: inst.id,
202
+ instance: inst,
203
+ active: inst.id === activeInstanceId,
204
+ fontFamily,
205
+ fontSize,
206
+ lineHeight,
207
+ onExit,
208
+ });
209
+ }
210
+ /* 多实例组:水平并排 */
211
+ return React.createElement(
212
+ 'div',
213
+ { key: g.id, className: 'dshTermSplitGroup' },
214
+ ...g.instances.flatMap((inst, idx) => {
215
+ const elements: ReactElement[] = [];
216
+ if (idx > 0) {
217
+ elements.push(React.createElement('div', { key: g.id + '-div-' + idx, className: 'dshTermSplitDivider' }));
218
+ }
219
+ elements.push(
220
+ React.createElement(
221
+ 'div',
222
+ { key: inst.id, className: 'dshTermSplitPane' },
223
+ React.createElement(TermPane, {
224
+ instance: inst,
225
+ active: inst.id === activeInstanceId,
226
+ fontFamily,
227
+ fontSize,
228
+ lineHeight,
229
+ onExit,
230
+ }),
231
+ ),
232
+ );
233
+ return elements;
234
+ }),
235
+ );
236
+ }),
237
+ instances.length === 0
238
+ ? React.createElement(
239
+ 'div',
240
+ { className: 'dshTermEmpty' },
241
+ React.createElement('span', null, '没有终端会话'),
242
+ React.createElement(
243
+ 'button',
244
+ { className: 'dshTermEmptyBtn', onClick: () => { void newTab(); } },
245
+ Plus12(),
246
+ '新建终端',
247
+ ),
248
+ )
249
+ : null,
250
+ ),
251
+ /* 右侧终端列表(仅多于一个终端时显示) */
252
+ instances.length > 1
253
+ ? React.createElement(SideList, {
254
+ groups,
255
+ activeInstanceId,
256
+ onSelect: handleSelectInstance,
257
+ onClose: closeTab,
258
+ onRename: handleRename,
259
+ })
260
+ : null,
261
+ ),
262
+ );
263
+ }
264
+
265
+ /**
266
+ * 侧边栏终端 tab 栏右侧操作区组件。
267
+ *
268
+ * 通过 descriptor.rightActions 注入 DSH-better-sidebar 的 tab 栏右端(+ 按钮
269
+ * 右侧、关闭按钮左侧,右对齐)。渲染新建终端组合按钮(+ / 下拉)+ 重启按钮,
270
+ * 与 VSCode 风格一致——操作按钮直接在 tab 标签条上,不在内容区单独渲染头部。
271
+ *
272
+ * 复用与 TerminalSidebarTab 相同的 hooks,保持操作与内容区状态同步。
273
+ *
274
+ * @param props - DSH-better-sidebar 注入的 tab 组件 props
275
+ * @returns tab 栏右侧操作区根元素
276
+ */
277
+ function TerminalSidebarRightActions(props: TabComponentPropsLike): ReactElement {
278
+ const { scope, visible } = props;
279
+ const { useCallback } = React;
280
+
281
+ /* 与 TerminalSidebarTab 完全相同的 hooks 调用——rightActions 与 component
282
+ * 是同一 descriptor 的两个渲染面,各自独立挂载(DSH-better-sidebar 分别
283
+ * 渲染它们),所以 hooks 状态各自独立。但这不会造成问题:两者都从同一
284
+ * /sessions 恢复同一批终端(ownerSessionId=null 的全局终端),操作的是
285
+ * 同一批宿主半 PTY 会话。rightActions 里的按钮触发 HTTP CRUD,内容区
286
+ * 通过 WebSocket 实时感知变化(newTab/closeTab/restart 的 HTTP 响应更新
287
+ * 内容区 state,WebSocket onExit 同步退出状态)。 */
288
+ const sessionId = undefined;
289
+ const workspaceCwd = scope.cwd;
290
+
291
+ const { state, dispatch } = useTerminalState(sessionId);
292
+ const { instances, activeInstanceId, busy } = state;
293
+
294
+ const activeInstance = instances.find(t => t.id === activeInstanceId) ?? null;
295
+ const activeGroup = state.groups.find(g => g.instances.some(i => i.id === activeInstanceId)) ?? null;
296
+
297
+ const { newTab, restartActive, splitTerminal } = useTerminalTabs({
298
+ state, dispatch,
299
+ activeInstance, activeGroup,
300
+ workspaceCwd, sessionId,
301
+ });
302
+
303
+ const { terminalProfiles } = useConfig(() => { /* 侧边栏模式不切换面板 */ });
304
+
305
+ const handleNewByType = useCallback((profileId: string): void => {
306
+ const profile = terminalProfiles.find(p => p.id === profileId);
307
+ if (profile === undefined) {
308
+ log.warn(`未知的终端配置 id:${profileId}`);
309
+ return;
310
+ }
311
+ void newTab(profile.id);
312
+ }, [newTab, terminalProfiles]);
313
+
314
+ /*
315
+ * rightActions 容器:DSH-better-sidebar 的 .tabBarRightActions 已提供
316
+ * flex:1 + justify-content:flex-end 右对齐。这里只渲染操作按钮组,
317
+ * 复用独立模式的 .dshTermHeaderActions 等样式(按钮尺寸/颜色一致)。
318
+ */
319
+ return React.createElement(
320
+ 'div',
321
+ { className: 'dshTermHeaderActions', style: { padding: '0 4px' } },
322
+ /* 新建终端组合按钮(+ / 下拉) */
323
+ React.createElement(
324
+ 'div',
325
+ { className: 'dshTermNewWrap' },
326
+ React.createElement(
327
+ 'button',
328
+ {
329
+ className: 'dshTermNew',
330
+ title: '新建终端',
331
+ 'aria-label': '新建终端',
332
+ disabled: busy,
333
+ onClick: () => { void newTab(); },
334
+ },
335
+ Plus12(),
336
+ ),
337
+ React.createElement('div', { className: 'dshTermNewSep' }),
338
+ React.createElement(DropdownMenu, {
339
+ busy,
340
+ onNewTerminal: () => { void newTab(); },
341
+ onSplitTerminal: () => { void splitTerminal(); },
342
+ terminalProfiles,
343
+ onNewByType: handleNewByType,
344
+ }),
345
+ ),
346
+ /* 重启按钮 */
347
+ React.createElement(RestartButton, { active: activeInstance, busy, onRestart: () => { void restartActive(); } }),
348
+ );
349
+ }
350
+
351
+ /**
352
+ * 创建侧边栏终端 tab 的描述符,供 DSH-better-sidebar 的 registerTab 注册。
353
+ *
354
+ * 描述符字段说明:
355
+ * - id: 'dsh-oh-my-terminal'(与插件包名一致,避免与其他插件 tab 冲突)
356
+ * - title: '终端'(中文,与独立模式 bar 标题一致)
357
+ * - icon: TerminalGlyph14(着 DSH 的 --dsw-alias-label-primary 色,与
358
+ * DSH-better-sidebar 内置终端图标风格一致)
359
+ * - single: true(单实例 tab——打开时聚焦已有而非重复创建)
360
+ * - order: 40(排在 DSH-better-sidebar 内置 tab 之后:editor 10 / git 20 / subagent 30 / sidechat 35)
361
+ * - rightActions: TerminalSidebarRightActions(注入 tab 栏右端,操作按钮不在内容区单独渲染头部)
362
+ *
363
+ * **能力探测降级**:当 DSH-better-sidebar 版本不支持 rightActions 能力时,
364
+ * descriptor 不提供 rightActions 字段,component 回退到内容区顶部两层显示
365
+ * 方案(渲染头部栏 + 终端区)。
366
+ *
367
+ * @param supportsRightActions - DSH-better-sidebar 是否支持 rightActions 能力
368
+ * @returns tab 描述符
369
+ */
370
+ export function createTerminalTabDescriptor(supportsRightActions: boolean): BetterSidebarTabDescriptorLike {
371
+ return {
372
+ id: 'dsh-oh-my-terminal',
373
+ title: () => '终端',
374
+ icon: (size: number) => React.createElement('span', {
375
+ style: {
376
+ display: 'inline-flex',
377
+ alignItems: 'center',
378
+ color: 'var(--dsw-alias-label-primary)',
379
+ },
380
+ }, TerminalGlyph14()),
381
+ order: SIDEBAR_TAB_ORDER,
382
+ single: true,
383
+ component: (props) => React.createElement(TerminalSidebarTab, { ...props, useHeader: !supportsRightActions }),
384
+ ...(supportsRightActions
385
+ ? {
386
+ // rightActions 签名是 (ctx, scope, state) => ReactNode(三个独立参数,
387
+ // 不是 props 对象——与 DSH-better-sidebar 的 TabDescriptor.rightActions
388
+ // 调用约定一致)。这里把它们组装成 TabComponentPropsLike 传给组件。
389
+ rightActions: (ctx: unknown, scope: { sessionId: string; cwd?: string }, _state: unknown) =>
390
+ React.createElement(TerminalSidebarRightActions, { ctx: ctx as TabComponentPropsLike['ctx'], scope, visible: true, tab: { id: '', type: '', title: '' } }),
391
+ }
392
+ : {}),
393
+ };
394
+ }
@@ -114,6 +114,11 @@ export const TERM_THEME: Record<string, string> = {
114
114
 
115
115
  /** 面板全局 CSS 字符串(含所有 .dshTerm* 样式规则) */
116
116
  export const PANEL_CSS = `.dshTermRoot{position:fixed;bottom:0;z-index:50;font-family:Inter,var(--dsw-font-family)}
117
+ /* 兼容模式根容器:嵌入 DSH-better-sidebar 底部工作台 tab 内容区。
118
+ * 覆盖 .dshTermRoot 的 position:fixed/bottom:0/z-index——这些是独立模式
119
+ * 底部固定面板的定位,在 tab 内容区里会导致组件脱离文档流覆盖整个侧边栏。
120
+ * 改为 position:relative 填满 tab 内容区(flex column 布局) */
121
+ .dshTermSidebarRoot{position:relative;bottom:auto;z-index:auto;width:100%;height:100%;display:flex;flex-direction:column;min-height:0;overflow:hidden;background:var(--dsw-specific-tip)}
117
122
  .dshTermBar{box-sizing:border-box;width:100%;height:34px;display:flex;align-items:center;gap:10px;padding:0 14px;background:var(--dsw-specific-tip);border-top:1px solid var(--dsw-alias-border-l1);cursor:pointer;color:var(--dsw-alias-label-primary);text-align:left;user-select:none;-webkit-user-select:none}
118
123
  .dshTermBar:focus-visible{outline:2px solid var(--dsw-alias-label-tertiary);outline-offset:-2px}
119
124
  .dshTermBarLead{color:var(--dsw-alias-label-tertiary);flex:none;place-items:center;display:grid}
package/src/client.tsx CHANGED
@@ -60,6 +60,9 @@ import {
60
60
  resetShortcutsState, markShortcutsActive,
61
61
  registerPanelToggler, notifyPanelToggle,
62
62
  } from './client/shortcut-bridge.js';
63
+ import { detectBetterSidebar } from './client/compat.js';
64
+ import type { BetterSidebarServiceLike } from './client/compat.js';
65
+ import { createTerminalTabDescriptor } from './client/sidebar-tab.js';
63
66
  import { SHORTCUT_COMMAND_ID, SHORTCUT_DEFAULTS } from './constants.js';
64
67
  import { createLogger } from './logger.js';
65
68
 
@@ -116,15 +119,97 @@ export const inject = ['slots'];
116
119
  * DSH 设置界面统一配置);旧宿主无 shortcuts 服务时软探测不命中,维持裸
117
120
  * keydown 降级路径。
118
121
  *
122
+ * **DSH-better-sidebar 兼容模式**:用 `ctx.inject(['betterSidebar'], ...)` 等待
123
+ * DSH-better-sidebar 的注册服务可用。服务可用时切换为兼容模式——注销
124
+ * `conversation.input.dock` 底部面板(避免与 DSH-better-sidebar 底部工作台的
125
+ * 终端竞争同一 DSH session 的 write handle),改为通过 `service.registerTab()`
126
+ * 注册"终端"tab 嵌入 DSH-better-sidebar 底部工作台。兼容模式下 PTY session
127
+ * 不绑定 DSH sessionId,从根源上消除 session 冲突。
128
+ *
129
+ * 激活顺序自适应:DSH-better-sidebar 可能先于或后于本插件激活。
130
+ * - 先激活:`ctx.get('betterSidebar')` 即时命中,直接走兼容模式
131
+ * - 后激活:先注册 dock 降级,`ctx.inject` 回调触发时切换为兼容模式
132
+ * - 永不安装:`ctx.inject` 回调不触发,保持 dock 独立模式
133
+ *
119
134
  * @param ctx - 远端页面的 cordis 上下文
120
135
  */
121
136
  export function apply(ctx: ClientContext): void {
122
- registerToggleShortcut(ctx);
123
- ctx.slots.inject('conversation.input.dock', () => ctx.slots.register(
124
- { name: 'conversation.input.dock', id: 'terminal', order: 10 },
125
- TerminalPanel,
126
- ));
137
+ // 设置页始终注册(两种模式用户都需配置终端种类/字体等)
127
138
  registerSettingsPage(ctx);
139
+
140
+ // 即时探测:DSH-better-sidebar 先激活时直接走兼容模式
141
+ const betterSidebar = detectBetterSidebar(ctx);
142
+ if (betterSidebar !== undefined) {
143
+ registerSidebarTab(ctx, betterSidebar);
144
+ return;
145
+ }
146
+
147
+ // DSH-better-sidebar 尚未激活:先注册独立模式(dock + shortcuts),
148
+ // 再用 ctx.inject 等待服务可用时切换为兼容模式
149
+ const dockDisposer = registerStandaloneDock(ctx);
150
+ registerToggleShortcut(ctx);
151
+
152
+ // ctx.inject(['betterSidebar'], cb):服务可用时回调,永不安装时不触发。
153
+ // 返回 Fiber(带 dispose 方法),随插件销毁清理(与 DSH-better-sidebar 自身
154
+ // native surface 的 ctx.inject 用法一致——不用 ctx.effect 包裹)
155
+ const seat = ctx.inject(['betterSidebar'], (injected: Context) => {
156
+ const service = detectBetterSidebar(injected);
157
+ if (service === undefined) return;
158
+ // 切换:注销独立模式 dock(shortcuts 命令保留——兼容模式下快捷键无害)
159
+ dockDisposer();
160
+ // 注册兼容模式 tab
161
+ registerSidebarTab(ctx, service);
162
+ });
163
+ ctx.effect(() => () => { void seat.dispose(); }, 'dsh-oh-my-terminal.better-sidebar-inject');
164
+ }
165
+
166
+ /**
167
+ * 注册独立模式底部面板(`conversation.input.dock`),返回注销函数。
168
+ *
169
+ * 封装为独立函数,供动态切换时注销。
170
+ *
171
+ * @param ctx - 远端页面的 cordis 上下文
172
+ * @returns 注销 dock 注册的 disposer
173
+ */
174
+ function registerStandaloneDock(ctx: ClientContext): () => void {
175
+ // ctx.slots.inject 返回的是声明监听的 disposer,内部 ctx.slots.register
176
+ // 返回的 disposer 需要在回调里捕获——但 slots.inject 的回调在声明存在时
177
+ // 触发,返回的 disposer 会注销整条声明。这里直接用 slots.inject 的返回值。
178
+ return ctx.slots.inject('conversation.input.dock', () => {
179
+ const regDispose = ctx.slots.register(
180
+ { name: 'conversation.input.dock', id: 'terminal', order: 10 },
181
+ TerminalPanel,
182
+ );
183
+ return regDispose;
184
+ }) as () => void;
185
+ }
186
+
187
+ /**
188
+ * 兼容模式:通过 DSH-better-sidebar 的 registerTab 注册"终端"tab。
189
+ *
190
+ * 不注册 conversation.input.dock(避免与底部工作台终端冲突)。PTY session
191
+ * 不绑定 DSH sessionId(sidebar-tab 组件内 sessionId 传 undefined),不进入
192
+ * DSH session 的 write handle 管理体系。
193
+ *
194
+ * @param ctx - 远端页面的 cordis 上下文
195
+ * @param service - DSH-better-sidebar 的 betterSidebar 服务实例
196
+ */
197
+ function registerSidebarTab(ctx: ClientContext, service: BetterSidebarServiceLike): void {
198
+ try {
199
+ // 能力探测:DSH-better-sidebar v0.25.0+ 支持 rightActions(操作按钮注入 tab 栏右端);
200
+ // 旧版本不支持时回退到内容区顶部两层显示方案(useHeader=true)
201
+ const supportsRightActions = service.features?.includes('rightActions') ?? false;
202
+ const descriptor = createTerminalTabDescriptor(supportsRightActions);
203
+ const dispose = service.registerTab(descriptor);
204
+ // 命令随插件销毁注销(registerTab 返回幂等 disposer)
205
+ ctx.effect(() => dispose, 'dsh-oh-my-terminal.sidebar-tab');
206
+ log.info(`已接入 DSH-better-sidebar 兼容模式(终端 tab 注册到底部工作台,rightActions=${supportsRightActions})`);
207
+ } catch (error) {
208
+ // 注册抛错(如 tab id 已被其他插件占用)→ 降级为独立模式
209
+ const msg = error instanceof Error ? error.message : String(error);
210
+ log.warn(`betterSidebar registerTab 失败,降级为独立模式:${msg}`);
211
+ registerStandaloneDock(ctx);
212
+ }
128
213
  }
129
214
 
130
215
  /** 插件在 profile patch 中的条目 id(rowConfigKey 的后半段,不可随意改动) */