dsh-island-overlay 0.2.2 → 0.2.3

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 CHANGED
@@ -83,15 +83,16 @@ dsh plugin --profile desktop add dsh-island-overlay
83
83
  | 事件 | 显示 |
84
84
  |---|---|
85
85
  | `turn/start` | 思考(小鲸鱼 + 计时,2.5s 后缩成小图标) |
86
- | `approval/asked` | 等待审批(琥珀脉冲,常驻展开) |
86
+ | `approval/asked` | 等待审批(琥珀脉冲,常驻展开,计时接着走) |
87
87
  | `tool/call` = `ask_user_question` | 等待回答(同上) |
88
88
  | `turn/end` = `completed` | 完成(绿勾,3s 后消失) |
89
89
  | `turn/end` = `error` / `blocked` / `max-tokens` | 失败(红色抖动,6s 后消失) |
90
90
  | `turn/end` = `aborted` / `interrupted` / `forked` | 不显示,直接消失 |
91
91
 
92
92
  多个会话同时有事时只显示优先级最高的一个(审批/回答 > 失败 > 完成 > 思考),其余用 `+N` 角标;最多并列显示 3 个会话,每段各自计时。
93
+ 计时读的是**回合开始时间**,所以「思考 20 分钟 → 弹审批 → 继续思考」是一条连续的时间轴,不会归零。
93
94
 
94
- 交互:拖动可移动位置(记在 `~/.dsh/dsh-island-pos.json`);点一下把 DSH 拉到前台;
95
+ 交互:拖动可移动位置(自动切到「自定义位置」,记在 `~/.dsh/dsh-island-pos.json`);点一下把 DSH 拉到前台;
95
96
  鼠标划过展开全文;检测到前台是全屏程序(视频/游戏)时自动隐藏。
96
97
 
97
98
  ## 卸载
package/lib/index.js CHANGED
@@ -167,24 +167,27 @@ export default {
167
167
  }
168
168
 
169
169
  const name = titleOf(session)
