dsh-connect 0.7.0 → 0.7.2

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/lib/runner.js CHANGED
@@ -10,8 +10,10 @@ import { copyFile, readFile } from "node:fs/promises";
10
10
  import { basename, isAbsolute, join } from "node:path";
11
11
  import { SessionId } from "@deepseek-ai/dsh-session";
12
12
  import { createUserMessage, ReasoningEffortId } from "@deepseek-ai/dsh-llm";
13
- import { createAsyncQueue } from "./stream.js";
13
+ import { createAsyncQueue, applyStreamChunk, applyToolCall, toolCallSummary, questionTextOf, summarizeTurn, classifyError, mapReason, truncate, fmtTokens, textOf, } from "./stream.js";
14
14
  import { helpText, parseCommand } from "./commands.js";
15
+ import { menuTitle, rootMenuSections, reasonLabel, goalPhaseLabel, listWorkspaces, PROGRESS_PRESET_MS } from "./menus.js";
16
+ import { MenuController } from "./menu-controller.js";
15
17
  import { messages } from "./i18n.js";
16
18
  import { DEFAULT_LOCK_TIMEOUT_MS, acquire as lockAcquire, canWrite as lockCanWrite, isLockTimedOut as lockIsTimedOut, release as lockRelease, } from "./mirror-lock.js";
17
19
  import { formatRemindAt, parseRemindTime } from "./scheduler.js";
@@ -27,280 +29,10 @@ export function resolveConnectConfig(config) {
27
29
  stateDir: config.stateDir,
28
30
  autoMirror: config.autoMirror ?? true, // Enabled by default
29
31
  streamHeartbeatMs: config.streamHeartbeatMs ?? 60_000,
30
- notifyLevel: config.notifyLevel ?? "important",
32
+ notifyLevel: config.notifyLevel ?? "result",
31
33
  progressTimeoutMs: config.progressTimeoutMs ?? 5 * 60_000, // Proactive progress notice after 5 min of silence
32
34
  };
33
35
  }
