dsh-coding-sidebar 1.0.7 → 1.0.9

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.
Files changed (81) hide show
  1. package/README.md +2 -2
  2. package/lib/client-editor.js +485 -228
  3. package/lib/client-registry.js +1238 -7583
  4. package/lib/client-terminal.js +345 -189
  5. package/lib/client.js +1213 -7558
  6. package/lib/index.js +518 -69
  7. package/lib/types/agent-pty.d.ts +62 -4
  8. package/lib/types/bundle-route.d.ts +1 -1
  9. package/lib/types/client/DiffView.d.ts +33 -1
  10. package/lib/types/client/EditorHost.d.ts +4 -1
  11. package/lib/types/client/FileTree.d.ts +6 -2
  12. package/lib/types/client/TerminalWaitBanner.d.ts +6 -0
  13. package/lib/types/client/TreePanel.d.ts +4 -1
  14. package/lib/types/client/api.d.ts +26 -0
  15. package/lib/types/client/chunk-loader.d.ts +1 -1
  16. package/lib/types/client/chunks/locale.d.ts +3 -0
  17. package/lib/types/client/conversation-draft.d.ts +85 -4
  18. package/lib/types/client/locales.d.ts +13 -20
  19. package/lib/types/client/selection-popup.d.ts +58 -0
  20. package/lib/types/client/service.d.ts +1 -1
  21. package/lib/types/client/state.d.ts +15 -0
  22. package/lib/types/client/terminal-font.d.ts +25 -2
  23. package/lib/types/context-types.d.ts +3 -1
  24. package/lib/types/fs-operations.d.ts +42 -0
  25. package/lib/types/git.d.ts +15 -0
  26. package/lib/types/index.d.ts +9 -0
  27. package/lib/types/prefs-shared.d.ts +7 -5
  28. package/lib/types/pty-manager.d.ts +53 -0
  29. package/lib/types/wire.d.ts +6 -2
  30. package/package.json +1 -1
  31. package/src/agent-pty.ts +221 -33
  32. package/src/bundle-route.ts +1 -1
  33. package/src/client/DiffTab.tsx +10 -1
  34. package/src/client/DiffView.tsx +174 -19
  35. package/src/client/EditorHost.tsx +9 -2
  36. package/src/client/FileTree.tsx +151 -8
  37. package/src/client/Sidebar.tsx +95 -25
  38. package/src/client/TerminalView.tsx +41 -0
  39. package/src/client/TerminalWaitBanner.tsx +32 -0
  40. package/src/client/TextEditor.tsx +27 -45
  41. package/src/client/TreePanel.tsx +22 -2
  42. package/src/client/api.ts +20 -0
  43. package/src/client/chunk-loader.ts +1 -1
  44. package/src/client/chunks/locale.tsx +60 -0
  45. package/src/client/conversation-draft.ts +233 -7
  46. package/src/client/index.tsx +19 -8
  47. package/src/client/locales-ar.ts +17 -4
  48. package/src/client/locales-de.ts +17 -4
  49. package/src/client/locales-fr.ts +17 -4
  50. package/src/client/locales-hi.ts +17 -4
  51. package/src/client/locales-id.ts +17 -4
  52. package/src/client/locales-it.ts +17 -4
  53. package/src/client/locales-ja.ts +17 -4
  54. package/src/client/locales-ko.ts +17 -4
  55. package/src/client/locales-nl.ts +17 -4
  56. package/src/client/locales-pl.ts +17 -4
  57. package/src/client/locales-pt.ts +17 -4
  58. package/src/client/locales-ru.ts +17 -4
  59. package/src/client/locales-sv.ts +17 -4
  60. package/src/client/locales-th.ts +17 -4
  61. package/src/client/locales-tr.ts +17 -4
  62. package/src/client/locales-vi.ts +17 -4
  63. package/src/client/locales-zh-HK.ts +17 -4
  64. package/src/client/locales-zh-MO.ts +17 -4
  65. package/src/client/locales-zh-TW.ts +17 -4
  66. package/src/client/locales.ts +41 -51
  67. package/src/client/selection-popup.ts +155 -0
  68. package/src/client/service.ts +1 -1
  69. package/src/client/sidebar.module.css +68 -0
  70. package/src/client/state.ts +56 -10
  71. package/src/client/terminal-font.ts +34 -3
  72. package/src/context-types.ts +3 -2
  73. package/src/fs-operations.ts +126 -4
  74. package/src/git.ts +56 -3
  75. package/src/index.ts +82 -7
  76. package/src/open-external.ts +3 -4
  77. package/src/prefs-shared.ts +7 -5
  78. package/src/pty-manager.ts +176 -5
  79. package/src/sidechat-routes.ts +13 -1
  80. package/src/tools.ts +24 -6
  81. package/src/wire.ts +3 -0
