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.
- package/README.md +2 -2
- package/lib/client-editor.js +485 -228
- package/lib/client-registry.js +1238 -7583
- package/lib/client-terminal.js +345 -189
- package/lib/client.js +1213 -7558
- package/lib/index.js +518 -69
- package/lib/types/agent-pty.d.ts +62 -4
- package/lib/types/bundle-route.d.ts +1 -1
- package/lib/types/client/DiffView.d.ts +33 -1
- package/lib/types/client/EditorHost.d.ts +4 -1
- package/lib/types/client/FileTree.d.ts +6 -2
- package/lib/types/client/TerminalWaitBanner.d.ts +6 -0
- package/lib/types/client/TreePanel.d.ts +4 -1
- package/lib/types/client/api.d.ts +26 -0
- package/lib/types/client/chunk-loader.d.ts +1 -1
- package/lib/types/client/chunks/locale.d.ts +3 -0
- package/lib/types/client/conversation-draft.d.ts +85 -4
- package/lib/types/client/locales.d.ts +13 -20
- package/lib/types/client/selection-popup.d.ts +58 -0
- package/lib/types/client/service.d.ts +1 -1
- package/lib/types/client/state.d.ts +15 -0
- package/lib/types/client/terminal-font.d.ts +25 -2
- package/lib/types/context-types.d.ts +3 -1
- package/lib/types/fs-operations.d.ts +42 -0
- package/lib/types/git.d.ts +15 -0
- package/lib/types/index.d.ts +9 -0
- package/lib/types/prefs-shared.d.ts +7 -5
- package/lib/types/pty-manager.d.ts +53 -0
- package/lib/types/wire.d.ts +6 -2
- package/package.json +1 -1
- package/src/agent-pty.ts +221 -33
- package/src/bundle-route.ts +1 -1
- package/src/client/DiffTab.tsx +10 -1
- package/src/client/DiffView.tsx +174 -19
- package/src/client/EditorHost.tsx +9 -2
- package/src/client/FileTree.tsx +151 -8
- package/src/client/Sidebar.tsx +95 -25
- package/src/client/TerminalView.tsx +41 -0
- package/src/client/TerminalWaitBanner.tsx +32 -0
- package/src/client/TextEditor.tsx +27 -45
- package/src/client/TreePanel.tsx +22 -2
- package/src/client/api.ts +20 -0
- package/src/client/chunk-loader.ts +1 -1
- package/src/client/chunks/locale.tsx +60 -0
- package/src/client/conversation-draft.ts +233 -7
- package/src/client/index.tsx +19 -8
- package/src/client/locales-ar.ts +17 -4
- package/src/client/locales-de.ts +17 -4
- package/src/client/locales-fr.ts +17 -4
- package/src/client/locales-hi.ts +17 -4
- package/src/client/locales-id.ts +17 -4
- package/src/client/locales-it.ts +17 -4
- package/src/client/locales-ja.ts +17 -4
- package/src/client/locales-ko.ts +17 -4
- package/src/client/locales-nl.ts +17 -4
- package/src/client/locales-pl.ts +17 -4
- package/src/client/locales-pt.ts +17 -4
- package/src/client/locales-ru.ts +17 -4
- package/src/client/locales-sv.ts +17 -4
- package/src/client/locales-th.ts +17 -4
- package/src/client/locales-tr.ts +17 -4
- package/src/client/locales-vi.ts +17 -4
- package/src/client/locales-zh-HK.ts +17 -4
- package/src/client/locales-zh-MO.ts +17 -4
- package/src/client/locales-zh-TW.ts +17 -4
- package/src/client/locales.ts +41 -51
- package/src/client/selection-popup.ts +155 -0
- package/src/client/service.ts +1 -1
- package/src/client/sidebar.module.css +68 -0
- package/src/client/state.ts +56 -10
- package/src/client/terminal-font.ts +34 -3
- package/src/context-types.ts +3 -2
- package/src/fs-operations.ts +126 -4
- package/src/git.ts +56 -3
- package/src/index.ts +82 -7
- package/src/open-external.ts +3 -4
- package/src/prefs-shared.ts +7 -5
- package/src/pty-manager.ts +176 -5
- package/src/sidechat-routes.ts +13 -1
- package/src/tools.ts +24 -6
- package/src/wire.ts +3 -0
package/src/client/Sidebar.tsx
CHANGED
|
@@ -25,14 +25,16 @@ import { useSyncExternalStore } from 'react'
|
|
|
25
25
|
import clsx from 'clsx'
|
|
26
26
|
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
27
27
|
import type { Context, SidebarSessionList } from '../context-types.ts'
|
|
28
|
-
import { appendToDraft } from './conversation-draft.ts'
|
|
28
|
+
import { appendToDraft, insertFileReference } from './conversation-draft.ts'
|
|
29
29
|
import {
|
|
30
30
|
PANEL_MIN, activateTab, agentUuidOf, closeFloatByTab, closeTab, dockFloat, firstLeaf, floatTab,
|
|
31
|
-
isAgentTabId, leafWithTab,
|
|
31
|
+
isAgentTabId, leafWithTab, allLeaves,
|
|
32
32
|
moveFloat, moveTab, moveTabToEdge, openDiffTab, raiseFloat, reconcileAgentTerminals,
|
|
33
33
|
resizeFloat, resizeSplitIn, setTabPin, setWidth, toggleExpanded, togglePanel,
|
|
34
34
|
type DropZone, type SidebarState, type SidebarStore, type SidebarTab,
|
|
35
35
|
} from './state.ts'
|
|
36
|
+
import { baseName } from './FileTree.tsx'
|
|
37
|
+
import { isWithinWorkspace } from './paths.ts'
|
|
36
38
|
import { collectPinnedTabs, createPinnedVirtualTab, getPinnedHomeScope, injectPinnedIntoTree, isPinnedVirtualId, isPinnedVirtualTab, parsePinnedVirtualId, type PinnedTabEntry } from './pinned.ts'
|
|
37
39
|
import { IconPinOutline16 } from './icons.tsx'
|
|
38
40
|
import { IconPanelRightOutline16 } from './icons.tsx'
|
|
@@ -119,18 +121,21 @@ function injectUserCss(attr: string, id: string, cssText: string): HTMLStyleElem
|
|
|
119
121
|
* pane; sessionId/cwd cover onReferenceFile). */
|
|
120
122
|
interface TabContentProps extends TabContentMemoKey {
|
|
121
123
|
onToggleDir: (path: string) => void
|
|
122
|
-
onReferenceFile: (path: string) => void
|
|
124
|
+
onReferenceFile: (path: string, isDir: boolean) => void
|
|
123
125
|
ctx: Context
|
|
124
126
|
store: SidebarStore
|
|
125
127
|
/** Fired before a topology node jumps to its child session (see Sidebar). */
|
|
126
128
|
onSubagentJump: (childSessionId: string) => void
|
|
127
129
|
/** Open a diff tab from the git panel (placement handled by the store). */
|
|
128
130
|
onOpenDiff: (tab: SidebarTab) => void
|
|
131
|
+
/** Tree-row mutations (threaded to the file tree; see Sidebar's handlers). */
|
|
132
|
+
onPathRenamed?: (oldPath: string, newPath: string) => void
|
|
133
|
+
onPathRemoved?: (path: string) => void
|
|
129
134
|
}
|
|
130
135
|
|
|
131
136
|
/** Render the content of one tab (dispatched by type). */
|
|
132
137
|
const TabContent = memo(function TabContent(props: TabContentProps) {
|
|
133
|
-
const { tab, effectiveTabId, sessionId, cwd, expanded, revealed, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props
|
|
138
|
+
const { tab, effectiveTabId, sessionId, cwd, expanded, revealed, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff, onPathRenamed, onPathRemoved } = props
|
|
134
139
|
const scope = { sessionId, cwd }
|
|
135
140
|
const descriptor = ctx.get('betterSidebar')?.getTab(tab.type)
|
|
136
141
|
if (descriptor === undefined) {
|
|
@@ -394,7 +399,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
|
|
394
399
|
socket.onmessage = (event) => {
|
|
395
400
|
if (typeof event.data !== 'string') return
|
|
396
401
|
try {
|
|
397
|
-
const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean }>
|
|
402
|
+
const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean; waiting?: { needle: string; since: number } | null }>
|
|
398
403
|
if (!Array.isArray(list)) return
|
|
399
404
|
store.reduce(s => ctx.get('betterSidebar')?.isTabEnabled('terminal') === false
|
|
400
405
|
? s
|
|
@@ -498,8 +503,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
|
|
498
503
|
* Subagent auto-activation: the moment the current conversation spawns its
|
|
499
504
|
* FIRST direct subagent (a 0 → N transition on the list feed), the "auto
|
|
500
505
|
* open" pref is on, and the Subagent tab type is enabled in settings,
|
|
501
|
-
*
|
|
502
|
-
*
|
|
506
|
+
* focus the Subagent page (single-instance: an existing tab is focused,
|
|
507
|
+
* never duplicated). On wide viewports the right panel also expands; on
|
|
508
|
+
* narrow viewports background activity never forces the full-screen drawer
|
|
509
|
+
* open over the chat.
|
|
503
510
|
* Switching to a session that already has subagents never triggers — its
|
|
504
511
|
* baseline starts at the current count — so a deliberate layout is never
|
|
505
512
|
* fought.
|
|
@@ -525,9 +532,14 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
|
|
525
532
|
if (!detectNewDirectSubagent(baseline, ctx.sessions.list.getSnapshot(), sessionId)) return
|
|
526
533
|
if (!store.getPrefs().autoOpenSubagent) return
|
|
527
534
|
if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
|
|
528
|
-
|
|
529
|
-
//
|
|
530
|
-
//
|
|
535
|
+
// Read the viewport when the delayed activation fires: a resize while
|
|
536
|
+
// the debounce is armed must not let background activity force the
|
|
537
|
+
// narrow full-screen drawer open over the chat.
|
|
538
|
+
if (!isNarrowWidth(window.innerWidth)) {
|
|
539
|
+
store.reduce(s => s.panelOpen ? s : togglePanel(s))
|
|
540
|
+
}
|
|
541
|
+
// Pin the landing to the first pane: the auto-activated Subagent page
|
|
542
|
+
// must be ready in the panel that just expanded, not wherever the user
|
|
531
543
|
// last touched.
|
|
532
544
|
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
|
|
533
545
|
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
|
|
@@ -545,11 +557,12 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
|
|
545
557
|
/**
|
|
546
558
|
* Job auto-activation: the moment a NEW background job appears for the
|
|
547
559
|
* current conversation (a job id the previous snapshot lacked), the
|
|
548
|
-
* auto-open pref is on, and the Jobs tab type is enabled,
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
560
|
+
* auto-open pref is on, and the Jobs tab type is enabled, focus the Jobs
|
|
561
|
+
* page. The right panel expands only on wide viewports — background
|
|
562
|
+
* activity never forces the narrow full-screen drawer open. Unlike the
|
|
563
|
+
* subagent trigger (0 → N only), ANY new job id triggers: the agent may
|
|
564
|
+
* start several jobs in one session, and each should surface. A fresh page
|
|
565
|
+
* load never triggers — its baseline starts at the current snapshot.
|
|
553
566
|
*/
|
|
554
567
|
const jobBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
|
|
555
568
|
useEffect(() => {
|
|
@@ -559,7 +572,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
|
|
559
572
|
if (!detectNewJob(prev, sessionList, sessionId)) return
|
|
560
573
|
if (!store.getPrefs().autoOpenJobs) return
|
|
561
574
|
if (ctx.get('betterSidebar')?.isTabEnabled('subagent') === false) return
|
|
562
|
-
|
|
575
|
+
if (!isNarrowWidth(window.innerWidth)) {
|
|
576
|
+
store.reduce(s => s.panelOpen ? s : togglePanel(s))
|
|
577
|
+
}
|
|
563
578
|
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
|
|
564
579
|
ctx.get('betterSidebar')?.openTab({ type: 'subagent', title: t('subagent') })
|
|
565
580
|
}, [sessionList, sessionId, store, ctx])
|
|
@@ -1128,19 +1143,63 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
|
|
1128
1143
|
}, [actions, pinnedVirtualTabs, activePinnedTabId, store])
|
|
1129
1144
|
|
|
1130
1145
|
/**
|
|
1131
|
-
* The explorer's @-reference button
|
|
1132
|
-
*
|
|
1133
|
-
*
|
|
1134
|
-
*
|
|
1135
|
-
*
|
|
1136
|
-
*
|
|
1137
|
-
*
|
|
1146
|
+
* The explorer's @-reference button. Directories append the folder mention
|
|
1147
|
+
* (`@dir/`) as plain text so DSH's folder decoration and completion keep
|
|
1148
|
+
* working; files insert a structured chip like the native `@` picker, so
|
|
1149
|
+
* the whole reference stays one link instead of decorating only the
|
|
1150
|
+
* leading folder. Resolves the session-scope ctx and the conversation
|
|
1151
|
+
* input service at click time; a missing service or scope degrades to a
|
|
1152
|
+
* logged no-op, never a crash. Defined above the no-session early return
|
|
1153
|
+
* — a hook must never sit behind a conditional return (React counts hooks
|
|
1154
|
+
* per render).
|
|
1138
1155
|
*/
|
|
1139
|
-
const referenceInChat = useCallback((path: string): void => {
|
|
1156
|
+
const referenceInChat = useCallback((path: string, isDir: boolean): void => {
|
|
1140
1157
|
if (sessionId === undefined) return
|
|
1141
|
-
|
|
1158
|
+
const rel = relativeTo(cwd ?? '', path)
|
|
1159
|
+
if (isDir) {
|
|
1160
|
+
appendToDraft(ctx, sessionId, `@${rel === '.' ? './' : `${rel}/`}`)
|
|
1161
|
+
return
|
|
1162
|
+
}
|
|
1163
|
+
if (!insertFileReference(ctx, sessionId, rel)) {
|
|
1164
|
+
appendToDraft(ctx, sessionId, `@${rel}`)
|
|
1165
|
+
}
|
|
1142
1166
|
}, [ctx, sessionId, cwd])
|
|
1143
1167
|
|
|
1168
|
+
/** Tree-row rename reconciliation: retarget every open tab whose path was
|
|
1169
|
+
* the renamed file (the editor content survives and later saves land on
|
|
1170
|
+
* the new path; the title follows the new base name). */
|
|
1171
|
+
const onPathRenamed = useCallback((oldPath: string, newPath: string): void => {
|
|
1172
|
+
const service = ctx.get('betterSidebar')
|
|
1173
|
+
if (service === undefined) return
|
|
1174
|
+
const snapshot = store.getSnapshot().state
|
|
1175
|
+
if (snapshot === undefined) return
|
|
1176
|
+
for (const leaf of allLeaves(snapshot.splits)) {
|
|
1177
|
+
for (const tab of leaf.tabs) {
|
|
1178
|
+
if (tab.path === oldPath) service.updateTab(tab.id, { path: newPath, title: baseName(newPath) })
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
for (const float of snapshot.floats) {
|
|
1182
|
+
if (float.tab.path === oldPath) service.updateTab(float.tab.id, { path: newPath, title: baseName(newPath) })
|
|
1183
|
+
}
|
|
1184
|
+
}, [ctx, store])
|
|
1185
|
+
|
|
1186
|
+
/** Tree-row delete reconciliation: close every open tab at or under the
|
|
1187
|
+
* removed path (a stale tab's next save would fail against a missing
|
|
1188
|
+
* path). Floating tabs are as open as docked ones. */
|
|
1189
|
+
const onPathRemoved = useCallback((target: string): void => {
|
|
1190
|
+
const service = ctx.get('betterSidebar')
|
|
1191
|
+
if (service === undefined) return
|
|
1192
|
+
const snapshot = store.getSnapshot().state
|
|
1193
|
+
if (snapshot === undefined) return
|
|
1194
|
+
const tabs: SidebarTab[] = []
|
|
1195
|
+
for (const leaf of allLeaves(snapshot.splits)) tabs.push(...leaf.tabs)
|
|
1196
|
+
for (const float of snapshot.floats) tabs.push(float.tab)
|
|
1197
|
+
for (const tab of tabs) {
|
|
1198
|
+
const path = tab.path
|
|
1199
|
+
if (path !== undefined && (path === target || isWithinWorkspace(target, path))) service.closeTab(tab.id)
|
|
1200
|
+
}
|
|
1201
|
+
}, [ctx, store])
|
|
1202
|
+
|
|
1144
1203
|
if (state === undefined || sessionId === undefined) {
|
|
1145
1204
|
// Keep the unavailable controls focusable: touch users have no hover, so
|
|
1146
1205
|
// focus is the only way the existing Tooltip can explain what is missing.
|
|
@@ -1186,6 +1245,15 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
|
|
1186
1245
|
* strip must never break because a plugin's badge computation failed.
|
|
1187
1246
|
*/
|
|
1188
1247
|
const tabBadgeOf = (tab: SidebarTab): ReactNode => {
|
|
1248
|
+
// Agent-terminal wait indicator (sidebar-internal, deliberately NOT a
|
|
1249
|
+
// TabDescriptor.badge — that API is type-keyed and shared with external
|
|
1250
|
+
// plugins, and cannot address one tab): the agent-terminals push mirrors
|
|
1251
|
+
// the model's live terminal_wait_for into state.agentWaits; an agent tab
|
|
1252
|
+
// whose uuid is waiting shows the hourglass pill.
|
|
1253
|
+
if (isAgentTabId(tab.id)) {
|
|
1254
|
+
const wait = state.agentWaits?.[agentUuidOf(tab.id)]
|
|
1255
|
+
if (wait !== undefined) return <span className={css.tabBadge}>{'⏳'}</span>
|
|
1256
|
+
}
|
|
1189
1257
|
const descriptor = ctx.get('betterSidebar')?.getTab(tab.type)
|
|
1190
1258
|
if (descriptor?.badge === undefined) return null
|
|
1191
1259
|
let value: string | number | null | undefined
|
|
@@ -1231,6 +1299,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
|
|
1231
1299
|
revealed={state.revealed ?? []}
|
|
1232
1300
|
onToggleDir={(path) => { store.reduce(s => toggleExpanded(s, path)) }}
|
|
1233
1301
|
onReferenceFile={referenceInChat}
|
|
1302
|
+
onPathRenamed={onPathRenamed}
|
|
1303
|
+
onPathRemoved={onPathRemoved}
|
|
1234
1304
|
ctx={ctx}
|
|
1235
1305
|
store={store}
|
|
1236
1306
|
visible={placement === 'float' ? true : state.panelOpen && active}
|
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
shouldActivateTerminalLink,
|
|
48
48
|
openTerminalUrl,
|
|
49
49
|
} from './terminal-links.ts'
|
|
50
|
+
import { TerminalWaitBanner } from './TerminalWaitBanner.tsx'
|
|
50
51
|
import css from './sidebar.module.css'
|
|
51
52
|
|
|
52
53
|
/** How many consecutive unreasoned failures before showing the error banner. */
|
|
@@ -60,6 +61,13 @@ const FAILURE_LIMIT = 3
|
|
|
60
61
|
*/
|
|
61
62
|
const PTY_DEPS_MISSING = 'pty-deps-missing'
|
|
62
63
|
|
|
64
|
+
/**
|
|
65
|
+
* The WS close-reason prefix the host sends when the CONFIGURED shell was
|
|
66
|
+
* not found (mirror of src/index.ts wsCloseReasonOf; wire contract, keep the
|
|
67
|
+
* literal in lockstep). The view renders a localized, actionable banner.
|
|
68
|
+
*/
|
|
69
|
+
const SHELL_NOT_FOUND_PREFIX = 'shell-not-found:'
|
|
70
|
+
|
|
63
71
|
/** The degraded-mode payload rendered by {@link TerminalDepsBanner}. */
|
|
64
72
|
type TerminalDepsInfo = Extract<TerminalDepsStatus, { ok: false }>
|
|
65
73
|
|
|
@@ -115,6 +123,27 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
|
|
115
123
|
const [fatal, setFatal] = useState<string | null>(null)
|
|
116
124
|
const [depsFatal, setDepsFatal] = useState<TerminalDepsInfo | null>(null)
|
|
117
125
|
const [lastUrl, setLastUrl] = useState<string | null>(null)
|
|
126
|
+
// Agent terminals only: the model's active terminal_wait_for (mirrored
|
|
127
|
+
// from the host's agent-terminals push into the store) drives the wait
|
|
128
|
+
// banner. Read + subscribe like the font prefs above; the banner vanishes
|
|
129
|
+
// when the host's push drops the waiting field (skip / exit / abort all
|
|
130
|
+
// converge through the same push). getSnapshot() is {sessionId, state?,
|
|
131
|
+
// prefs} — the state may be briefly undefined around session switches.
|
|
132
|
+
const agentUuid = isAgentTabId(tabId) ? agentUuidOf(tabId) : null
|
|
133
|
+
const [waiting, setWaiting] = useState<{ needle: string; since: number } | undefined>(undefined)
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
if (agentUuid === null) return
|
|
136
|
+
const read = (): void => {
|
|
137
|
+
const next = store.getSnapshot().state?.agentWaits?.[agentUuid]
|
|
138
|
+
setWaiting(prev => {
|
|
139
|
+
const nextValue = next === undefined ? undefined : { needle: next.needle, since: next.since }
|
|
140
|
+
if (prev?.needle === nextValue?.needle && prev?.since === nextValue?.since) return prev
|
|
141
|
+
return nextValue
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
read()
|
|
145
|
+
return store.subscribe(read)
|
|
146
|
+
}, [agentUuid, store])
|
|
118
147
|
const connectRef = useRef<(() => void) | null>(null)
|
|
119
148
|
|
|
120
149
|
useEffect(() => {
|
|
@@ -241,6 +270,12 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
|
|
241
270
|
})
|
|
242
271
|
return
|
|
243
272
|
}
|
|
273
|
+
// The configured shell could not be found (settings page or yaml):
|
|
274
|
+
// a localized banner beats the raw English close reason.
|
|
275
|
+
if (event.code === 1011 && event.reason.startsWith(SHELL_NOT_FOUND_PREFIX)) {
|
|
276
|
+
setFatal(t('terminalShellNotFound', { name: event.reason.slice(SHELL_NOT_FOUND_PREFIX.length) || '?' }))
|
|
277
|
+
return
|
|
278
|
+
}
|
|
244
279
|
// A server-side refusal carries a close code + reason; retrying it
|
|
245
280
|
// forever would only spin the banner, so surface it with a retry.
|
|
246
281
|
if (event.code === 1011 && event.reason !== '') {
|
|
@@ -361,6 +396,12 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
|
|
361
396
|
|
|
362
397
|
return (
|
|
363
398
|
<div className={css.terminalWrap}>
|
|
399
|
+
{agentUuid !== null && waiting !== undefined && (
|
|
400
|
+
<TerminalWaitBanner
|
|
401
|
+
needle={waiting.needle}
|
|
402
|
+
onSkip={() => { void api.agentSkipWait(agentUuid).catch(() => { /* 跳过失败时 banner 留存,可重试 */ }) }}
|
|
403
|
+
/>
|
|
404
|
+
)}
|
|
364
405
|
{depsFatal !== null && (
|
|
365
406
|
<TerminalDepsBanner deps={depsFatal} onRetry={() => { setDepsFatal(null); connectRef.current?.() }} />
|
|
366
407
|
)}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "Agent 正在等待 {needle}" wait banner: rendered at the top of an
|
|
3
|
+
* agent-owned terminal's view while the model blocks in terminal_wait_for.
|
|
4
|
+
* The skip button asks the host to abort every active wait on the terminal
|
|
5
|
+
* (`agent-pty.skip-wait`); the banner disappears when the host's next
|
|
6
|
+
* agent-terminals push drops the waiting field — no optimistic UI. Kept in
|
|
7
|
+
* its own module (no xterm imports) so jsdom tests can render it directly.
|
|
8
|
+
*/
|
|
9
|
+
import { t } from './locales.ts'
|
|
10
|
+
import css from './sidebar.module.css'
|
|
11
|
+
|
|
12
|
+
/** Cap the needle shown inline; the full text rides the title tooltip. */
|
|
13
|
+
const NEEDLE_DISPLAY_CAP = 80
|
|
14
|
+
|
|
15
|
+
/** Truncate one needle for inline display (title attr carries the full text). */
|
|
16
|
+
export function truncateNeedle(needle: string): string {
|
|
17
|
+
return needle.length > NEEDLE_DISPLAY_CAP ? `${needle.slice(0, NEEDLE_DISPLAY_CAP - 1)}…` : needle
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function TerminalWaitBanner(props: { needle: string; onSkip: () => void }) {
|
|
21
|
+
const { needle, onSkip } = props
|
|
22
|
+
return (
|
|
23
|
+
<div className={css.terminalWaitBanner}>
|
|
24
|
+
<span className={css.terminalWaitNeedle} title={needle}>
|
|
25
|
+
{t('terminalWaitBanner', { needle: truncateNeedle(needle) })}
|
|
26
|
+
</span>
|
|
27
|
+
<button type="button" className={css.terminalRetry} onClick={onSkip}>
|
|
28
|
+
{t('terminalSkipWait')}
|
|
29
|
+
</button>
|
|
30
|
+
</div>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
@@ -26,6 +26,7 @@ import { languageForPath } from './lang.ts'
|
|
|
26
26
|
import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts'
|
|
27
27
|
import { isDarkScheme, subscribeColorScheme } from './theme.ts'
|
|
28
28
|
import { appendToDraft } from './conversation-draft.ts'
|
|
29
|
+
import { useSelectionPopup } from './selection-popup.ts'
|
|
29
30
|
import { buildSelectionInsert } from './selection-payload.ts'
|
|
30
31
|
import { t } from './locales.ts'
|
|
31
32
|
import type { EditorToolbarControls, EditorToolbarState } from './service.ts'
|
|
@@ -50,13 +51,6 @@ export interface TextEditorProps {
|
|
|
50
51
|
onToolbarControls?: (controls: EditorToolbarControls | null) => void
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
/** The floating "add to conversation" action: payload + viewport anchor. */
|
|
54
|
-
interface SelectionPopup {
|
|
55
|
-
insert: string
|
|
56
|
-
left: number
|
|
57
|
-
top: number
|
|
58
|
-
}
|
|
59
|
-
|
|
60
54
|
export function TextEditor(props: TextEditorProps) {
|
|
61
55
|
const { ctx, scope, path, content, truncated } = props
|
|
62
56
|
const [dirty, setDirty] = useState(false)
|
|
@@ -68,34 +62,21 @@ export function TextEditor(props: TextEditorProps) {
|
|
|
68
62
|
const themeCompRef = useRef<CmThemeCompartment | null>(null)
|
|
69
63
|
/** The app's resolved color scheme; the editor re-themes in place on flips. */
|
|
70
64
|
const [dark, setDark] = useState(() => isDarkScheme())
|
|
71
|
-
/**
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
top,
|
|
87
|
-
}
|
|
88
|
-
popupRef.current = next
|
|
89
|
-
setPopup(next)
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** The popup button's click: insert the stored payload into the draft. */
|
|
93
|
-
const commitPopup = (): void => {
|
|
94
|
-
const current = popupRef.current
|
|
95
|
-
if (current === null) return
|
|
96
|
-
appendToDraft(ctx, scope.sessionId, current.insert)
|
|
97
|
-
hidePopup()
|
|
98
|
-
}
|
|
65
|
+
/**
|
|
66
|
+
* The floating "add to conversation" popup (viewport-anchored; null =
|
|
67
|
+
* hidden). The hook owns show/hide/commit plus the global dismissal
|
|
68
|
+
* listeners (outside mousedown, Escape, hidden tab/window, and the editor
|
|
69
|
+
* surface leaving the viewport — the tab-switch/panel-collapse paths have
|
|
70
|
+
* no DOM events of their own) — see selection-popup.ts.
|
|
71
|
+
*/
|
|
72
|
+
const selectionPopup = useSelectionPopup({
|
|
73
|
+
onCommit: (insert) => { appendToDraft(ctx, scope.sessionId, insert) },
|
|
74
|
+
// The surface that must stay on screen: the CodeMirror host. This
|
|
75
|
+
// component has no preview surface (file preview is retired); without
|
|
76
|
+
// the geometry signal a tab switch (display:none) or a panel collapse
|
|
77
|
+
// (translated off-screen) would leave the fixed portaled button behind.
|
|
78
|
+
getSurface: () => hostRef.current,
|
|
79
|
+
})
|
|
99
80
|
|
|
100
81
|
useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), [])
|
|
101
82
|
|
|
@@ -103,7 +84,7 @@ export function TextEditor(props: TextEditorProps) {
|
|
|
103
84
|
useEffect(() => {
|
|
104
85
|
setDirty(false)
|
|
105
86
|
setSaveState('idle')
|
|
106
|
-
|
|
87
|
+
selectionPopup.hide()
|
|
107
88
|
}, [content])
|
|
108
89
|
|
|
109
90
|
// Create the CodeMirror editor once the content is loaded. The view owns
|
|
@@ -149,33 +130,33 @@ export function TextEditor(props: TextEditorProps) {
|
|
|
149
130
|
// selection and hides it too.
|
|
150
131
|
CodeMirrorView.updateListener.of((update) => {
|
|
151
132
|
if (update.geometryChanged || update.viewportChanged) {
|
|
152
|
-
|
|
133
|
+
selectionPopup.hide()
|
|
153
134
|
return
|
|
154
135
|
}
|
|
155
136
|
if (!update.view.hasFocus) {
|
|
156
|
-
|
|
137
|
+
selectionPopup.hide()
|
|
157
138
|
return
|
|
158
139
|
}
|
|
159
140
|
if (!(update.selectionSet || update.docChanged || update.focusChanged)) return
|
|
160
141
|
const sel = update.state.selection.main
|
|
161
142
|
if (sel.empty) {
|
|
162
|
-
|
|
143
|
+
selectionPopup.hide()
|
|
163
144
|
return
|
|
164
145
|
}
|
|
165
146
|
const text = update.state.sliceDoc(sel.from, sel.to)
|
|
166
147
|
if (text.trim() === '') {
|
|
167
|
-
|
|
148
|
+
selectionPopup.hide()
|
|
168
149
|
return
|
|
169
150
|
}
|
|
170
151
|
// Page coordinates (the document root may scroll); the popup is
|
|
171
152
|
// position:fixed, so convert to viewport coordinates.
|
|
172
153
|
const rect = update.view.coordsAtPos(sel.head)
|
|
173
154
|
if (rect === null) {
|
|
174
|
-
|
|
155
|
+
selectionPopup.hide()
|
|
175
156
|
return
|
|
176
157
|
}
|
|
177
158
|
const doc = update.state.doc
|
|
178
|
-
|
|
159
|
+
selectionPopup.show(
|
|
179
160
|
buildSelectionInsert(path, scope.cwd, {
|
|
180
161
|
start: doc.lineAt(sel.from).number,
|
|
181
162
|
end: doc.lineAt(sel.to).number,
|
|
@@ -272,15 +253,16 @@ export function TextEditor(props: TextEditorProps) {
|
|
|
272
253
|
<div className={css.editorCm} ref={hostRef} />
|
|
273
254
|
</>
|
|
274
255
|
)}
|
|
275
|
-
{popup !== null && createPortal(
|
|
256
|
+
{selectionPopup.popup !== null && createPortal(
|
|
276
257
|
<button
|
|
277
258
|
type="button"
|
|
259
|
+
ref={selectionPopup.buttonRef}
|
|
278
260
|
className={css.selectionPopup}
|
|
279
|
-
style={{ left: popup.left, top: popup.top }}
|
|
261
|
+
style={{ left: selectionPopup.popup.left, top: selectionPopup.popup.top }}
|
|
280
262
|
// Keep the selection (and CodeMirror focus) alive until the click
|
|
281
263
|
// commits — without this the popup unmounts before click lands.
|
|
282
264
|
onMouseDown={(event) => { event.preventDefault() }}
|
|
283
|
-
onClick={
|
|
265
|
+
onClick={selectionPopup.commit}
|
|
284
266
|
>
|
|
285
267
|
{t('addToConversation')}
|
|
286
268
|
</button>,
|
package/src/client/TreePanel.tsx
CHANGED
|
@@ -59,16 +59,34 @@ export function TreePanel(props: {
|
|
|
59
59
|
openWithSsh?: boolean
|
|
60
60
|
onOpenWith?: (targetId: string, path: string) => void
|
|
61
61
|
onToggleOpenWithPin?: (targetId: string) => void
|
|
62
|
-
onReferenceFile: (path: string) => void
|
|
62
|
+
onReferenceFile: (path: string, isDir: boolean) => void
|
|
63
|
+
/** Tree-row mutations (passed through to the file tree; absent → hidden). */
|
|
64
|
+
onPathRenamed?: (oldPath: string, newPath: string) => void
|
|
65
|
+
onPathRemoved?: (path: string) => void
|
|
63
66
|
/** Full-window presentation: the panel fills its host instead of docking
|
|
64
67
|
* at a fixed width. */
|
|
65
68
|
full?: boolean
|
|
66
69
|
}) {
|
|
67
|
-
const { sessionId, cwd, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, full } = props
|
|
70
|
+
const { sessionId, cwd, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, onPathRenamed, onPathRemoved, full } = props
|
|
68
71
|
const [query, setQuery] = useState('')
|
|
69
72
|
const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null)
|
|
70
73
|
const [error, setError] = useState<string | null>(null)
|
|
71
74
|
const [refreshTick, setRefreshTick] = useState(0)
|
|
75
|
+
|
|
76
|
+
// The tree caches loaded directories per refresh tick, so content changed
|
|
77
|
+
// outside DSH (another editor, a sync tool) stays stale until the manual
|
|
78
|
+
// refresh click. Re-focusing the window bumps the tick automatically, and
|
|
79
|
+
// integrations can force a refresh by dispatching a bubbling
|
|
80
|
+
// `dsh-sidebar:refresh-files` event on `window`.
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
const bump = (): void => { setRefreshTick(tick => tick + 1) }
|
|
83
|
+
window.addEventListener('focus', bump)
|
|
84
|
+
window.addEventListener('dsh-sidebar:refresh-files', bump)
|
|
85
|
+
return () => {
|
|
86
|
+
window.removeEventListener('focus', bump)
|
|
87
|
+
window.removeEventListener('dsh-sidebar:refresh-files', bump)
|
|
88
|
+
}
|
|
89
|
+
}, [])
|
|
72
90
|
/** One-line upload status under the search row ('' hides the hint). */
|
|
73
91
|
const [uploadStatus, setUploadStatus] = useState('')
|
|
74
92
|
/** Whether the status line is a failure/cancel (error color, stays visible). */
|
|
@@ -233,6 +251,8 @@ export function TreePanel(props: {
|
|
|
233
251
|
onOpenWith={onOpenWith}
|
|
234
252
|
onToggleOpenWithPin={onToggleOpenWithPin}
|
|
235
253
|
onReferenceFile={onReferenceFile}
|
|
254
|
+
onPathRenamed={onPathRenamed}
|
|
255
|
+
onPathRemoved={onPathRemoved}
|
|
236
256
|
refreshTick={refreshTick}
|
|
237
257
|
onUploadRequest={startUpload}
|
|
238
258
|
busy={busy}
|
package/src/client/api.ts
CHANGED
|
@@ -212,6 +212,14 @@ export const api = {
|
|
|
212
212
|
call<FsTextResult | FsBinaryResult>('fs.read', scopePayload(scope, { path }), signal),
|
|
213
213
|
fsWrite: (scope: SessionScope, path: string, content: string) =>
|
|
214
214
|
call<{ ok: true }>('fs.write', scopePayload(scope, { path, content })),
|
|
215
|
+
/** Rename one tree row within its directory (single-segment name; a
|
|
216
|
+
* destination-existence clash is a 409; symlink rows rename the link). */
|
|
217
|
+
fsRename: (scope: SessionScope, path: string, name: string) =>
|
|
218
|
+
call<{ path: string }>('fs.rename', scopePayload(scope, { path, name })),
|
|
219
|
+
/** Delete one tree row permanently (recursive for directories; a symlink
|
|
220
|
+
* row unlinks the link only). */
|
|
221
|
+
fsRemove: (scope: SessionScope, path: string) =>
|
|
222
|
+
call<{ path: string }>('fs.remove', scopePayload(scope, { path })),
|
|
215
223
|
/** Upload one file's raw bytes into `dir` (keeps the folder tree via
|
|
216
224
|
* `relativePath`); the host streams it under the session workspace. */
|
|
217
225
|
uploadFile: (scope: SessionScope, dir: string, relativePath: string, body: Blob, signal?: AbortSignal) =>
|
|
@@ -241,6 +249,14 @@ export const api = {
|
|
|
241
249
|
/** Full patch text of one commit (diff display for the history rows). */
|
|
242
250
|
gitCommitDiff: (scope: SessionScope, hash: string, worktree?: string, signal?: AbortSignal) =>
|
|
243
251
|
call<{ diff: string }>('git.commit-diff', gitPayload(scope, worktree, { hash }), signal),
|
|
252
|
+
/** Both sides' full file contents for a diff-fold expansion; a missing
|
|
253
|
+
* side is null (untracked / deleted) and the view degrades the fold. */
|
|
254
|
+
gitFoldContents: (scope: SessionScope, opts: { path: string; staged?: boolean; hash?: string }, worktree?: string, signal?: AbortSignal) =>
|
|
255
|
+
call<{ old: string | null; new: string | null }>('git.fold-contents', gitPayload(scope, worktree, {
|
|
256
|
+
path: opts.path,
|
|
257
|
+
...(opts.staged !== undefined ? { staged: opts.staged } : {}),
|
|
258
|
+
...(opts.hash !== undefined ? { hash: opts.hash } : {}),
|
|
259
|
+
}), signal),
|
|
244
260
|
/** Discard the worktree changes of one file (the index is untouched). */
|
|
245
261
|
gitDiscard: (scope: SessionScope, path: string, worktree?: string) =>
|
|
246
262
|
call<{ ok: true }>('git.discard', gitPayload(scope, worktree, { path })),
|
|
@@ -258,6 +274,10 @@ export const api = {
|
|
|
258
274
|
/** Release an agent terminal by uuid (tab closed while WS was down). */
|
|
259
275
|
agentPtyClose: (uuid: string) =>
|
|
260
276
|
call<{ ok: true }>('agent-pty.close', { uuid }),
|
|
277
|
+
/** Skip every active terminal_wait_for on one agent terminal (the wait
|
|
278
|
+
* banner's skip button). Idempotent: {skipped:0} when none is active. */
|
|
279
|
+
agentSkipWait: (uuid: string) =>
|
|
280
|
+
call<{ ok: true; skipped: number }>('agent-pty.skip-wait', { uuid }),
|
|
261
281
|
/** Terminal dependency status (issue #140): after a WS close 1011 with
|
|
262
282
|
* reason `pty-deps-missing` the view fetches the full repair details here
|
|
263
283
|
* (the close reason itself is capped at 123 bytes). */
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
* client.js); an edit that does land while a core HMR happens is caught by
|
|
49
49
|
* the ETag comparison on the next activation.
|
|
50
50
|
*/
|
|
51
|
-
export type ChunkName = 'terminal' | 'editor'
|
|
51
|
+
export type ChunkName = 'terminal' | 'editor' | 'locale'
|
|
52
52
|
|
|
53
53
|
/** The module exports a chunk factory provides (namespace-ish record). */
|
|
54
54
|
export type ChunkExports = Record<string, unknown>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lazy chunk entry: the 19 non-zh/en dictionaries (ja/de/fr/…/zh-HK/MO/TW —
|
|
3
|
+
* ~640KB of source). The sidebar's own `t()` only ever consults zh/en plus
|
|
4
|
+
* the better-locale override store, so these dicts are needed ONLY when
|
|
5
|
+
* @huanlin/dsh-plugin-better-locale is installed: they register into its
|
|
6
|
+
* override store from there (see the client apply's better-locale
|
|
7
|
+
* integration). Built as `lib/client-locale.js` and fetched from the
|
|
8
|
+
* plugin's /sidebar/bundle route on first need — never import this module
|
|
9
|
+
* from the core bundle: it would drag every language back into the startup
|
|
10
|
+
* path.
|
|
11
|
+
*/
|
|
12
|
+
import { ja } from '../locales-ja.ts'
|
|
13
|
+
import { de } from '../locales-de.ts'
|
|
14
|
+
import { fr } from '../locales-fr.ts'
|
|
15
|
+
import { pt } from '../locales-pt.ts'
|
|
16
|
+
import { ko } from '../locales-ko.ts'
|
|
17
|
+
import { ar } from '../locales-ar.ts'
|
|
18
|
+
import { hi } from '../locales-hi.ts'
|
|
19
|
+
import { id } from '../locales-id.ts'
|
|
20
|
+
import { tr } from '../locales-tr.ts'
|
|
21
|
+
import { vi } from '../locales-vi.ts'
|
|
22
|
+
import { th } from '../locales-th.ts'
|
|
23
|
+
import { ru } from '../locales-ru.ts'
|
|
24
|
+
import { it } from '../locales-it.ts'
|
|
25
|
+
import { nl } from '../locales-nl.ts'
|
|
26
|
+
import { sv } from '../locales-sv.ts'
|
|
27
|
+
import { pl } from '../locales-pl.ts'
|
|
28
|
+
import { zhHK } from '../locales-zh-HK.ts'
|
|
29
|
+
import { zhTW } from '../locales-zh-TW.ts'
|
|
30
|
+
import { zhMO } from '../locales-zh-MO.ts'
|
|
31
|
+
import type { CopyKey } from '../locales.ts'
|
|
32
|
+
|
|
33
|
+
/** Key-set check against zh (type-only import — erased before bundling, so
|
|
34
|
+
* this adds no runtime tie to the core bundle): a dictionary missing or
|
|
35
|
+
* adding a key fails the build instead of silently falling back to en. */
|
|
36
|
+
const checked = (dict: Record<CopyKey, string>): Record<string, string> => dict
|
|
37
|
+
|
|
38
|
+
/** Every non-zh/en dictionary keyed by its override id, ready for the
|
|
39
|
+
* better-locale store's `register(ns, dicts)`. */
|
|
40
|
+
export const localeDicts: Record<string, Record<string, string>> = {
|
|
41
|
+
ja: checked(ja),
|
|
42
|
+
de: checked(de),
|
|
43
|
+
fr: checked(fr),
|
|
44
|
+
pt: checked(pt),
|
|
45
|
+
ko: checked(ko),
|
|
46
|
+
ar: checked(ar),
|
|
47
|
+
hi: checked(hi),
|
|
48
|
+
id: checked(id),
|
|
49
|
+
tr: checked(tr),
|
|
50
|
+
vi: checked(vi),
|
|
51
|
+
th: checked(th),
|
|
52
|
+
ru: checked(ru),
|
|
53
|
+
it: checked(it),
|
|
54
|
+
nl: checked(nl),
|
|
55
|
+
sv: checked(sv),
|
|
56
|
+
pl: checked(pl),
|
|
57
|
+
'zh-HK': checked(zhHK),
|
|
58
|
+
'zh-TW': checked(zhTW),
|
|
59
|
+
'zh-MO': checked(zhMO),
|
|
60
|
+
}
|