34
- /**
35
- * Assemble one `assistant/chunk` event into the streaming queue with visible
36
- * structure. DSH streams one block per LLM content block and block boundaries
37
- * carry no newline of their own, so without separators every block (and the
38
- * reasoning phase vs the final answer) would be glued together in the chat
39
- * card. This inserts a blank line between blocks and before the answer, keeps
40
- * the thinking hint once per turn, expands reasoning soft breaks (Feishu's
41
- * streaming card collapses single `\n`), and falls back to whole-block text
42
- * for blocks that arrived only via `block-end`. Every push refreshes
43
- * `lastPushAt` so the liveness heartbeat knows the card is still moving.
44
- * @param state - the turn's mutable assembly state.
45
- * @param thinkingHint - localized hint text pushed at the first reasoning delta.
46
- * @param chunk - the assistant chunk event's `chunk` payload.
47
- */
48
- /** Every line-break run becomes a paragraph break (Feishu's streaming card collapses single `\n`). */
49
- function expandLineBreaks(text) {
50
- return text.replace(/\r\n?/g, "\n").replace(/\n+/g, "\n\n");
51
- }
52
- export function applyStreamChunk(state, thinkingHint, chunk, level = "full") {
53
- if (chunk.type === "text-delta" || chunk.type === "reasoning-delta") {
54
- const isReasoning = chunk.type === "reasoning-delta";
55
- // Reasoning content is only streamed in `full` mode; `important` shows
56
- // just the thinking hint and `result` shows nothing until the answer.
57
- if (isReasoning && level !== "full") {
58
- if (level === "important" && !state.hintPushed) {
59
- state.hintPushed = true;
60
- state.chunks.push(thinkingHint);
61
- state.pushedAny = true;
62
- state.lastPushAt = Date.now();
63
- }
64
- return;
65
- }
66
- if (state.pushedAny) {
67
- const newBlock = chunk.index !== undefined && state.lastIndex !== undefined && chunk.index !== state.lastIndex;
68
- const reasoningToAnswer = !isReasoning && state.reasoning;
69
- if (newBlock || reasoningToAnswer)
70
- state.chunks.push("\n\n");
71
- }
72
- if (isReasoning) {
73
- if (!state.hintPushed) {
74
- state.hintPushed = true;
75
- state.chunks.push(thinkingHint);
76
- }
77
- const text = chunk.text ?? "";
78
- if (text !== "") {
79
- state.chunks.push(expandLineBreaks(text));
80
- }
81
- state.reasoning = true;
82
- }
83
- else {
84
- const text = chunk.text ?? "";
85
- state.chunks.push(text);
86
- state.lastText += text;
87
- state.reasoning = false;
88
- }
89
- state.lastIndex = chunk.index;
90
- state.pushedAny = true;
91
- state.lastPushAt = Date.now();
92
- return;
93
- }
94
- if (chunk.type === "block-end") {
95
- // Short blocks may arrive whole here with no preceding delta chunks.
96
- // Fall back to the block text when this index never streamed deltas, so
97
- // no content is silently dropped (guarded by index against duplication;
98
- // each step restarts block indices, and a tool call resets `lastIndex`,
99
- // so a same-index block-end after streamed deltas is reliably detected).
100
- const index = chunk.index;
101
- const blockType = chunk.block?.type;
102
- const text = chunk.block?.text;
103
- if (blockType === "reasoning" && level !== "full") {
104
- // Reasoning block text is never streamed outside `full` mode.
105
- if (level === "important" && !state.hintPushed) {
106
- state.hintPushed = true;
107
- state.chunks.push(thinkingHint);
108
- state.pushedAny = true;
109
- state.lastPushAt = Date.now();
110
- }
111
- return;
112
- }
113
- if (typeof text === "string" && text.length > 0 && (index === undefined || index !== state.lastIndex)) {
114
- if (state.pushedAny)
115
- state.chunks.push("\n\n");
116
- if (blockType === "reasoning" && !state.hintPushed) {
117
- state.hintPushed = true;
118
- state.chunks.push(thinkingHint);
119
- }
120
- state.chunks.push(blockType === "reasoning" ? expandLineBreaks(text) : text);
121
- if (blockType !== "reasoning")
122
- state.lastText += text;
123
- state.lastIndex = index;
124
- state.pushedAny = true;
125
- state.reasoning = blockType === "reasoning";
126
- state.lastPushAt = Date.now();
127
- }
128
- }
129
- }
130
- /**
131
- * Push a localized tool-call status line into the streaming queue so long
132
- * tool-execution stretches stay visibly alive (the Web GUI shows tool calls;
133
- * the chat card should too). Ends the reasoning phase and drops the block
134
- * index so the next content block gets its own separator instead of stacking
135
- * one on top of the tool line's own blank lines.
136
- * @param state - the turn's mutable assembly state.
137
- * @param label - the localized status line, e.g. "🔧 调用工具 `pwsh` — …".
138
- */
139
- export function applyToolCall(state, label) {
140
- state.chunks.push(`\n\n${label}\n\n`);
141
- state.reasoning = false;
142
- state.lastIndex = undefined;
143
- state.pushedAny = true;
144
- state.lastPushAt = Date.now();
145
- }
146
- /** Pull a short human summary out of a tool call's `arguments` JSON (whitespace folded to one line). */
147
- export function toolCallSummary(argumentsJson) {
148
- if (typeof argumentsJson !== "string")
149
- return undefined;
150
- const fold = (value) => value.replace(/\s+/g, " ").trim();
151
- try {
152
- const parsed = JSON.parse(argumentsJson);
153
- const description = parsed?.description;
154
- if (typeof description === "string" && description !== "") {
155
- const folded = fold(description);
156
- return folded.length > 60 ? `${folded.slice(0, 60)}…` : folded;
157
- }
158
- for (const key of ["file_path", "path"]) {
159
- const value = parsed?.[key];
160
- if (typeof value === "string" && value !== "") {
161
- const folded = fold(value);
162
- return folded.length > 60 ? `${folded.slice(0, 60)}…` : folded;
163
- }
164
- }
165
- }
166
- catch {
167
- // Not JSON — no summary.
168
- }
169
- return undefined;
170
- }
171
- /** Pull the first question's text out of an `ask_user_question` arguments JSON (whitespace folded to one line). */
172
- export function questionTextOf(argumentsJson) {
173
- if (typeof argumentsJson !== "string")
174
- return "";
175
- try {
176
- const parsed = JSON.parse(argumentsJson);
177
- const first = Array.isArray(parsed?.questions) ? parsed.questions[0] : undefined;
178
- const text = typeof first?.question === "string" ? first.question : "";
179
- const folded = text.replace(/\s+/g, " ").trim();
180
- return folded.length > 60 ? `${folded.slice(0, 60)}…` : folded;
181
- }
182
- catch {
183
- // Not JSON — no question text.
184
- }
185
- return "";
186
- }
187
- function mapReason(kind) {
188
- switch (kind) {
189
- case "completed":
190
- case "aborted":
191
- case "blocked":
192
- case "error":
193
- case "max-tokens":
194
- case "interrupted":
195
- return kind;
196
- default:
197
- return "unknown";
198
- }
199
- }
200
- export function summarizeTurn(events, firstSeq) {
201
- let started = false;
202
- let text = "";
203
- let reason = "unknown";
204
- let code;
205
- let message;
206
- let model;
207
- let inputTokens = 0;
208
- let outputTokens = 0;
209
- let cacheReadTokens = 0;
210
- let contextSize;
211
- let contextWindow;
212
- let steps = 0;
213
- let turnStartTime;
214
- let turnEndTime;
215
- for (const event of events) {
216
- if (event.seq < firstSeq)
217
- continue;
218
- if (event.type === "turn/start") {
219
- started = true;
220
- turnStartTime = typeof event.time === "number" ? event.time : undefined;
221
- continue;
222
- }
223
- if (!started)
224
- continue;
225
- if (event.type === "step/start")
226
- steps += 1;
227
- if (event.type === "request/context") {
228
- const provider = typeof event.data.provider === "string" ? event.data.provider : undefined;
229
- const m = typeof event.data.model === "string" ? event.data.model : undefined;
230
- if (provider !== undefined && m !== undefined)
231
- model = `${provider}/${m}`;
232
- if (typeof event.data.contextWindow === "number")
233
- contextWindow = event.data.contextWindow;
234
- }
235
- if (event.type === "assistant/message") {
236
- const usage = event.data.usage;
237
- if (usage !== undefined) {
238
- inputTokens += typeof usage.inputTokens === "number" ? usage.inputTokens : 0;
239
- outputTokens += typeof usage.outputTokens === "number" ? usage.outputTokens : 0;
240
- cacheReadTokens += typeof usage.cacheReadTokens === "number" ? usage.cacheReadTokens : 0;
241
- if (typeof usage.inputTokens === "number")
242
- contextSize = usage.inputTokens;
243
- }
244
- const joined = event.data.message.content
245
- .filter((block) => block.type === "text")
246
- .map((block) => block.text)
247
- .join("");
248
- if (joined !== "")
249
- text = joined;
250
- }
251
- if (event.type === "turn/end") {
252
- turnEndTime = typeof event.time === "number" ? event.time : undefined;
253
- reason = mapReason(event.data.reason.kind);
254
- if (event.data.reason.kind === "error") {
255
- code = event.data.reason.error.code;
256
- message = event.data.reason.error.message;
257
- }
258
- }
259
- }
260
- const elapsedMs = turnStartTime !== undefined && turnEndTime !== undefined && turnEndTime >= turnStartTime
261
- ? turnEndTime - turnStartTime
262
- : undefined;
263
- return {
264
- reason,
265
- text,
266
- ...(code === undefined ? {} : { code }),
267
- ...(message === undefined ? {} : { message }),
268
- ...(model === undefined ? {} : { model }),
269
- ...(inputTokens > 0 ? { inputTokens } : {}),
270
- ...(outputTokens > 0 ? { outputTokens } : {}),
271
- ...(cacheReadTokens > 0 ? { cacheReadTokens } : {}),
272
- ...(contextSize === undefined ? {} : { contextSize }),
273
- ...(contextWindow === undefined ? {} : { contextWindow }),
274
- ...(steps > 0 ? { steps } : {}),
275
- ...(elapsedMs === undefined ? {} : { elapsedMs }),
276
- };
277
- }
278
- function truncate(text, max = 500) {
279
- return text.length <= max ? text : `${text.slice(0, max)}…`;
280
- }
281
- /** Classify an error message into an advice bucket (keyword-based, best-effort). */
282
- export function classifyError(detail) {
283
- const d = detail.toLowerCase();
284
- if (/(permission|forbidden|denied|not have access|no access|unauthorized|403|sandbox|acl |isn't permitted|not permitted|access denied)/.test(d))
285
- return "permission";
286
- if (/(timeout|econnrefused|econnreset|enotfound|enetunreach|etimedout|socket|network|proxy|tls|certificate|fetch failed|connect |unreachable|dns)/.test(d))
287
- return "network";
288
- if (/(model|quota|rate ?limit|429|insufficient|balance|token(s)? (limit|length)|max_tokens|context length|credit)/.test(d))
289
- return "model";
290
- return "generic";
291
- }
292
- /** Thousands-separated token count for display. */
293
- function fmtTokens(n) {
294
- return n.toLocaleString("en-US");
295
- }
296
- function textOf(message) {
297
- if (message === undefined)
298
- return "";
299
- return message.content
300
- .filter((b) => b.type === "text" && typeof b.text === "string")
301
- .map((b) => b.text ?? "")
302
- .join("");
303
- }
304
36
  /** Lightweight fold of `schedule/change` events (create / delete / dispatch). */
305
37
  function foldReminders(events) {
306
38
  const active = new Map();
@@ -378,6 +110,7 @@ export class AgentRunner {
378
110
  notifyLevel;
379
111
  progressTimeoutMs;
380
112
  t;
113
+ menu;
381
114
  constructor(ctx, config, channel, chatKey, chatType, adapter, bindings, adapters,
382
115
  /**
383
116
  * Route a replayed queued message back into the runner of its own channel
@@ -404,54 +137,16 @@ export class AgentRunner {
404
137
  this.notifyLevel = stored?.notifyLevel ?? config.notifyLevel;
405
138
  this.progressTimeoutMs = stored?.progressTimeoutMs ?? config.progressTimeoutMs;
406
139
  this.t = messages(this.language);
140
+ this.menu = new MenuController(this);
407
141
  }
408
142
  menuTitle(menuId) {
409
- switch (menuId) {
410
- case "root":
411
- return this.t.menuRoot;
412
- case "workspace":
413
- return this.t.menuWorkspace;
414
- case "chat":
415
- return this.t.menuChat;
416
- case "settings":
417
- return this.t.menuSettings;
418
- case "model":
419
- return this.t.menuModel;
420
- case "reasoning":
421
- return this.t.menuReasoning;
422
- case "notify":
423
- return this.t.menuNotify;
424
- case "language":
425
- return this.t.menuSettingsLanguage;
426
- case "progress":
427
- return this.t.progressMenuTitle;
428
- }
143
+ return menuTitle(menuId, this.t);
429
144
  }
430
145
  rootMenuSections() {
431
- return [
432
- { title: this.t.rootSectionWorkspace, ids: ["workspace"], columnsPerRow: 1 },
433
- { title: this.t.rootSectionChat, ids: ["chat", "history"], columnsPerRow: 1 },
434
- { title: this.t.rootSectionTask, ids: ["task", "goals", "schedule"], columnsPerRow: 2 },
435
- { title: this.t.rootSectionSystem, ids: ["status", "plugins", "compact", "settings"], columnsPerRow: 2 },
436
- ];
146
+ return rootMenuSections(this.t);
437
147
  }
438
148
  reasonLabel(reason) {
439
- switch (reason) {
440
- case "completed":
441
- return this.t.reasonCompleted;
442
- case "aborted":
443
- return this.t.reasonAborted;
444
- case "blocked":
445
- return this.t.reasonBlocked;
446
- case "error":
447
- return this.t.reasonError;
448
- case "max-tokens":
449
- return this.t.reasonMaxTokens;
450
- case "interrupted":
451
- return this.t.reasonInterrupted;
452
- default:
453
- return this.t.reasonUnknown;
454
- }
149
+ return reasonLabel(reason, this.t);
455
150
  }
456
151
  /** Prefer the user's first DSH workspace over the process cwd as default. */
457
152
  resolveDefaultWorkDir() {
@@ -528,16 +223,18 @@ export class AgentRunner {
528
223
  else {
529
224
  turn.milestone = this.t.toolCalling(name, undefined);
530
225
  }
531
- // `result` mode shows nothing until the final answer; `important` shows
532
- // the tool name with a step counter, `full` also carries the summary.
533
- if (this.notifyLevel === "result")
226
+ // `result` mode shows nothing until the final answer.
227
+ if (this.notifyLevel === 'result')
228
+ return;
229
+ // Keep the streaming card short: push a status line only once per distinct
230
+ // tool (not once per call, which would log "tool #51" spam), and label it
231
+ // with the tool name rather than a running counter.
232
+ if (turn.lastToolName === name)
534
233
  return;
535
- const base = name === "ask_user_question"
234
+ turn.lastToolName = name;
235
+ const label = name === 'ask_user_question'
536
236
  ? this.t.questionToolCall(questionTextOf(event.data.arguments))
537
- : this.t.toolStepLabel(turn.toolCount, name);
538
- const label = name === "ask_user_question" || this.notifyLevel !== "full" || summary === undefined
539
- ? base
540
- : `${base} — ${summary}`;
237
+ : this.t.toolCalling(name, this.notifyLevel === 'full' ? summary : undefined);
541
238
  applyToolCall(turn, label);
542
239
  }
543
240
  }
@@ -915,7 +612,7 @@ export class AgentRunner {
915
612
  this.turn = {
916
613
  firstSeq, chunks, lastText: "",
917
614
  reasoning: false, hintPushed: false, lastIndex: undefined, pushedAny: false,
918
- startedAt: now, lastPushAt: now, toolCount: 0, contextNudged: false, compactAfterTurn: false,
615
+ startedAt: now, lastPushAt: now, toolCount: 0, lastToolName: undefined, contextNudged: false, compactAfterTurn: false,
919
616
  };
920
617
  // Liveness heartbeat: while the agent is working, keep the streaming card
921
618
  // visibly alive even through long reasoning or tool-execution stretches
@@ -936,26 +633,29 @@ export class AgentRunner {
936
633
  }, heartbeatMs)
937
634
  : undefined;
938
635
  // Proactive progress watchdog: when no standalone card/text has been sent
939
- // for `progressTimeoutMs` (default 5 min, user-configurable), send a status
940
- // card reporting the latest milestone so a long turn never looks frozen.
941
- // Streaming-card heartbeats deliberately do NOT reset this — the user asked
942
- // for an explicit, separate nudge.
636
+ // for `progressTimeoutMs` (default 5 min, user-configurable), re-sync the
637
+ // latest milestone INTO the streaming card (via its chunk stream) so a long
638
+ // turn never looks frozen — WITHOUT posting a separate message. The card is
639
+ // edited in place, keeping the chat history clean. Streaming-card heartbeats
640
+ // deliberately do NOT reset this: the progress watchdog only fires when no
641
+ // explicit milestone sync has been pushed for the configured interval.
943
642
  const progressTimeoutMs = this.progressTimeoutMs;
944
- let lastStandaloneNoticeAt = now;
643
+ let lastProgressNoticeAt = now;
945
644
  const progressWatchdog = progressTimeoutMs > 0
946
645
  ? setInterval(() => {
947
646
  const turn = this.turn;
948
647
  if (turn === undefined)
949
648
  return;
950
- const elapsed = Date.now() - lastStandaloneNoticeAt;
649
+ const elapsed = Date.now() - lastProgressNoticeAt;
951
650
  if (elapsed < progressTimeoutMs)
952
651
  return;
953
- lastStandaloneNoticeAt = Date.now();
652
+ lastProgressNoticeAt = Date.now();
954
653
  const minutes = Math.max(1, Math.round((Date.now() - turn.startedAt) / 60_000));
955
654
  const status = turn.milestone ?? this.t.progressThinking;
956
- void this.adapter
957
- .sendText(this.target(msg), this.t.progressReminder(minutes, status))
958
- .catch(() => undefined);
655
+ // Edit the existing streaming card in place instead of sending a new
656
+ // message: the milestone is appended to the same card via the chunk
657
+ // stream, so progress updates never clutter the chat with new bubbles.
658
+ turn.chunks.push(`\n\n${this.t.progressReminder(minutes, status)}\n\n`);
959
659
  }, PROGRESS_WATCHDOG_CHECK_MS)
960
660
  : undefined;
961
661
  // End the chunk stream unconditionally: if `followup` / `whenIdle` /
@@ -1262,7 +962,11 @@ export class AgentRunner {
1262
962
  * time, and whether compaction is advisable given context-window usage.
1263
963
  */
1264
964
  async sendTurnStats(msg, outcome) {
1265
- if (outcome.model === undefined && outcome.inputTokens === undefined && outcome.outputTokens === undefined)
965
+ const hasStats = outcome.model !== undefined || outcome.inputTokens !== undefined || outcome.outputTokens !== undefined;
966
+ const hasResult = outcome.reason === "completed" && outcome.text !== "";
967
+ // No stats and no result to report — nothing to show (a bare stats card with
968
+ // only a duration would be noise).
969
+ if (!hasStats && !hasResult)
1266
970
  return;
1267
971
  const lines = [
1268
972
  this.t.taskStatsHeader(outcome.elapsedMs === undefined ? "—" : this.t.taskDuration(outcome.elapsedMs)),
@@ -1281,6 +985,14 @@ export class AgentRunner {
1281
985
  lines.push(this.t.taskStatsContext(`${pct}`, fmtTokens(outcome.contextWindow)));
1282
986
  lines.push(pct >= AgentRunner.COMPACT_THRESHOLD_PCT ? this.t.taskStatsCompactSuggest : this.t.taskStatsCompactOk);
1283
987
  }
988
+ // A finished task's answer must survive the end of the run: the live
989
+ // streaming card shows the output as it is produced, but the stats card is
990
+ // the durable post-task report. When the turn completed and produced text,
991
+ // append the result here instead of leaving a bare stats card.
992
+ if (hasResult) {
993
+ lines.push("");
994
+ lines.push(this.t.produced(truncate(outcome.text, 300)));
995
+ }
1284
996
  const card = { markdown: lines.join("\n") };
1285
997
  await this.adapter.sendCard(this.taskTarget(msg), card).catch(() => undefined);
1286
998
  }
@@ -1320,7 +1032,7 @@ export class AgentRunner {
1320
1032
  }
1321
1033
  case "dir": {
1322
1034
  if (command.path === undefined) {
1323
- await this.openMenu(target, msg, "workspace", ["root"]);
1035
+ await this.menu.openMenu(target, msg, "workspace", ["root"]);
1324
1036
  break;
1325
1037
  }
1326
1038
  const path = command.path;
@@ -1344,15 +1056,15 @@ export class AgentRunner {
1344
1056
  break;
1345
1057
  }
1346
1058
  case "chat": {
1347
- await this.openMenu(target, msg, "chat", ["root"]);
1059
+ await this.menu.openMenu(target, msg, "chat", ["root"]);
1348
1060
  break;
1349
1061
  }
1350
1062
  case "menu": {
1351
- await this.openMenu(target, msg, "root");
1063
+ await this.menu.openMenu(target, msg, "root");
1352
1064
  break;
1353
1065
  }
1354
1066
  case "settings": {
1355
- await this.openMenu(target, msg, "settings", ["root"]);
1067
+ await this.menu.openMenu(target, msg, "settings", ["root"]);
1356
1068
  break;
1357
1069
  }
1358
1070
  case "plugins": {
@@ -1384,7 +1096,7 @@ export class AgentRunner {
1384
1096
  break;
1385
1097
  }
1386
1098
  case "model": {
1387
- await this.openMenu(target, msg, "model", ["root"]);
1099
+ await this.menu.openMenu(target, msg, "model", ["root"]);
1388
1100
  break;
1389
1101
  }
1390
1102
  case "notify": {
@@ -1676,198 +1388,12 @@ export class AgentRunner {
1676
1388
  await this.disposeAgent();
1677
1389
  }
1678
1390
  listWorkspaces() {
1679
- const registry = this.ctx.get("workspaceRegistry");
1680
- const out = [];
1681
- const seen = new Set();
1682
- const add = (path, title) => {
1683
- if (path === "")
1684
- return;
1685
- const key = path.replace(/[\\/]+$/, "").toLowerCase();
1686
- if (seen.has(key))
1687
- return;
1688
- seen.add(key);
1689
- out.push({ path, title: title || path });
1690
- };
1691
- add(this.workDir, this.t.currentDir);
1692
- for (const w of registry?.list?.() ?? [])
1693
- add(w.path, w.title);
1694
- for (const p of this.config.workspaces)
1695
- add(p, p);
1696
- return out;
1697
- }
1698
- async openMenu(target, msg, menuId, stack = [], cardId) {
1699
- const items = await this.menuItems(menuId);
1700
- const options = items.map((i) => ({ id: i.id, label: i.label }));
1701
- options.push({ id: "menu:exit", label: this.t.menuExit });
1702
- if (stack.length > 0)
1703
- options.push({ id: "menu:back", label: this.t.menuBack });
1704
- // Determine columns per row based on menu type
1705
- // Workspace and chat lists use 1 column (full width), others use 2 columns
1706
- const columnsPerRow = (menuId === "workspace" || menuId === "chat") ? 1 : 2;
1707
- const { choice, messageId } = await this.adapter.promptChoice(target, {
1708
- title: this.menuTitle(menuId),
1709
- options,
1710
- columnsPerRow,
1711
- ...(menuId === "root" ? { sections: this.rootMenuSections(), footer: this.t.rootMenuFooter } : {}),
1712
- }, cardId);
1713
- if (choice === undefined)
1714
- return;
1715
- if (choice === "menu:exit") {
1716
- await this.adapter.closeMenu(messageId, this.t.menuClosed);
1717
- return;
1718
- }
1719
- if (choice === "menu:back") {
1720
- const parent = stack[stack.length - 1];
1721
- await this.openMenu(target, msg, parent, stack.slice(0, -1), messageId);
1722
- return;
1723
- }
1724
- const item = items.find((i) => i.id === choice);
1725
- if (item === undefined) {
1726
- // The tap belonged to a previous card generation (e.g. a rapid second
1727
- // tap on the old menu while the new one was still being redrawn). Don't
1728
- // silently swallow it: redraw the current menu so the chain stays usable.
1729
- await this.openMenu(target, msg, menuId, stack, messageId);
1730
- return;
1731
- }
1732
- const feedback = await item.onSelect(target, msg, messageId);
1733
- if (item.leaf === true) {
1734
- // Every leaf press must answer visibly: send the action's result feedback
1735
- // (if any) before returning to the root menu on the same card.
1736
- if (feedback !== undefined && feedback !== "") {
1737
- await this.adapter.sendText(target, feedback).catch(() => undefined);
1738
- }
1739
- await this.openMenu(target, msg, "root", [], messageId);
1740
- }
1741
- }
1742
- async menuItems(menuId) {
1743
- switch (menuId) {
1744
- case "root":
1745
- return [
1746
- { id: "workspace", label: this.t.menuWorkspaceAction, onSelect: (t, m, cardId) => this.openMenu(t, m, "workspace", ["root"], cardId) },
1747
- { id: "chat", label: this.t.menuChatAction, onSelect: (t, m, cardId) => this.openMenu(t, m, "chat", ["root"], cardId) },
1748
- { id: "status", label: this.t.menuStatusAction, leaf: true, onSelect: async (t) => { await this.showStatus(t); } },
1749
- { id: "task", label: this.t.menuTaskAction, leaf: true, onSelect: async (t) => { await this.showTasks(t); } },
1750
- { id: "history", label: this.t.menuHistoryAction, leaf: true, onSelect: async (t) => { await this.showHistory(t, 10); } },
1751
- { id: "goals", label: this.t.menuGoalsAction, leaf: true, onSelect: async (t) => { await this.showGoals(t); } },
1752
- { id: "schedule", label: this.t.menuScheduleAction, leaf: true, onSelect: async (t) => { await this.showSchedule(t); } },
1753
- { id: "compact", label: this.t.menuCompactAction, leaf: true, onSelect: async (t) => { await this.compact(t); } },
1754
- { id: "plugins", label: this.t.menuPluginsAction, leaf: true, onSelect: async (t) => { await this.showPlugins(t); } },
1755
- { id: "settings", label: this.t.menuSettingsAction, onSelect: (t, m, cardId) => this.openMenu(t, m, "settings", ["root"], cardId) },
1756
- ];
1757
- case "workspace": {
1758
- const workspaces = this.listWorkspaces();
1759
- return workspaces.map((w) => ({
1760
- id: `dir:${w.path}`,
1761
- label: `${w.path === this.workDir ? "● " : ""}${w.title}${w.title !== w.path ? ` (${w.path})` : ""}`,
1762
- leaf: true,
1763
- onSelect: async (t, m) => {
1764
- this.workDir = w.path;
1765
- await this.newChat(m);
1766
- return this.t.dirSwitched(w.path);
1767
- },
1768
- }));
1769
- }
1770
- case "chat": {
1771
- const binding = this.bindings.get(this.channel, this.chatKey);
1772
- const active = binding?.sessionId;
1773
- const hasWebMirror = binding?.webMirrorSessionId !== undefined;
1774
- const items = (await this.collectWorkdirSessions()).map(({ sessionId, title }) => {
1775
- const isMirrored = hasWebMirror && binding?.webMirrorSessionId === sessionId;
1776
- const mirrorIndicator = isMirrored ? ` ${this.t.webMirrorIndicator}` : "";
1777
- const activeIndicator = sessionId === active ? "●" : "○";
1778
- return {
1779
- id: `session:${sessionId}`,
1780
- label: `${activeIndicator} ${title || sessionId}${mirrorIndicator}`,
1781
- leaf: true,
1782
- onSelect: async (t, m) => {
1783
- await this.switchTo(sessionId, m.senderKey);
1784
- return this.t.sessionSwitched(title || sessionId);
1785
- },
1786
- };
1787
- });
1788
- if (items.length === 0) {
1789
- items.push({
1790
- id: "none:history",
1791
- label: this.t.noSessionsInWorkdir(this.workDir),
1792
- leaf: true,
1793
- onSelect: async () => this.t.noSessionsInWorkdir(this.workDir),
1794
- });
1795
- }
1796
- items.push({
1797
- id: "action:new",
1798
- label: this.t.menuNewChat,
1799
- leaf: true,
1800
- onSelect: async (t, m, messageId) => {
1801
- // Reuse the menu card for the confirm prompt so no stale card is left behind.
1802
- if (await this.confirmAction(t, this.t.confirmNewText, messageId)) {
1803
- await this.newChat(m);
1804
- return this.t.newChatDone;
1805
- }
1806
- return this.t.actionCancelled;
1807
- },
1808
- });
1809
- return items;
1810
- }
1811
- case "settings":
1812
- return [
1813
- { id: "model", label: this.t.menuSettingsModel, onSelect: (t, m, cardId) => this.openMenu(t, m, "model", ["settings", "root"], cardId) },
1814
- { id: "reasoning", label: this.t.menuSettingsReasoning, onSelect: (t, m, cardId) => this.openMenu(t, m, "reasoning", ["settings", "root"], cardId) },
1815
- { id: "notify", label: this.t.menuSettingsNotify, onSelect: (t, m, cardId) => this.openMenu(t, m, "notify", ["settings", "root"], cardId) },
1816
- { id: "progress", label: this.t.menuSettingsProgress, onSelect: (t, m, cardId) => this.openMenu(t, m, "progress", ["settings", "root"], cardId) },
1817
- { id: "language", label: this.t.menuSettingsLanguage, onSelect: (t, m, cardId) => this.openMenu(t, m, "language", ["settings", "root"], cardId) },
1818
- { id: "overview", label: this.t.menuSettingsOverview, leaf: true, onSelect: async (t) => { await this.showSettings(t); } },
1819
- ];
1820
- case "language":
1821
- return [
1822
- {
1823
- id: "lang:zh",
1824
- label: `${this.language === "zh" ? "● " : ""}${this.t.languageZh}`,
1825
- leaf: true,
1826
- onSelect: async (t, m) => { await this.setLanguage("zh", t, m); },
1827
- },
1828
- {
1829
- id: "lang:en",
1830
- label: `${this.language === "en" ? "● " : ""}${this.t.languageEn}`,
1831
- leaf: true,
1832
- onSelect: async (t, m) => { await this.setLanguage("en", t, m); },
1833
- },
1834
- ];
1835
- case "model":
1836
- return await this.modelMenuItems();
1837
- case "reasoning":
1838
- return this.reasoningMenuItems();
1839
- case "notify":
1840
- return [
1841
- { id: "full", label: this.t.notifyFull },
1842
- { id: "important", label: this.t.notifyImportant },
1843
- { id: "result", label: this.t.notifyResult },
1844
- ].map((o) => ({
1845
- id: `notify:${o.id}`,
1846
- label: `${this.notifyLevel === o.id ? "● " : ""}${o.label}`,
1847
- leaf: true,
1848
- onSelect: async (t, m) => {
1849
- await this.setNotifyLevel(o.id, t, m);
1850
- },
1851
- }));
1852
- case "progress": {
1853
- const presets = [
1854
- { id: "progress:0", label: this.t.progressOff, ms: 0 },
1855
- { id: "progress:120000", label: this.t.progressMinutes(2), ms: 2 * 60_000 },
1856
- { id: "progress:300000", label: this.t.progressMinutes(5), ms: 5 * 60_000 },
1857
- { id: "progress:600000", label: this.t.progressMinutes(10), ms: 10 * 60_000 },
1858
- { id: "progress:900000", label: this.t.progressMinutes(15), ms: 15 * 60_000 },
1859
- { id: "progress:1800000", label: this.t.progressMinutes(30), ms: 30 * 60_000 },
1860
- ];
1861
- return presets.map((o) => ({
1862
- id: o.id,
1863
- label: `${this.progressTimeoutMs === o.ms ? "● " : ""}${o.label}`,
1864
- leaf: true,
1865
- onSelect: async (t, m) => {
1866
- await this.setProgressTimeout(o.ms, t, m);
1867
- },
1868
- }));
1869
- }
1870
- }
1391
+ return listWorkspaces({
1392
+ registry: this.ctx.get("workspaceRegistry"),
1393
+ workDir: this.workDir,
1394
+ currentDirLabel: this.t.currentDir,
1395
+ workspaces: this.config.workspaces,
1396
+ });
1871
1397
  }
1872
1398
  async showStatus(target) {
1873
1399
  const agent = this.agent;
@@ -1918,6 +1444,7 @@ export class AgentRunner {
1918
1444
  catch {
1919
1445
  // Token meter unavailable: skip.
1920
1446
  }
1447
+ lines.push(this.t.progressSetting(this.progressTimeoutMs === 0 ? this.t.progressOff : this.t.progressMinutes(Math.round(this.progressTimeoutMs / 60_000))));
1921
1448
  await this.adapter.sendText(target, lines.join("\n"));
1922
1449
  }
1923
1450
  /** Extract the most recent turn's completion info from session events. */
@@ -1968,41 +1495,6 @@ export class AgentRunner {
1968
1495
  lines.push(this.t.stateDirField(this.config.stateDir ?? ".dsh-connect"));
1969
1496
  await this.adapter.sendText(target, lines.join("\n"));
1970
1497
  }
1971
- async modelMenuItems() {
1972
- const choices = await this.listModelChoices();
1973
- const current = this.defaultSelection();
1974
- const items = choices.map((c) => ({
1975
- id: `model:${c.provider}:${c.model}`,
1976
- label: `${c.provider === current.provider && c.model === current.model ? "● " : ""}${c.name}`,
1977
- leaf: true,
1978
- onSelect: async (t, m) => {
1979
- await this.setModel(c.provider, c.model, m);
1980
- return this.t.modelSet(c.name);
1981
- },
1982
- }));
1983
- if (items.length === 0) {
1984
- items.push({ id: "model:none", label: this.t.noModelsFound, leaf: true, onSelect: async () => this.t.noModelsFound });
1985
- }
1986
- return items;
1987
- }
1988
- async listModelChoices() {
1989
- const llm = this.ctx.get("llm");
1990
- const providers = llm?.listProviders?.() ?? [];
1991
- const out = [];
1992
- for (const p of providers) {
1993
- let models = [];
1994
- try {
1995
- models = (await llm?.listModels?.(p.id)) ?? [];
1996
- }
1997
- catch {
1998
- // Skip providers whose catalog cannot be listed.
1999
- }
2000
- for (const m of models) {
2001
- out.push({ provider: p.id, model: m.id, name: this.t.modelName(m.name || m.id, p.name || p.id) });
2002
- }
2003
- }
2004
- return out;
2005
- }
2006
1498
  async setModel(provider, model, msg) {
2007
1499
  const svc = this.ctx.get("agentDefaultModel");
2008
1500
  const cur = this.defaultSelection();
@@ -2013,37 +1505,6 @@ export class AgentRunner {
2013
1505
  });
2014
1506
  await this.newChat(msg);
2015
1507
  }
2016
- reasoningMenuItems() {
2017
- const current = this.defaultSelection();
2018
- const efforts = [
2019
- { id: "low", name: this.t.effortLow },
2020
- { id: "medium", name: this.t.effortMedium },
2021
- { id: "high", name: this.t.effortHigh },
2022
- ];
2023
- const items = [
2024
- {
2025
- id: "effort:default",
2026
- label: `${current.reasoningEffort === undefined ? "● " : ""}${this.t.effortDefault}`,
2027
- leaf: true,
2028
- onSelect: async (t, m) => {
2029
- await this.setReasoning(undefined, m);
2030
- return this.t.reasoningSet(this.t.effortDefault);
2031
- },
2032
- },
2033
- ];
2034
- for (const e of efforts) {
2035
- items.push({
2036
- id: `effort:${e.id}`,
2037
- label: `${e.id === current.reasoningEffort ? "● " : ""}${e.name}`,
2038
- leaf: true,
2039
- onSelect: async (t, m) => {
2040
- await this.setReasoning(e.id, m);
2041
- return this.t.reasoningSet(e.name);
2042
- },
2043
- });
2044
- }
2045
- return items;
2046
- }
2047
1508
  async setReasoning(effort, msg) {
2048
1509
  const svc = this.ctx.get("agentDefaultModel");
2049
1510
  const cur = this.defaultSelection();
@@ -2154,14 +1615,10 @@ export class AgentRunner {
2154
1615
  }
2155
1616
  /** Show the progress-interval picker (used by the `/progress` command). */
2156
1617
  async openProgressPicker(target, msg) {
2157
- const options = [
2158
- { id: "progress:0", label: this.t.progressOff },
2159
- { id: "progress:120000", label: this.t.progressMinutes(2) },
2160
- { id: "progress:300000", label: this.t.progressMinutes(5) },
2161
- { id: "progress:600000", label: this.t.progressMinutes(10) },
2162
- { id: "progress:900000", label: this.t.progressMinutes(15) },
2163
- { id: "progress:1800000", label: this.t.progressMinutes(30) },
2164
- ];
1618
+ const options = PROGRESS_PRESET_MS.map((ms) => ({
1619
+ id: `progress:${ms}`,
1620
+ label: ms === 0 ? this.t.progressOff : this.t.progressMinutes(ms / 60_000),
1621
+ }));
2165
1622
  const { choice } = await this.adapter.promptChoice(target, { title: this.t.progressMenuTitle, options });
2166
1623
  if (choice === undefined || !choice.startsWith("progress:"))
2167
1624
  return;
@@ -2304,18 +1761,7 @@ export class AgentRunner {
2304
1761
  await this.adapter.sendText(target, lines.join("\n"));
2305
1762
  }
2306
1763
  goalPhaseLabel(phase) {
2307
- switch (phase) {
2308
- case "active":
2309
- return this.t.goalPhaseActive;
2310
- case "paused":
2311
- return this.t.goalPhasePaused;
2312
- case "blocked":
2313
- return this.t.goalPhaseBlocked;
2314
- case "complete":
2315
- return this.t.goalPhaseComplete;
2316
- default:
2317
- return phase;
2318
- }
1764
+ return goalPhaseLabel(phase, this.t);
2319
1765
  }
2320
1766
  async showSchedule(target) {
2321
1767
  // Agent-level reminders (session `schedule` tool) + persistent chat-level