@yolk_vat-y/dsh-project-memory 0.4.2 → 0.4.4

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.
@@ -15,6 +15,9 @@ export const zh = {
15
15
  'style.mono': '终端',
16
16
  'panel.syncing': '同步中…',
17
17
  'panel.sync-failed': '同步失败',
18
+ 'panel.hints-on': '显示提示信息',
19
+ 'panel.hints-off': '隐藏提示信息',
20
+ 'drag.cancel': '取消拖拽',
18
21
  'panel.empty': '暂无任务',
19
22
  'panel.empty-desc': '让模型开始工作并维护 todo 清单后会自动建档',
20
23
  'panel.no-session': '还没有会话',
@@ -53,6 +56,9 @@ export const en = {
53
56
  'style.mono': 'Mono',
54
57
  'panel.syncing': 'Syncing…',
55
58
  'panel.sync-failed': 'Sync failed',
59
+ 'panel.hints-on': 'Show hints',
60
+ 'panel.hints-off': 'Hide hints',
61
+ 'drag.cancel': 'Cancel drag',
56
62
  'panel.empty': 'No Tasks',
57
63
  'panel.empty-desc': 'Tasks are created automatically when the model maintains a todo list',
58
64
  'panel.no-session': 'No session yet',
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Task Data Store — 仅管服务端同步的数据(tasks、boundTaskId、archivedCount)
3
+ * - 单向数据流:命令执行结果 → setTasks → 广播给其他标签页
4
+ * - 无 UI 状态,无 localStorage,纯内存 + BroadcastChannel 同步
5
+ */
6
+ import { useSyncExternalStore } from 'react'
7
+
8
+ export interface TaskStep {
9
+ content: string
10
+ status: 'pending' | 'in_progress' | 'completed'
11
+ }
12
+
13
+ export interface TaskFile {
14
+ path: string
15
+ line?: number
16
+ }
17
+
18
+ export interface Task {
19
+ id: string
20
+ title: string
21
+ steps: TaskStep[]
22
+ files: TaskFile[]
23
+ lastActiveAt: string
24
+ updatedAt: string
25
+ archived?: boolean
26
+ }
27
+
28
+ export interface TaskPayload {
29
+ tasks: Task[]
30
+ boundId: string | null
31
+ archived: number
32
+ }
33
+
34
+ export function parseTaskPayloadText(text?: string | null): TaskPayload | null {
35
+ if (!text) return null
36
+ let jsonBlock: string | null = null
37
+ const fenced = text.match(/```(?:json)?\s*\n([\s\S]*?)\n```/)
38
+ if (fenced) jsonBlock = fenced[1]
39
+ else {
40
+ const lastBrace = text.lastIndexOf('{')
41
+ if (lastBrace !== -1) jsonBlock = text.slice(lastBrace)
42
+ }
43
+ if (!jsonBlock) return null
44
+ try {
45
+ const data = JSON.parse(jsonBlock)
46
+ if (!Array.isArray(data.tasks)) return null
47
+ return { tasks: data.tasks, boundId: data.boundId ?? null, archived: data.archived ?? 0 }
48
+ } catch {
49
+ return null
50
+ }
51
+ }
52
+
53
+ interface TaskDataState {
54
+ tasks: Task[]
55
+ boundTaskId: string | null
56
+ archivedCount: number
57
+ lastUpdate: number
58
+ }
59
+
60
+ const SYNC_CHANNEL = typeof window !== 'undefined' ? new BroadcastChannel('dsh-pm-tasks-data') : null
61
+
62
+ let dataState: TaskDataState = {
63
+ tasks: [],
64
+ boundTaskId: null,
65
+ archivedCount: 0,
66
+ lastUpdate: 0,
67
+ }
68
+
69
+ const dataListeners = new Set<() => void>()
70
+
71
+ function setDataState(next: TaskDataState): void {
72
+ dataState = next
73
+ for (const listener of dataListeners) listener()
74
+ }
75
+
76
+ function broadcastDataUpdate(tasks: Task[], archivedCount: number): void {
77
+ if (!SYNC_CHANNEL) return
78
+ SYNC_CHANNEL.postMessage({
79
+ type: 'PROJECT_TASKS_UPDATED',
80
+ payload: { tasks, archivedCount },
81
+ })
82
+ }
83
+
84
+ if (SYNC_CHANNEL) {
85
+ SYNC_CHANNEL.onmessage = (event) => {
86
+ const msg = event.data
87
+ if (msg?.type === 'PROJECT_TASKS_UPDATED' && msg.payload) {
88
+ const { tasks, archivedCount } = msg.payload
89
+ setDataState(prev => ({
90
+ ...prev,
91
+ tasks: tasks || [],
92
+ archivedCount: archivedCount ?? 0,
93
+ lastUpdate: Date.now(),
94
+ }))
95
+ }
96
+ }
97
+ }
98
+
99
+ export const taskDataStore = {
100
+ subscribe(listener: () => void): () => void {
101
+ dataListeners.add(listener)
102
+ return () => { dataListeners.delete(listener) }
103
+ },
104
+ getSnapshot(): TaskDataState {
105
+ return dataState
106
+ },
107
+ actions: {
108
+ setTasks(payload: { tasks: Task[]; boundTaskId: string | null; archivedCount: number }): void {
109
+ const nextTasks = payload.tasks || []
110
+ const nextArchived = payload.archivedCount ?? 0
111
+ setDataState({
112
+ tasks: nextTasks,
113
+ boundTaskId: payload.boundTaskId ?? null,
114
+ archivedCount: nextArchived,
115
+ lastUpdate: Date.now(),
116
+ })
117
+ broadcastDataUpdate(nextTasks, nextArchived)
118
+ },
119
+ reset(): void {
120
+ setDataState({
121
+ tasks: [],
122
+ boundTaskId: null,
123
+ archivedCount: 0,
124
+ lastUpdate: 0,
125
+ })
126
+ },
127
+ },
128
+ }
129
+
130
+ export function useTaskData(): TaskDataState {
131
+ return useSyncExternalStore(taskDataStore.subscribe, taskDataStore.getSnapshot, taskDataStore.getSnapshot)
132
+ }
133
+
134
+ export function useTaskDataActions() {
135
+ return taskDataStore.actions
136
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Task Panel Hooks — 步骤拖拽交互逻辑(useTaskDrag)。
3
+ * 只依赖 react + task-data-store 类型,不碰数据/UI store。
4
+ */
5
+ import { useEffect, useRef, useState, useCallback } from 'react'
6
+ import { TaskStep } from './task-data-store.ts'
7
+
8
+ interface DragState {
9
+ fromIndex: number
10
+ stepContent: string
11
+ clientX: number
12
+ clientY: number
13
+ dragging: boolean
14
+ }
15
+
16
+ /**
17
+ * useTaskDrag — 步骤拖拽重排(绑定任务内,同一任务卡的步骤行间移动)。
18
+ *
19
+ * 交互模型(拖动全程按住鼠标):
20
+ * - 步骤行上按下(左键、非按钮/输入区域)→ 进入预览;
21
+ * - 按住 350ms 不松,或按住后移动超过 6px → 唤起跟随鼠标的浮动幽灵卡片;
22
+ * - 幽灵悬停到本任务卡内的步骤行时显示蓝色插入线(上半=插到该行前,下半=插到该行后);
23
+ * - 松手(mouseup):落在有效位置 → onReorder 落子;落在卡外/别的任务卡/原行
24
+ * (插入自身前后)/按钮输入等控件上 → 取消;
25
+ * - 350ms 内原地松手 = 普通点击,不产生幽灵;Esc → 取消。
26
+ */
27
+ export function useTaskDrag(
28
+ taskId: string,
29
+ steps: TaskStep[],
30
+ isBound: boolean,
31
+ onReorder: (fromIndex: number, toIndex: number) => void
32
+ ) {
33
+ const [dragState, setDragState] = useState<DragState | null>(null)
34
+ const [dropTargetIndex, setDropTargetIndex] = useState<number | null>(null)
35
+
36
+ // 最新值镜像:拖拽期间 window 监听只在“会话开始/结束”时挂载/卸载,
37
+ // 避免每次 mousemove 更新状态导致监听器重挂、连 350ms 定时器都被清掉。
38
+ const dragRef = useRef(dragState)
39
+ dragRef.current = dragState
40
+ const longPressTimer = useRef<number | undefined>(undefined)
41
+ const dragOrigin = useRef<{ x: number; y: number } | null>(null)
42
+
43
+ const clearTimer = useCallback(() => {
44
+ if (longPressTimer.current !== undefined) {
45
+ window.clearTimeout(longPressTimer.current)
46
+ longPressTimer.current = undefined
47
+ }
48
+ }, [])
49
+
50
+ const cancelDrag = useCallback(() => {
51
+ clearTimer()
52
+ dragOrigin.current = null
53
+ document.body.style.userSelect = ''
54
+ setDropTargetIndex(null)
55
+ setDragState(null)
56
+ }, [clearTimer])
57
+
58
+ const startDrag = useCallback((
59
+ fromIndex: number,
60
+ stepContent: string,
61
+ e: React.MouseEvent
62
+ ) => {
63
+ if (!isBound || e.button !== 0) return
64
+ if (dragRef.current) return // 已有预览/拖拽会话
65
+ e.preventDefault()
66
+ e.stopPropagation()
67
+ document.body.style.userSelect = 'none'
68
+ dragOrigin.current = { x: e.clientX, y: e.clientY }
69
+ setDropTargetIndex(null)
70
+ setDragState({ fromIndex, stepContent, clientX: e.clientX, clientY: e.clientY, dragging: false })
71
+ clearTimer()
72
+ longPressTimer.current = window.setTimeout(() => {
73
+ longPressTimer.current = undefined
74
+ // 350ms 长按 → 唤起幽灵卡片
75
+ setDragState(prev => (prev ? { ...prev, dragging: true } : null))
76
+ }, 350)
77
+ }, [isBound, clearTimer])
78
+
79
+ /** 计算某行上 y 坐标对应的插入位(行数下标:上半=该行前,下半=该行后);非本任务卡返回 null */
80
+ const dropIndexAt = useCallback((row: Element, clientY: number): number | null => {
81
+ const parentOl = row.closest('ol')
82
+ if (!parentOl || !parentOl.closest(`article[data-task-id="${taskId}"]`)) return null
83
+ const index = Array.from(parentOl.children).indexOf(row)
84
+ const rect = row.getBoundingClientRect()
85
+ return clientY < rect.top + rect.height / 2 ? index : index + 1
86
+ }, [taskId])
87
+
88
+ /** 在 (x,y) 处结算:命中有效落点则重排,否则取消;并结束本次拖拽 */
89
+ const commitDrop = useCallback((x: number, y: number) => {
90
+ const cur = dragRef.current
91
+ if (!cur || !cur.dragging) return
92
+ const el = document.elementFromPoint(x, y) as HTMLElement | null
93
+ // 松手点在按钮/输入等交互控件上:不落子,直接取消
94
+ if (el && !el.closest('button, input, textarea, a')) {
95
+ const row = el.closest('[data-step-row]')
96
+ if (row) {
97
+ const drop = dropIndexAt(row, y)
98
+ if (drop !== null) {
99
+ // 行下标形式的落点:插到目标行前/后;落在自身原位视为取消(不产生空推送)
100
+ const effective = drop > cur.fromIndex ? drop - 1 : drop
101
+ if (effective !== cur.fromIndex) onReorder(cur.fromIndex, drop)
102
+ }
103
+ }
104
+ }
105
+ cancelDrag()
106
+ }, [dropIndexAt, onReorder, cancelDrag])
107
+
108
+ const active = dragState !== null
109
+
110
+ useEffect(() => {
111
+ if (!active) return
112
+
113
+ const onMouseMove = (e: MouseEvent) => {
114
+ const cur = dragRef.current
115
+ if (!cur) return
116
+ if (!cur.dragging) {
117
+ // 未到 350ms:移动超过阈值视为拖拽开始(不必死等长按)
118
+ const origin = dragOrigin.current
119
+ if (!origin) return
120
+ if (Math.hypot(e.clientX - origin.x, e.clientY - origin.y) < 6) return
121
+ clearTimer()
122
+ setDragState(prev => (prev ? { ...prev, dragging: true, clientX: e.clientX, clientY: e.clientY } : null))
123
+ } else {
124
+ setDragState(prev => (prev ? { ...prev, clientX: e.clientX, clientY: e.clientY } : null))
125
+ }
126
+ const row = document.elementFromPoint(e.clientX, e.clientY)?.closest('[data-step-row]')
127
+ setDropTargetIndex(row ? dropIndexAt(row, e.clientY) : null)
128
+ }
129
+
130
+ const onMouseUp = (e: MouseEvent) => {
131
+ const cur = dragRef.current
132
+ if (!cur) return
133
+ if (cur.dragging) {
134
+ // 拖拽中松手 = 落子/取消
135
+ commitDrop(e.clientX, e.clientY)
136
+ } else {
137
+ // 350ms 内原地松手 = 普通点击
138
+ cancelDrag()
139
+ }
140
+ }
141
+
142
+ const onClick = (e: MouseEvent) => {
143
+ // 兜底:mouseup 发生在窗口外等情况时,下一次点击直接结算
144
+ const cur = dragRef.current
145
+ if (!cur || !cur.dragging) return
146
+ commitDrop(e.clientX, e.clientY)
147
+ }
148
+
149
+ const onKeyDown = (e: KeyboardEvent) => {
150
+ if (e.key === 'Escape') cancelDrag()
151
+ }
152
+
153
+ window.addEventListener('mousemove', onMouseMove)
154
+ window.addEventListener('mouseup', onMouseUp)
155
+ window.addEventListener('click', onClick, true)
156
+ window.addEventListener('keydown', onKeyDown)
157
+ return () => {
158
+ window.removeEventListener('mousemove', onMouseMove)
159
+ window.removeEventListener('mouseup', onMouseUp)
160
+ window.removeEventListener('click', onClick, true)
161
+ window.removeEventListener('keydown', onKeyDown)
162
+ document.body.style.userSelect = ''
163
+ }
164
+ }, [active, taskId, dropIndexAt, onReorder, cancelDrag, commitDrop])
165
+
166
+ useEffect(() => () => clearTimer(), [clearTimer])
167
+
168
+ return { dragState, dropTargetIndex, startDrag, cancelDrag }
169
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Task UI Store — 仅管本地 UI 状态(closed、minimized、position、expandedIds、theme)
3
+ * - 持久化到 localStorage(key: dsh-pm-task-panel-ui)
4
+ * - 不跨标签页同步(每个标签页独立)
5
+ * - 启动/刷新强制 closed: true,面板默认不显示
6
+ */
7
+ import { useSyncExternalStore } from 'react'
8
+
9
+ const STORAGE_KEY = 'dsh-pm-task-panel-ui'
10
+
11
+ interface TaskUIState {
12
+ expandedTaskIds: string[]
13
+ panelPosition: { x: number; y: number }
14
+ minimized: boolean
15
+ closed: boolean
16
+ theme: string
17
+ }
18
+
19
+ function defaultPosition(): { x: number; y: number } {
20
+ if (typeof window !== 'undefined') {
21
+ return { x: Math.max(16, window.innerWidth - 408), y: 72 }
22
+ }
23
+ return { x: 0, y: 0 }
24
+ }
25
+
26
+ /**
27
+ * 启动时的初始状态:
28
+ * - closed 恒为 true(不读取 localStorage 的 closed),刷新后面板隐藏;
29
+ * - 其余(展开项/位置/主题)可恢复上次会话偏好,minimized 默认折叠成迷你条。
30
+ */
31
+ function getDefaultUIState(): TaskUIState {
32
+ if (typeof window !== 'undefined') {
33
+ try {
34
+ const saved = localStorage.getItem(STORAGE_KEY)
35
+ if (saved) {
36
+ const parsed = JSON.parse(saved)
37
+ return {
38
+ expandedTaskIds: Array.isArray(parsed.expandedTaskIds) ? parsed.expandedTaskIds : [],
39
+ panelPosition: parsed.panelPosition ?? defaultPosition(),
40
+ minimized: parsed.minimized !== false,
41
+ closed: true,
42
+ theme: typeof parsed.theme === 'string' ? parsed.theme : 'native',
43
+ }
44
+ }
45
+ } catch { /* corrupt state — fall through to defaults */ }
46
+ }
47
+ return {
48
+ expandedTaskIds: [],
49
+ panelPosition: defaultPosition(),
50
+ minimized: true,
51
+ closed: true,
52
+ theme: 'native',
53
+ }
54
+ }
55
+
56
+ function persistUI(state: TaskUIState): void {
57
+ if (typeof window === 'undefined') return
58
+ try {
59
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
60
+ } catch { /* storage full/unavailable — non-fatal */ }
61
+ }
62
+
63
+ let uiState: TaskUIState = getDefaultUIState()
64
+ const uiListeners = new Set<() => void>()
65
+
66
+ function setUIState(next: TaskUIState): void {
67
+ uiState = next
68
+ persistUI(next)
69
+ for (const listener of uiListeners) listener()
70
+ }
71
+
72
+ export const taskUIStore = {
73
+ subscribe(listener: () => void): () => void {
74
+ uiListeners.add(listener)
75
+ return () => { uiListeners.delete(listener) }
76
+ },
77
+ getSnapshot(): TaskUIState {
78
+ return uiState
79
+ },
80
+ actions: {
81
+ toggleTaskExpanded(taskId: string): void {
82
+ const prev = uiState
83
+ const ids = prev.expandedTaskIds.includes(taskId)
84
+ ? prev.expandedTaskIds.filter((id) => id !== taskId)
85
+ : [...prev.expandedTaskIds, taskId]
86
+ setUIState({ ...prev, expandedTaskIds: ids })
87
+ },
88
+ expandAll(taskIds: string[]): void {
89
+ setUIState({ ...uiState, expandedTaskIds: taskIds })
90
+ },
91
+ setPanelPosition(pos: { x: number; y: number }): void {
92
+ setUIState({ ...uiState, panelPosition: pos })
93
+ },
94
+ open(): void {
95
+ setUIState({ ...uiState, minimized: false, closed: false })
96
+ },
97
+ minimize(): void {
98
+ setUIState({ ...uiState, minimized: true, closed: false })
99
+ },
100
+ close(): void {
101
+ setUIState({ ...uiState, minimized: true, closed: true })
102
+ },
103
+ setTheme(theme: string): void {
104
+ setUIState({ ...uiState, theme })
105
+ },
106
+ reset(): void {
107
+ setUIState(getDefaultUIState())
108
+ },
109
+ },
110
+ }
111
+
112
+ export function useTaskUI(): TaskUIState {
113
+ return useSyncExternalStore(taskUIStore.subscribe, taskUIStore.getSnapshot, taskUIStore.getSnapshot)
114
+ }
115
+
116
+ export function useTaskUIActions() {
117
+ return taskUIStore.actions
118
+ }
@@ -25,15 +25,17 @@ function withTaskSnapshot(config, cwd, sid, store, note) {
25
25
  return { kind: 'success', text: `${human}\n\n\`\`\`json\n${payload}\n\`\`\`` }
26
26
  }
27
27
 
28
- export function taskCommandDefinition(config) {
28
+ export function taskCommandDefinition(config, ctx) {
29
29
  return {
30
30
  name: 'task',
31
31
  description: '任务面板动作:/task switch <任务id> 切换绑定,/task archive <任务id> 归档,/task unbind 取消当前任务绑定',
32
32
  input: { hint: 'switch|archive <任务id> | unbind' },
33
33
  handler: (invocation) => {
34
34
  try {
35
- const cwd = invocation?.agent?.session?.header?.cwd
36
- const sid = invocation?.agent?.session?.id
35
+ const agent = invocation?.agent
36
+ const sid = agent?.id || agent?.session?.id
37
+ const session = sid && agent?.ctx ? agent.ctx.sessions?.get(sid) : (agent?.session || null)
38
+ const cwd = session?.header?.cwd || agent?.session?.header?.cwd
37
39
  const raw = (invocation?.rawInput || '').trim()
38
40
  const [verb, taskId, ...rest] = raw.split(/\s+/)
39
41
 
@@ -56,8 +58,8 @@ export function taskCommandDefinition(config) {
56
58
  if (sid) store.removeBinding(sid)
57
59
  store.save()
58
60
  // 推空 todo/write 清掉输入框上方的宿主任务清单(TaskBridge 空写=清空,不再自动建档)
59
- if (invocation?.agent?.session && typeof invocation.agent.session.append === 'function') {
60
- invocation.agent.session.append('todo/write', { todos: [] })
61
+ if (session && typeof session.append === 'function') {
62
+ session.append('todo/write', { todos: [] })
61
63
  }
62
64
  const note = '已取消当前任务绑定并清空会话任务清单(需要时可让模型重新规划)'
63
65
  return withTaskSnapshot(config, cwd, sid, store, note)
@@ -116,7 +118,7 @@ export function taskCommandDefinition(config) {
116
118
  task.lastActiveAt = task.updatedAt
117
119
  store.save()
118
120
  if (shouldAdoptToHost(config)) {
119
- adoptStepsToSession(invocation?.agent?.session, task)
121
+ adoptStepsToSession(session, task)
120
122
  }
121
123
  const note = `已更新「${task.title}」步骤(${norm.length} 条)`
122
124
  return withTaskSnapshot(config, cwd, sid, store, note)
@@ -134,7 +136,7 @@ export function taskCommandDefinition(config) {
134
136
  store.save()
135
137
  // 反向接管:切换成功后把任务步骤推成宿主 todo/write(dsh 清单跟随)
136
138
  if (shouldAdoptToHost(config)) {
137
- adoptStepsToSession(invocation?.agent?.session, task)
139
+ adoptStepsToSession(session, task)
138
140
  }
139
141
  const note = `已切换绑定: ${describeTask(task)}${sid ? '' : '(拿不到会话 id,未持久绑定)'}`
140
142
  return withTaskSnapshot(config, cwd, sid, store, note)
@@ -69,14 +69,16 @@ export function renderTaskSnapshot(config, cwd, sid) {
69
69
  return { kind: 'success', text: `${humanText}\n\n\`\`\`json\n${payload}\n\`\`\`` }
70
70
  }
71
71
 
72
- export function tasksCommandDefinition(config) {
72
+ export function tasksCommandDefinition(config, ctx) {
73
73
  return {
74
74
  name: 'tasks',
75
75
  description: '查看本项目的任务清单(几套任务、进度、涉及文件、当前绑定)',
76
76
  handler: (invocation) => {
77
77
  try {
78
- const cwd = invocation?.agent?.session?.header?.cwd
79
- const sid = invocation?.agent?.session?.id
78
+ const agent = invocation?.agent
79
+ const sid = agent?.id || agent?.session?.id
80
+ const session = sid && agent?.ctx ? agent.ctx.sessions?.get(sid) : (agent?.session || null)
81
+ const cwd = session?.header?.cwd || agent?.session?.header?.cwd
80
82
  return renderTaskSnapshot(config, cwd, sid)
81
83
  } catch (err) {
82
84
  return { kind: 'error', text: `[tasks] ${err?.message || err}` }
package/src/index.js CHANGED
@@ -10,7 +10,7 @@ import { WatchManager } from './watch.js'
10
10
  import { setupLazyIndexing } from './lazy.js'
11
11
  import { initTypeScript } from './enhancer.js'
12
12
  import { setupTaskbridge } from './setup/taskbridge.js'
13
- import { listTasksTool, selectTaskTool, archiveTaskTool } from './tools/task-tools.js'
13
+ import { listTasksTool, selectTaskTool, archiveTaskTool, showTaskPanelTool } from './tools/task-tools.js'
14
14
  import { tasksCommandDefinition } from './commands/tasks.js'
15
15
  import { taskCommandDefinition } from './commands/task-actions.js'
16
16
 
@@ -63,12 +63,13 @@ export function apply(ctx, config) {
63
63
  ctx.tools.register(listTasksTool(config))
64
64
  ctx.tools.register(selectTaskTool(config))
65
65
  ctx.tools.register(archiveTaskTool(config))
66
+ ctx.tools.register(showTaskPanelTool(config))
66
67
 
67
68
  // /tasks、/task 用户命令(宿主 commands 服务存在时注册,feature-detect 降级)
68
69
  try {
69
70
  ctx.inject(['commands'], (commandsCtx) => {
70
- commandsCtx.commands.register(tasksCommandDefinition(config))
71
- commandsCtx.commands.register(taskCommandDefinition(config))
71
+ commandsCtx.commands.register(tasksCommandDefinition(config, ctx))
72
+ commandsCtx.commands.register(taskCommandDefinition(config, ctx))
72
73
  })
73
74
  } catch (err) {
74
75
  console.error(`[dsh-project-memory] /tasks,/task registration skipped: ${err.message}`)
@@ -145,6 +145,25 @@ export function selectTaskTool(config) {
145
145
  })
146
146
  }
147
147
 
148
+ export function showTaskPanelTool(config) {
149
+ return defineTool({
150
+ name: 'show_task_panel',
151
+ description:
152
+ 'Show the task panel in the UI. Call this when the user asks to see the task list or when you want to display the task panel to the user.',
153
+ parameters: {},
154
+ output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
155
+ async execute(_args, exec) {
156
+ const ctx = exec?.ctx
157
+ if (!ctx) return '无法获取上下文'
158
+ // 通过 BroadcastChannel 通知客户端打开面板
159
+ try {
160
+ ctx.events?.emit?.('dsh:task-panel:show', {})
161
+ } catch { /* ignore */ }
162
+ return '已请求打开任务面板。'
163
+ },
164
+ })
165
+ }
166
+
148
167
  export function archiveTaskTool(config) {
149
168
  return defineTool({
150
169
  name: 'archive_task',