170
+ // since = 这一回合的开始时间,只有 turn/start 能改它。
171
+ // 以前 approval/asked、approval/decided、tool/call 都把它重置成 now,
172
+ // 于是「思考 20 分钟 → 弹审批 → 继续思考」的计时会从 0 重新数。
170
173
  switch (type) {
171
174
  case 'turn/start':
172
175
  touch(sid, { state: 'thinking', detail: '', since: Date.now(), title: name })
173
176
  break
174
177
  case 'approval/asked':
175
- touch(sid, { state: 'approval', detail: String(data.toolName || '需要授权'), since: Date.now(), title: name })
178
+ touch(sid, { state: 'approval', detail: String(data.toolName || '需要授权'), title: name })
176
179
  break
177
180
  case 'approval/decided':
178
- if (items.has(sid)) touch(sid, { state: 'thinking', detail: '', since: Date.now(), title: name })
181
+ if (items.has(sid)) touch(sid, { state: 'thinking', detail: '', title: name })
179
182
  break
180
183
  case 'tool/call':
181
184
  if (String(data.name || '') === 'ask_user_question') {
182
- touch(sid, { state: 'ask', detail: '等待你的回答', since: Date.now(), title: name })
185
+ touch(sid, { state: 'ask', detail: '等待你的回答', title: name })
183
186
  }
184
187
  break
185
188
  case 'tool/result': {
186
189
  const cur = items.get(sid)
187
- if (cur && cur.state === 'ask') touch(sid, { state: 'thinking', detail: '', since: Date.now(), title: name })
190
+ if (cur && cur.state === 'ask') touch(sid, { state: 'thinking', detail: '', title: name })
188
191
  break
189
192
  }
190
193
  case 'turn/end': {
@@ -1,4 +1,5 @@
1
1
  # Bring the DSH main window to the foreground (click-through pill -> click).
2
+ # Fallback path only: the overlay normally sends "activate" over watch.ps1's stdin.
2
3
  $ErrorActionPreference = 'SilentlyContinue'
3
4
 
4
5
  Add-Type -TypeDefinition @"
@@ -7,6 +8,7 @@ using System.Runtime.InteropServices;
7
8
  public class IslandFg {
8
9
  [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
9
10
  [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);
11
+ [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
10
12
  [DllImport("user32.dll")] public static extern void keybd_event(byte vk, byte scan, uint flags, IntPtr extra);
11
13
  }
12
14
  "@
@@ -20,5 +22,6 @@ if (-not $p) { exit }
20
22
  # 先按一下 Alt 骗过前台锁定,再抢焦点
21
23
  [IslandFg]::keybd_event(0x12, 0, 0, [IntPtr]::Zero)
22
24
  [IslandFg]::keybd_event(0x12, 0, 2, [IntPtr]::Zero)
23
- [IslandFg]::ShowWindow($p.MainWindowHandle, 9) | Out-Null
25
+ # 只在真最小化时还原。SW_RESTORE 会把最大化窗口缩回普通窗口,就是用户报的「点了变成窗口化」
26
+ if ([IslandFg]::IsIconic($p.MainWindowHandle)) { [IslandFg]::ShowWindow($p.MainWindowHandle, 9) | Out-Null }
24
27
  [IslandFg]::SetForegroundWindow($p.MainWindowHandle) | Out-Null
@@ -150,7 +150,12 @@ function esc(s) {
150
150
 
151
151
  function fmtTime(sec) {
152
152
  sec = Math.max(0, Math.floor(sec))
153
- return sec < 60 ? sec + 's' : Math.floor(sec / 60) + 'm' + (sec % 60) + 's'
153
+ if (sec < 60) return sec + 's'
154
+ const m = Math.floor(sec / 60)
155
+ const s = sec % 60
156
+ if (m < 60) return s ? m + 'm' + s + 's' : m + 'm'
157
+ const h = Math.floor(m / 60)
158
+ return m % 60 ? h + 'h' + (m % 60) + 'm' : h + 'h'
154
159
  }
155
160
 
156
161
  function icon(state) {
@@ -202,8 +207,11 @@ function ordered() {
202
207
  .sort((a, b) => (RANK[b.state] || 0) - (RANK[a.state] || 0) || (a.since || 0) - (b.since || 0))
203
208
  }
204
209
 
210
+ const TIMED = { thinking: 1, approval: 1, ask: 1 }
211
+
205
212
  function segHtml(it, primary) {
206
- const meta = it.state === 'thinking'
213
+ // 计时都读 since(= 回合开始),所以「思考 → 审批 → 继续思考」是一条连续的时间轴
214
+ const meta = TIMED[it.state]
207
215
  ? '<span class="meta" data-since="' + (Number(it.since) || Date.now()) + '">' + fmtTime((Date.now() - (Number(it.since) || Date.now())) / 1000) + '</span>'
208
216
  : it.state === 'done' ? '<span class="meta">刚刚</span>' : ''
209
217
  // 只有主段展开标题,其余段只留图标 + 状态词,避免胶囊被撑爆
@@ -228,6 +236,9 @@ function render() {
228
236
  curState = ''
229
237
  clearTimeout(shrinkTimer)
230
238
  if (tick) { clearInterval(tick); tick = null }
239
+ // 悬停中被清空时必须把鼠标穿透还回去:窗口本身还在,ignoreMouseEvents=false
240
+ // 会让它在「看不见」的状态下继续吃掉顶部中间那块区域的点击。
241
+ if (hovered) { hovered = false; window.islandApi.hover(false) }
231
242
  return
232
243
  }
233
244
 
@@ -235,8 +246,9 @@ function render() {
235
246
  const e = cfg.effects || DEFAULTS.effects
236
247
  const shown = list.slice(0, MAXSEG)
237
248
  const rest = list.length - shown.length
238
- // cfg 也进 key:设置页改了缩放/迷你尺寸/动效开关时,items 没变也得重画
239
- const key = list.map(it => it.state + it.id + it.since).join(',') + '|' + rest + '|' + JSON.stringify(cfg)
249
+ // cfg 也进 key:设置页改了缩放/迷你尺寸/动效开关时,items 没变也得重画。
250
+ // title/detail 同样要进:会话改名、失败原因变化时 since 不变,漏了就不会重绘。
251
+ const key = list.map(it => it.state + it.id + it.since + '|' + (it.title || '') + '|' + (it.detail || '')).join(',') + '|' + rest + '|' + JSON.stringify(cfg)
240
252
  if (key !== curKey) {
241
253
  curKey = key
242
254
  curState = best.state
@@ -255,9 +267,9 @@ function render() {
255
267
  scheduleMini(hovered ? 0 : holdFor(best.state))
256
268
  }
257
269
 
258
- // 只要有思考中的段就跑秒表
259
- const anyThinking = list.some(it => it.state === 'thinking')
260
- if (anyThinking) {
270
+ // 只要有一段带计时(思考/审批/等待回答)就跑秒表
271
+ const anyTimed = list.some(it => TIMED[it.state])
272
+ if (anyTimed) {
261
273
  if (!tick) { paintTimer(); tick = setInterval(paintTimer, 1000) }
262
274
  } else if (tick) { clearInterval(tick); tick = null }
263
275
  }
package/overlay/main.js CHANGED
@@ -7,6 +7,7 @@ const { execFile, spawn } = require('child_process')
7
7
  const DSH_HOME = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
8
8
  const STATE_FILE = path.join(DSH_HOME, 'dsh-island-state.json')
9
9
  const POS_FILE = path.join(DSH_HOME, 'dsh-island-pos.json')
10
+ const CONFIG_FILE = path.join(DSH_HOME, 'dsh-island-config.json')
10
11
  const DEAD_MS = 30000
11
12
  const LOG_FILE = path.join(DSH_HOME, 'dsh-island-overlay.log')
12
13
 
@@ -57,6 +58,20 @@ function savePos() {
57
58
  } catch (err) {}
58
59
  }
59
60
 
61
+ // 拖动 = 切到「自定义位置」。宿主 watchFile 监听同一个配置文件,会立刻把新
62
+ // 配置下发回来(不需要浮层自己刷新界面)。写失败就算了,位置至少本次有效。
63
+ function persistPositionCustom() {
64
+ try {
65
+ let cur = {}
66
+ try { cur = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')) || {} } catch (err) {}
67
+ if (cur.position === 'custom') return
68
+ cur.position = 'custom'
69
+ const tmp = CONFIG_FILE + '.tmp'
70
+ fs.writeFileSync(tmp, JSON.stringify(cur, null, 2), 'utf8')
71
+ fs.renameSync(tmp, CONFIG_FILE)
72
+ } catch (err) {}
73
+ }
74
+
60
75
  // 窗口比胶囊大一圈:缩放后的胶囊要放得下,还要留出呼吸/涟漪的溢出空间
61
76
  function winSize(s) {
62
77
  return { w: Math.round(BASE_W * s), h: Math.round(BASE_H * s) }
@@ -113,15 +128,27 @@ function create() {
113
128
  win.setIgnoreMouseEvents(true, { forward: true })
114
129
  win.loadFile(path.join(__dirname, 'index.html'))
115
130
  win.once('ready-to-show', () => win.showInactive())
131
+ // 首帧会丢:tick() 在页面还没加载完时就把状态 send 出去了(被丢弃),
132
+ // 而 last 已经记下,之后状态不再变化就一直不重发 —— 结果是浮层起来了却空白。
133
+ // 页面加载完清掉 last,让下一次 tick 无条件重发。
134
+ win.webContents.on('did-finish-load', () => { last = '' })
116
135
 
117
136
  // 诊断:之前浮层会莫名其妙消失且日志里什么都没留下,这里把每种死法都记下来
118
- win.webContents.on('render-process-gone', (_e, d) => log('render-process-gone: ' + JSON.stringify(d)))
137
+ let reloads = 0
138
+ win.webContents.on('render-process-gone', (_e, d) => {
139
+ log('render-process-gone: ' + JSON.stringify(d))
140
+ // 渲染进程挂了窗口会留在那但永远空白。重载一次;连续崩就退出,
141
+ // 让插件的 3 秒心跳重新拉起一个干净进程,而不是卡在空白窗口上。
142
+ if (reloads++ < 2) setTimeout(() => { try { win.reload() } catch (err) {} }, 1000)
143
+ else { log('quit: renderer keeps dying'); app.quit() }
144
+ })
119
145
  win.webContents.on('unresponsive', () => log('unresponsive'))
120
146
  win.webContents.on('responsive', () => log('responsive'))
121
147
  app.on('child-process-gone', (_e, d) => log('child-process-gone: ' + JSON.stringify(d)))
122
148
  win.on('hide', () => log('window hidden'))
123
149
  win.on('show', () => log('window shown'))
124
- win.on('closed', () => log('window closed'))
150
+ // 窗口被关掉但进程还在 = 不会再有任何界面;直接退出让宿主重新拉起
151
+ win.on('closed', () => { log('window closed'); app.quit() })
125
152
 
126
153
  // 别的置顶窗口偶尔会把我们压下去,定期重新抬一次
127
154
  setInterval(() => {
@@ -276,6 +303,9 @@ ipcMain.on('drag-end', () => {
276
303
  if (!dragOrigin) return
277
304
  dragOrigin = null
278
305
  savePos()
306
+ // 只写 pos.json 没用:place() 只在 position==='custom' 时才读它,
307
+ // 所以拖完重启又回到顶部居中。这里把配置切成 custom(宿主 watchFile 会接住)。
308
+ persistPositionCustom()
279
309
  })
280
310
 
281
311
  app.whenReady().then(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-island-overlay",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "main": "lib/index.js",
6
6
  "exports": {