@@ -95,6 +95,7 @@ export const zh = {
95
95
  terminalDepsFailed: '终端依赖 node-pty 加载失败',
96
96
  terminalDepsHint: '在 DSH 所在环境的终端或 cmd 中执行以下命令修复,然后点重试(node-pty 与 DSH 核心保持同一版本):',
97
97
  terminalDepsProfile: '(检测到 profile:{profile})',
98
+ terminalShellNotFound: '未找到配置的 Shell:{name},请到 设置 → 侧边卡片 → 终端 检查 Shell 路径',
98
99
  refresh: '刷新',
99
100
  refreshUnsavedConfirm: '文件已在磁盘更新,刷新将丢弃未保存编辑。继续吗?',
100
101
  save: '保存',
@@ -159,6 +160,18 @@ export const zh = {
159
160
  producedOpen: '在侧边栏中打开',
160
161
  showInFolder: '在文件夹中显示',
161
162
  disconnected: '终端连接断开,重连中…',
163
+ terminalWaitBanner: 'Agent 正在等待 {needle}',
164
+ terminalSkipWait: '跳过等待',
165
+ gitFoldExpand: '展开 {count} 行上下文',
166
+ gitFoldLoading: '展开中…',
167
+ gitFoldFailed: '展开失败',
168
+ rename: '重命名',
169
+ renameInvalid: '名称不能为空,且不能包含路径分隔符。',
170
+ delete: '删除',
171
+ deleteTitle: '删除「{name}」?',
172
+ deleteDescFile: '将永久删除该文件,此操作不可撤销。',
173
+ deleteDescDir: '将永久删除该目录及其全部内容,此操作不可撤销。',
174
+ dismiss: '关闭',
162
175
  exited: '终端进程已退出',
163
176
  noSession: '选择一个会话以使用侧边栏',
164
177
  pluginNotLoaded: '插件未加载,标签页暂不可用:',
@@ -218,10 +231,10 @@ export const zh = {
218
231
  settingsConflict: '设置已被其他窗口修改,请重试',
219
232
  binaryNoPreview: '此文件类型不支持预览',
220
233
  downloadToView: '下载查看',
221
- settingsSubagentTitle: '检测到子代理时自动展开任务管理页',
222
- settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开任务管理页;关闭后需手动打开',
223
- settingsJobsTitle: '有新后台任务时自动展开后台任务页',
224
- settingsJobsDesc: '当前会话出现新的后台任务时,自动展开侧边栏并打开后台任务页(每个新任务都会触发);关闭后需手动打开',
234
+ settingsSubagentTitle: '检测到子代理时自动激活任务管理页',
235
+ settingsSubagentDesc: '当前会话产生新的子代理时,自动激活任务管理页;宽屏同时展开侧边栏,窄屏不强制展开全屏抽屉;关闭后需手动打开',
236
+ settingsJobsTitle: '有新后台任务时自动激活后台任务页',
237
+ settingsJobsDesc: '当前会话出现新的后台任务时,自动激活后台任务页(每个新任务都会触发);宽屏同时展开侧边栏,窄屏不强制展开全屏抽屉;关闭后需手动打开',
225
238
  settingsToolsTitle: '为模型注入终端工具',
226
239
  settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)',
227
240
  settingsFontFamilyTitle: '终端字体',
@@ -431,6 +444,7 @@ export const en: Record<keyof typeof zh, string> = {
431
444
  terminalDepsFailed: 'Terminal dependency node-pty failed to load',
432
445
  terminalDepsHint: 'Run the command below in a terminal or cmd on the DSH machine to repair it, then retry (node-pty stays in sync with the DSH core version):',
433
446
  terminalDepsProfile: ' (detected profile: {profile})',
447
+ terminalShellNotFound: 'Configured shell not found: {name} — check the shell path under Settings → Side card → Terminal',
434
448
  refresh: 'Refresh',
435
449
  refreshUnsavedConfirm: 'The file changed on disk. Refreshing will discard unsaved edits. Continue?',
436
450
  save: 'Save',
@@ -495,6 +509,18 @@ export const en: Record<keyof typeof zh, string> = {
495
509
  producedOpen: 'Open in sidebar',
496
510
  showInFolder: 'Show in folder',
497
511
  disconnected: 'Terminal disconnected, reconnecting…',
512
+ terminalWaitBanner: 'Agent is waiting for {needle}',
513
+ terminalSkipWait: 'Skip wait',
514
+ gitFoldExpand: 'Expand {count} context lines',
515
+ gitFoldLoading: 'Expanding…',
516
+ gitFoldFailed: 'Failed to expand',
517
+ rename: 'Rename',
518
+ renameInvalid: 'The name cannot be empty or contain path separators.',
519
+ delete: 'Delete',
520
+ deleteTitle: 'Delete "{name}"?',
521
+ deleteDescFile: 'This permanently deletes the file. This cannot be undone.',
522
+ deleteDescDir: 'This permanently deletes the directory and everything inside it. This cannot be undone.',
523
+ dismiss: 'Dismiss',
498
524
  exited: 'Terminal process exited',
499
525
  noSession: 'Select a conversation to use the sidebar',
500
526
  pluginNotLoaded: 'Plugin not loaded; tab unavailable:',
@@ -554,10 +580,10 @@ export const en: Record<keyof typeof zh, string> = {
554
580
  settingsConflict: 'The setting changed in another window — please retry',
555
581
  binaryNoPreview: 'This file type cannot be previewed',
556
582
  downloadToView: 'Download to view',
557
- settingsSubagentTitle: 'Auto-open the Tasks page when a subagent appears',
558
- settingsSubagentDesc: 'Expand the side card and open the Tasks page when the current conversation spawns a new subagent; turn off to open it manually',
559
- settingsJobsTitle: 'Auto-open the Jobs page on a new background job',
560
- settingsJobsDesc: 'Expand the side card and open the Jobs page whenever a new background job appears for the current conversation (every new job triggers); turn off to open it manually',
583
+ settingsSubagentTitle: 'Auto-activate the Tasks page when a subagent appears',
584
+ settingsSubagentDesc: 'Activate the Tasks page when the current conversation spawns a new subagent; on wide screens the side card expands with it, narrow screens never force the full-screen drawer; turn off to open it manually',
585
+ settingsJobsTitle: 'Auto-activate the Jobs page on a new background job',
586
+ settingsJobsDesc: 'Activate the Jobs page whenever a new background job appears for the current conversation (every new job triggers); on wide screens the side card expands with it, narrow screens never force the full-screen drawer; turn off to open it manually',
561
587
  settingsToolsTitle: 'Inject terminal tools for the model',
562
588
  settingsToolsDesc: 'When enabled, the model can create and drive sidebar terminals through the 8 terminal_* tools (off by default)',
563
589
  settingsFontFamilyTitle: 'Terminal font family',
@@ -699,49 +725,13 @@ export const en: Record<keyof typeof zh, string> = {
699
725
  */
700
726
  export const LOCALE_NS = 'betterSidebar'
701
727
 
702
- // The ja dictionary lives in a sibling file (326 keys) so this module
703
- // stays readable. Type-checked against the zh key set: a missing or extra
704
- // ja key is a compile error.
705
- import { ja as jaDict } from './locales-ja.ts'
706
- import { de as deDict } from './locales-de.ts'
707
- import { fr as frDict } from './locales-fr.ts'
708
- import { pt as ptDict } from './locales-pt.ts'
709
- import { ko as koDict } from './locales-ko.ts'
710
- import { ar as arDict } from './locales-ar.ts'
711
- import { hi as hiDict } from './locales-hi.ts'
712
- import { id as idDict } from './locales-id.ts'
713
- import { tr as trDict } from './locales-tr.ts'
714
- import { vi as viDict } from './locales-vi.ts'
715
- import { th as thDict } from './locales-th.ts'
716
- import { ru as ruDict } from './locales-ru.ts'
717
- import { it as itDict } from './locales-it.ts'
718
- import { nl as nlDict } from './locales-nl.ts'
719
- import { sv as svDict } from './locales-sv.ts'
720
- import { pl as plDict } from './locales-pl.ts'
721
- import { zhHK as zhHKDict } from './locales-zh-HK.ts'
722
- import { zhTW as zhTWDict } from './locales-zh-TW.ts'
723
- import { zhMO as zhMODict } from './locales-zh-MO.ts'
724
-
725
- /** The ja dictionary (key-set-equal to zh, enforced by the type annotation). */
726
- export const ja: Record<keyof typeof zh, string> = jaDict as Record<keyof typeof zh, string>
727
- export const de: Record<keyof typeof zh, string> = deDict as Record<keyof typeof zh, string>
728
- export const fr: Record<keyof typeof zh, string> = frDict as Record<keyof typeof zh, string>
729
- export const pt: Record<keyof typeof zh, string> = ptDict as Record<keyof typeof zh, string>
730
- export const ko: Record<keyof typeof zh, string> = koDict as Record<keyof typeof zh, string>
731
- export const ar: Record<keyof typeof zh, string> = arDict as Record<keyof typeof zh, string>
732
- export const hi: Record<keyof typeof zh, string> = hiDict as Record<keyof typeof zh, string>
733
- export const id: Record<keyof typeof zh, string> = idDict as Record<keyof typeof zh, string>
734
- export const tr: Record<keyof typeof zh, string> = trDict as Record<keyof typeof zh, string>
735
- export const vi: Record<keyof typeof zh, string> = viDict as Record<keyof typeof zh, string>
736
- export const th: Record<keyof typeof zh, string> = thDict as Record<keyof typeof zh, string>
737
- export const ru: Record<keyof typeof zh, string> = ruDict as Record<keyof typeof zh, string>
738
- export const it: Record<keyof typeof zh, string> = itDict as Record<keyof typeof zh, string>
739
- export const nl: Record<keyof typeof zh, string> = nlDict as Record<keyof typeof zh, string>
740
- export const sv: Record<keyof typeof zh, string> = svDict as Record<keyof typeof zh, string>
741
- export const pl: Record<keyof typeof zh, string> = plDict as Record<keyof typeof zh, string>
742
- export const zhHK: Record<keyof typeof zh, string> = zhHKDict as Record<keyof typeof zh, string>
743
- export const zhTW: Record<keyof typeof zh, string> = zhTWDict as Record<keyof typeof zh, string>
744
- export const zhMO: Record<keyof typeof zh, string> = zhMODict as Record<keyof typeof zh, string>
728
+ // The 19 non-zh/en dictionaries live in the LAZY `locale` chunk
729
+ // (src/client/chunks/locale.tsx → lib/client-locale.js): `t()` only ever
730
+ // consults zh/en plus the better-locale override store, so those dicts are
731
+ // needed only when better-locale is installed — the client apply fetches the
732
+ // chunk there and registers its `localeDicts` into the override store. They
733
+ // used to sit statically in the core bundle (≈ half its weight) for a code
734
+ // path most sessions never enter.
745
735
 
746
736
  /** The DSH locale service attached by the client apply (absent → browser detection). */
747
737
  let localeService: { getSnapshot(): { active: string } } | undefined
@@ -0,0 +1,155 @@
1
+ /**
2
+ * The floating "add selection to conversation" popup shared by the text
3
+ * viewer: a viewport-anchored button portaled to `document.body`, kept alive
4
+ * across the selection gesture and committed on click.
5
+ *
6
+ * Dismissal contract (upstream issue #425): the popup must never outlive its
7
+ * editor surface. The sidebar keeps every tab MOUNTED — switching tabs only
8
+ * flips the pane cell to `display:none` and collapsing the panel translates
9
+ * it off-screen — while the portaled `position:fixed` button stays pinned to
10
+ * its viewport anchor, ignoring both. The caller already hides on surface
11
+ * scrolls, selection collapse and content swaps; this hook adds the global
12
+ * dismissal that covers everything else:
13
+ *
14
+ * - any `mousedown` outside the button (tab bar, composer, another pane,
15
+ * the collapse toggle, …) closes it;
16
+ * - `Escape` closes it;
17
+ * - the document going hidden (`visibilitychange`) or the window losing
18
+ * focus closes it;
19
+ * - an `IntersectionObserver` on the editor surface closes it as soon as the
20
+ * surface leaves the viewport — the tab-switch (`display:none`) and
21
+ * panel-collapse (translated off-screen) paths have no DOM events of their
22
+ * own, so the geometry signal is the only reliable one.
23
+ *
24
+ * The button's own `mousedown` is never treated as an outside click: the
25
+ * caller preventDefaults it to keep the selection/caret alive until the
26
+ * click commits (the hook's capture-phase listener runs first and must not
27
+ * hide for it).
28
+ */
29
+ import { useEffect, useRef, useState, type RefObject } from 'react'
30
+
31
+ /** The floating "add to conversation" action: payload + viewport anchor. */
32
+ export interface SelectionPopup {
33
+ insert: string
34
+ left: number
35
+ top: number
36
+ }
37
+
38
+ export interface SelectionPopupOptions {
39
+ /** Commit the payload into the composer draft (button click). */
40
+ onCommit(insert: string): void
41
+ /**
42
+ * The DOM surface that must stay on screen for the popup to live: the
43
+ * CodeMirror host element. Called lazily (refs are null until the content
44
+ * loads).
45
+ */
46
+ getSurface(): HTMLElement | null
47
+ }
48
+
49
+ export interface SelectionPopupControls {
50
+ /** The current popup (null = hidden). */
51
+ popup: SelectionPopup | null
52
+ /** Attach to the portaled button element. */
53
+ buttonRef: RefObject<HTMLButtonElement>
54
+ /** Anchor the popup above a selection (viewport-clamped). */
55
+ show(insert: string, left: number, top: number): void
56
+ /** Hide the popup (idempotent). */
57
+ hide(): void
58
+ /** The button's click: commit the stored payload, then hide. */
59
+ commit(): void
60
+ }
61
+
62
+ export function useSelectionPopup(options: SelectionPopupOptions): SelectionPopupControls {
63
+ // Latest-callback refs: the dismissal listeners live for the mount's
64
+ // lifetime, so they must not capture stale closures across renders.
65
+ const onCommitRef = useRef(options.onCommit)
66
+ const getSurfaceRef = useRef(options.getSurface)
67
+ onCommitRef.current = options.onCommit
68
+ getSurfaceRef.current = options.getSurface
69
+
70
+ const [popup, setPopup] = useState<SelectionPopup | null>(null)
71
+ /** Live mirror for click/event-time reads (no re-render race). */
72
+ const popupRef = useRef<SelectionPopup | null>(null)
73
+ /** The portaled button itself (for the outside-click guard). */
74
+ const buttonRef = useRef<HTMLButtonElement>(null)
75
+ /** The surface visibility observer (created lazily on open). */
76
+ const observerRef = useRef<IntersectionObserver | null>(null)
77
+
78
+ const show = (insert: string, left: number, top: number): void => {
79
+ const next: SelectionPopup = {
80
+ insert,
81
+ left: Math.min(Math.max(left, 80), window.innerWidth - 80),
82
+ top,
83
+ }
84
+ popupRef.current = next
85
+ setPopup(next)
86
+ }
87
+
88
+ const hide = (): void => {
89
+ popupRef.current = null
90
+ setPopup(null)
91
+ }
92
+
93
+ const commit = (): void => {
94
+ const current = popupRef.current
95
+ if (current === null) return
96
+ onCommitRef.current(current.insert)
97
+ hide()
98
+ }
99
+
100
+ // Global dismissal listeners, registered once; every handler reads the
101
+ // live popup ref so it no-ops while no popup is open.
102
+ useEffect(() => {
103
+ const onMouseDown = (event: MouseEvent): void => {
104
+ if (popupRef.current === null) return
105
+ const button = buttonRef.current
106
+ if (button !== null && (button === event.target || button.contains(event.target as Node))) return
107
+ hide()
108
+ }
109
+ const onKeyDown = (event: KeyboardEvent): void => {
110
+ if (event.key === 'Escape' && popupRef.current !== null) hide()
111
+ }
112
+ const onVisibilityChange = (): void => {
113
+ if (document.hidden && popupRef.current !== null) hide()
114
+ }
115
+ const onWindowBlur = (): void => {
116
+ if (popupRef.current !== null) hide()
117
+ }
118
+ document.addEventListener('mousedown', onMouseDown, true)
119
+ document.addEventListener('keydown', onKeyDown, true)
120
+ document.addEventListener('visibilitychange', onVisibilityChange)
121
+ window.addEventListener('blur', onWindowBlur)
122
+ return () => {
123
+ document.removeEventListener('mousedown', onMouseDown, true)
124
+ document.removeEventListener('keydown', onKeyDown, true)
125
+ document.removeEventListener('visibilitychange', onVisibilityChange)
126
+ window.removeEventListener('blur', onWindowBlur)
127
+ observerRef.current?.disconnect()
128
+ observerRef.current = null
129
+ }
130
+ }, [])
131
+
132
+ // Re-target the visibility observer whenever a popup opens: the surface
133
+ // refs may have been null at mount (content loads async), so the surface
134
+ // is only trustworthy at open time.
135
+ useEffect(() => {
136
+ if (popup === null) return
137
+ observerRef.current?.disconnect()
138
+ observerRef.current = null
139
+ if (typeof IntersectionObserver === 'undefined') return
140
+ const surface = getSurfaceRef.current()
141
+ if (surface === null) return
142
+ const observer = new IntersectionObserver((entries) => {
143
+ for (const entry of entries) {
144
+ if (!entry.isIntersecting) hide()
145
+ }
146
+ }, { threshold: 0 })
147
+ observerRef.current = observer
148
+ observer.observe(surface)
149
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- the boolean
150
+ // flip is the only thing that must re-run this; an anchor move while
151
+ // open keeps the same surface.
152
+ }, [popup !== null])
153
+
154
+ return { popup, buttonRef, show, hide, commit }
155
+ }
@@ -150,7 +150,7 @@ export interface TabComponentProps {
150
150
  /** The explorer's reveal-highlight set (ExplorerView; "Show in folder" targets). */
151
151
  revealed?: string[]
152
152
  onToggleDir?: (path: string) => void
153
- onReferenceFile?: (path: string) => void
153
+ onReferenceFile?: (path: string, isDir: boolean) => void
154
154
  onOpenFile?: (path: string) => void
155
155
  onOpenDiff?: (tab: SidebarTab) => void
156
156
  onSubagentJump?: (childSessionId: string) => void
@@ -1651,6 +1651,74 @@ body[data-dsh-sidebar-dragging] .panel {
1651
1651
  color: var(--dsw-alias-label-primary);
1652
1652
  }
1653
1653
 
1654
+ /* A hunk-gap fold in a diff: the "expand hidden context" chip between two
1655
+ hunks (loading is inert, failure degrades to a static marker). */
1656
+ .gitFoldRow {
1657
+ display: block;
1658
+ width: 100%;
1659
+ box-sizing: border-box;
1660
+ padding: 2px 12px;
1661
+ border: none;
1662
+ background: var(--dsw-alias-bg-layer-1);
1663
+ color: var(--dsw-alias-label-tertiary);
1664
+ font: var(--dsw-font-xxxs-11);
1665
+ text-align: center;
1666
+ cursor: pointer;
1667
+ outline: none;
1668
+ }
1669
+
1670
+ .gitFoldRow:hover {
1671
+ background: var(--dsw-alias-interactive-bg-hover);
1672
+ color: var(--dsw-alias-label-secondary);
1673
+ }
1674
+
1675
+ .gitFoldRow:disabled {
1676
+ cursor: default;
1677
+ }
1678
+
1679
+ .gitFoldRowFailed {
1680
+ color: var(--dsw-alias-state-error-primary);
1681
+ opacity: 0.7;
1682
+ }
1683
+
1684
+ /* The tree row's inline rename editor: fills the row's name slot, keeps the
1685
+ row's metrics so the tree does not jump when editing starts. */
1686
+ .explorerRenameInput {
1687
+ flex: 1;
1688
+ min-width: 0;
1689
+ height: 20px;
1690
+ padding: 0 4px;
1691
+ border: 1px solid var(--dsw-alias-border-l2);
1692
+ border-radius: 4px;
1693
+ background: var(--dsw-alias-bg-layer-2);
1694
+ color: var(--dsw-alias-label-primary);
1695
+ font: var(--dsw-font-xxs-12);
1696
+ outline: none;
1697
+ }
1698
+
1699
+ /* The "Agent 正在等待 {needle}" wait banner: a warn-toned strip above the
1700
+ terminal surface while the model blocks in terminal_wait_for; the needle
1701
+ ellipsizes inline and the full text rides the title tooltip. */
1702
+ .terminalWaitBanner {
1703
+ flex: none;
1704
+ display: flex;
1705
+ align-items: center;
1706
+ gap: 8px;
1707
+ padding: 3px 10px;
1708
+ font: var(--dsw-font-xxxs-11);
1709
+ color: var(--dsw-alias-state-warn-label);
1710
+ background: var(--dsw-alias-state-warn-tertiary);
1711
+ }
1712
+
1713
+ .terminalWaitNeedle {
1714
+ flex: 1;
1715
+ min-width: 0;
1716
+ overflow: hidden;
1717
+ text-overflow: ellipsis;
1718
+ white-space: nowrap;
1719
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1720
+ }
1721
+
1654
1722
  /* The node-pty dependency failure banner (issue #140): a taller warn block
1655
1723
  carrying the pasteable repair command (bash / cmd / PowerShell). */
1656
1724
  .terminalDepsBanner {
@@ -104,6 +104,14 @@ export interface SidebarState {
104
104
  splits: SplitNode
105
105
  /** Free windows (tabs dragged out onto the conversation area). */
106
106
  floats: FloatWindow[]
107
+ /**
108
+ * Live agent-terminal wait state (uuid → the wait the model currently
109
+ * blocks on in `terminal_wait_for`), mirrored from the host's
110
+ * agent-terminals push. Transient by design: sanitizeState never restores
111
+ * it, so a reload starts clean and the next push (sent immediately on WS
112
+ * attach) repopulates it.
113
+ */
114
+ agentWaits: Record<string, { needle: string; since: number }>
107
115
  }
108
116
 
109
117
  export const PANEL_MIN = 280
@@ -137,7 +145,7 @@ export function mintTabId(): string {
137
145
 
138
146
  /**
139
147
  * The largest numeric suffix across a raw persisted state's counter ids
140
- * (`pane:N` / `tab:N` / `split:N`). The uid counter is module-global and
148
+ * (`pane:N` / `tab:N` / `split:N` / `float:N`). The uid counter is module-global and
141
149
  * resets on every reload, so a split minted AFTER a reload would collide
142
150
  * with the persisted ids (a fresh "pane:1" beside the persisted "pane:1");
143
151
  * mapLeaf would then visit BOTH leaves and every open would land in both
@@ -148,7 +156,7 @@ function maxCounterId(parsed: unknown): number {
148
156
  let max = 0
149
157
  const consider = (id: unknown): void => {
150
158
  if (typeof id !== 'string') return
151
- const match = /^(?:pane|tab|split):(\d+)$/.exec(id)
159
+ const match = /^(?:pane|tab|split|float):(\d+)$/.exec(id)
152
160
  if (match !== null) max = Math.max(max, Number(match[1]))
153
161
  }
154
162
  const walk = (node: unknown): void => {
@@ -209,6 +217,7 @@ export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seed:
209
217
  revealed: [],
210
218
  splits: leaf,
211
219
  floats: [],
220
+ agentWaits: {},
212
221
  }
213
222
  }
214
223
 
@@ -854,6 +863,23 @@ export function agentTabId(uuid: string): string {
854
863
  return `${AGENT_TAB_PREFIX}${uuid}`
855
864
  }
856
865
 
866
+ /** Shallow equality of two agent-wait maps (same keys, same needle+since). */
867
+ function sameAgentWaits(
868
+ a: SidebarState['agentWaits'] | undefined,
869
+ b: Record<string, { needle: string; since: number }>,
870
+ ): boolean {
871
+ if (a === undefined) return Object.keys(b).length === 0
872
+ const aKeys = Object.keys(a)
873
+ if (aKeys.length !== Object.keys(b).length) return false
874
+ for (const key of aKeys) {
875
+ const av = a[key]
876
+ const bv = b[key]
877
+ if (av === undefined || bv === undefined) return false
878
+ if (av.needle !== bv.needle || av.since !== bv.since) return false
879
+ }
880
+ return true
881
+ }
882
+
857
883
  /**
858
884
  * Reconcile the sidebar's agent-terminal tabs with the host's live list.
859
885
  * The host pushes the current list of agent terminals (created by the model
@@ -868,7 +894,7 @@ export function agentTabId(uuid: string): string {
868
894
  */
869
895
  export function reconcileAgentTerminals(
870
896
  state: SidebarState,
871
- agentTerminals: ReadonlyArray<{ uuid: string; title: string }>,
897
+ agentTerminals: ReadonlyArray<{ uuid: string; title: string; waiting?: { needle: string; since: number } | null }>,
872
898
  ): SidebarState {
873
899
  const existingTabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
874
900
  .concat(state.floats.map(float => float.tab))
@@ -883,7 +909,17 @@ export function reconcileAgentTerminals(
883
909
  // convergence: no title suffix, no meta write — the tab keeps its uuid
884
910
  // so a later reconcile push revives it if the agent reopens the same one).
885
911
  const toRemove = existingAgentTabs.filter(tab => !serverUuids.has(agentUuidOf(tab.id)) && tab.pin === undefined)
886
- if (toAdd.length === 0 && toRemove.length === 0) return state
912
+ // Mirror the live wait state from the push (authoritative: a vanished
913
+ // waiting field simply drops the entry). A waits-only change must still
914
+ // produce a new state — the tab add/remove no-change check alone would
915
+ // swallow banner updates.
916
+ const serverWaits: Record<string, { needle: string; since: number }> = {}
917
+ for (const terminal of agentTerminals) {
918
+ if (terminal.waiting !== undefined && terminal.waiting !== null) {
919
+ serverWaits[terminal.uuid] = { needle: terminal.waiting.needle, since: terminal.waiting.since }
920
+ }
921
+ }
922
+ if (toAdd.length === 0 && toRemove.length === 0 && sameAgentWaits(state.agentWaits, serverWaits)) return state
887
923
  // Remove tabs whose uuids vanished from the server list (the agent closed
888
924
  // them, or the pty exited and was reaped). Reuse closeTab's leaf cleanup;
889
925
  // a FLOATED agent terminal leaves with its window.
@@ -900,7 +936,7 @@ export function reconcileAgentTerminals(
900
936
  }
901
937
  // Add tabs for new uuids (the agent created a terminal). They land in the
902
938
  // active pane via openTabInActivePane; the next reconcile is a no-op for them.
903
- let next: SidebarState = { ...state, splits, floats }
939
+ let next: SidebarState = { ...state, splits, floats, agentWaits: serverWaits }
904
940
  for (const terminal of toAdd) {
905
941
  const tab: SidebarTab = {
906
942
  id: agentTabId(terminal.uuid),
@@ -994,6 +1030,7 @@ function resetRequested(): boolean {
994
1030
 
995
1031
  function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
996
1032
  const reset = resetRequested()
1033
+ const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined
997
1034
  if (reset) {
998
1035
  try {
999
1036
  localStorage.removeItem(`${STORAGE_PREFIX}:${sessionId}`)
@@ -1016,7 +1053,15 @@ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
1016
1053
  nextIdCounter = maxCounterId(parsed)
1017
1054
  const sanitized = sanitizeState(parsed)
1018
1055
  if (sanitized !== undefined) {
1019
- return globalWidth === undefined ? sanitized : { ...sanitized, width: globalWidth }
1056
+ const restored = globalWidth === undefined ? sanitized : { ...sanitized, width: globalWidth }
1057
+ // A narrow panel is a full-screen drawer, so every page/session load
1058
+ // starts it closed even when the prior desktop/mobile state persisted
1059
+ // `panelOpen: true`. Preserve the entire restored workbench (panes,
1060
+ // tabs, and free windows); visibility is the sole viewport-derived
1061
+ // override.
1062
+ return viewport !== undefined && isNarrowWidth(viewport) && restored.panelOpen
1063
+ ? { ...restored, panelOpen: false }
1064
+ : restored
1020
1065
  }
1021
1066
  }
1022
1067
  } catch {
@@ -1031,10 +1076,8 @@ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
1031
1076
  // disabled editor type seeds nothing. On a NARROW viewport a brand-new
1032
1077
  // session starts collapsed instead — the panel is a full-screen drawer
1033
1078
  // there, and auto-opening it on first paint would cover the conversation
1034
- // before the user asked. Only the first seeding is affected: once the
1035
- // user expands the drawer, `panelOpen: true` persists like any other
1036
- // state.
1037
- const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined
1079
+ // before the user asked. Persisted layouts follow the same narrow-load
1080
+ // visibility rule above, while their workbench contents remain intact.
1038
1081
  const width = globalWidth ?? (viewport === undefined
1039
1082
  ? PANEL_DEFAULT
1040
1083
  : defaultWidthFor(viewport, prefs.defaultWidthPercent))
@@ -1143,6 +1186,9 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
1143
1186
  revealed: [],
1144
1187
  splits: migratedSplits,
1145
1188
  floats,
1189
+ // Live wait state is transient: a reload starts clean; the next
1190
+ // agent-terminals push (sent on WS attach) repopulates it.
1191
+ agentWaits: {},
1146
1192
  }
1147
1193
  }
1148
1194
 
@@ -198,12 +198,43 @@ function usableBase(value: string | undefined): string {
198
198
  return trimmed
199
199
  }
200
200
 
201
+ /**
202
+ * Guarantee the stack ends in a generic family, so a font the browser cannot
203
+ * resolve degrades to a monospace one.
204
+ *
205
+ * Without this a stack of unresolvable names (a misspelled family, or one only
206
+ * installed on the machine running the shell rather than the one running the
207
+ * browser) falls through to the browser's *standard* font, which is
208
+ * proportional — xterm then measures its cell from a proportional advance and
209
+ * the whole grid breaks, rather than merely losing the requested typeface.
210
+ *
211
+ * This does not sniff whether the named families exist (an explicit non-goal
212
+ * of the terminal font design): it only terminates the stack. A stack that
213
+ * already names a generic family is returned untouched, so a user who
214
+ * deliberately wrote one keeps it. Generics are matched unquoted only — a
215
+ * quoted `"monospace"` is a family *name*, not the keyword.
216
+ *
217
+ * @param stack - the resolved font-family value.
218
+ * @returns the stack, ending in a generic family.
219
+ */
220
+ export function withMonospaceFallback(stack: string): string {
221
+ const families = splitFamilies(stack)
222
+ if (families.length === 0) return DEFAULT_TERMINAL_FONT_FAMILY
223
+ const only = families.length === 1 ? families[0] : undefined
224
+ if (only !== undefined && CSS_WIDE_KEYWORDS.has(only.toLowerCase())) return DEFAULT_TERMINAL_FONT_FAMILY
225
+ if (families.some((family) => GENERIC_FAMILIES.has(family.toLowerCase()))) return families.join(', ')
226
+ return `${families.join(', ')}, monospace`
227
+ }
228
+
201
229
  /**
202
230
  * Resolve the xterm font options for the given prefs.
203
231
  *
204
232
  * The base family keeps its existing precedence — user pref > theme code
205
- * font > built-in stack — and then {@link withIconFontFallbacks} tops it up
206
- * so prompt icons resolve regardless of which base won.
233
+ * font > built-in stack. {@link withMonospaceFallback} then terminates the
234
+ * stack with a generic family so an unresolvable name degrades to a
235
+ * monospace instead of the proportional standard font, and finally
236
+ * {@link withIconFontFallbacks} tops it up so prompt icons resolve
237
+ * regardless of which base won.
207
238
  *
208
239
  * @param prefs - the current side card preferences.
209
240
  * @param themeFontFamily - the app's theme code font (`--ds-font-family-code`
@@ -218,7 +249,7 @@ export function resolveTerminalFont(
218
249
  || usableBase(themeFontFamily)
219
250
  || DEFAULT_TERMINAL_FONT_FAMILY
220
251
  return {
221
- fontFamily: withIconFontFallbacks(base),
252
+ fontFamily: withIconFontFallbacks(withMonospaceFallback(base)),
222
253
  fontSize: clampTerminalFontSize(prefs.terminalFontSize),
223
254
  }
224
255
  }
@@ -428,9 +428,10 @@ export interface SidebarLocaleService {
428
428
 
429
429
  /** The composer draft face the sidebar reaches through `ctx.conversation.input`. */
430
430
  export interface SidebarSessionInput {
431
- /** The live input store (draft read for append). */
431
+ /** The live input store (draft read for append). `draftRev` is the machine's
432
+ * span-CAS revision — required to mint a structured file-reference chip. */
432
433
  state: {
433
- getSnapshot(): { draft: string }
434
+ getSnapshot(): { draft: string; draftRev?: number }
434
435
  }
435
436
  /** Replace the draft text (the input machine's single public write path). */
436
437
  setDraft(text: string): void