Repository navigation
Expand file tree
/
Copy pathclient.js
More file actions
5490 lines (5264 loc) · 244 KB
/
Copy pathclient.js
File metadata and controls
5490 lines (5264 loc) · 244 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* dsh-plugin-toolkit - Client half (v0.1.0)
*
* Personal quality-of-life toolkit for the DeepSeek Harness Web client.
*
* 1. workspacelessChat - when no session is selected and the runtime's own
* startup policy has nothing to connect (first run, no workspaces), the
* client ensures a default chat workspace exists and connects it, so the
* composer is live without the user picking a workspace. dsh's composer
* requires a blank session to belong to a workspace, so "workspaceless"
* is delivered as an auto-connected default workspace.
*
* 2. A settings card on the Web plugins page (slot `settings.plugin.item`,
* key `toolkit` - must equal the server-registered settings namespace).
* Clicking the card EXPANDS it into one half-width sub-card per
* optimization; each sub-card carries its own toggle and, for the ones
* that have options, a settings dialog of its own.
*/
window.__ModuleLoader__.load({
id: "dsh-plugin-toolkit",
factory: (require) => {
const exports = {};
const React = require("react");
const { useState, useEffect, useLayoutEffect, useRef, memo } = React;
const { jsx, jsxs } = require("react/jsx-runtime");
const primitives = require("@deepseek-ai/dsh-client-ui-primitives");
if (primitives && typeof primitives === "object") {
for (const key of Object.keys(primitives)) {
if (key.startsWith("Icon") && key.endsWith("Regular")) {
const base = key.slice(0, -7);
for (const suffix of ["12", "14", "16", "18", "20", "24", ""]) {
if (!primitives[base + suffix]) primitives[base + suffix] = primitives[key];
}
}
}
}
const {
Modal, projectUserText, JsonBlock, Tooltip,
FileTypeIcon, fileExtension, fileSizeText, diffTotals,
} = primitives;
const IconChevronDownOutline14 = primitives.IconChevronDownOutlineRegular || primitives.IconChevronDownOutline14 || (() => null);
const IconCopyOutline16 = primitives.IconCopyOutlineRegular || primitives.IconCopyOutline16 || (() => null);
const IconCheckOutline16 = primitives.IconCheckOutlineRegular || primitives.IconCheckOutline16 || (() => null);
const IconPersonalizationOutline16 = primitives.IconPersonalizationOutlineRegular || primitives.IconPersonalizationOutline16 || (() => null);
const IconNewChatOutline16 = primitives.IconNewChatOutlineRegular || primitives.IconNewChatOutline16 || (() => null);
const IconEditOutline16 = primitives.IconEditOutlineRegular || primitives.IconEditOutline16 || (() => null);
const IconClockOutline16 = primitives.IconClockOutlineRegular || primitives.IconClockOutline16 || (() => null);
const IconGlobeOutline14 = primitives.IconGlobeOutlineRegular || primitives.IconGlobeOutline14 || (() => null);
const IconBranchOutline16 = primitives.IconBranchOutlineRegular || primitives.IconBranchOutline16 || (() => null);
const IconApiOutline14 = primitives.IconApiOutlineRegular || primitives.IconApiOutline14 || (() => null);
const IconGaugeOutline16 = primitives.IconGaugeOutlineRegular || primitives.IconGaugeOutline16 || (() => null);
/** Fallback glyph for smartReply, so a primitive rename costs a blank card, not a crash. */
const IconSparklesOutline16 = primitives.IconSparklesOutlineRegular || primitives.IconSparklesOutline16 || IconGaugeOutline16;
/** Fallback model route; the live value comes from the settings section. */
const MODELS_PATH = "/api/toolkit/models";
function ClockIcon() {
return jsx("svg", {
width: 14, height: 14, viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true",
children: [
jsx("circle", { cx: 8, cy: 8, r: 6.5, stroke: "currentColor", strokeWidth: 1.3 }),
jsx("path", { d: "M8 4.5v3.5l2 1.5", stroke: "currentColor", strokeWidth: 1.3, strokeLinecap: "round" }),
],
});
}
function MessageIconActions(props) {
const { text, time, clock = "end", t, extraActions } = props;
const [copied, setCopied] = useState(false);
const copy = () => {
if (text === undefined) return;
// Feature-detect: `navigator.clipboard` is undefined outside a secure
// context, and a denied write rejects (Safari/Firefox permissions).
// Either way the button must not claim a copy that did not happen.
const clipboard = globalThis.navigator?.clipboard;
if (typeof clipboard?.writeText !== "function") return;
clipboard.writeText(text).then(
() => { setCopied(true); },
(error) => { console.warn("[toolkit] clipboard write failed:", error); },
);
};
useEffect(() => {
if (!copied) return;
const timer = setTimeout(() => { setCopied(false); }, 1500);
return () => { clearTimeout(timer); };
}, [copied]);
const formatTime = (ts) => {
if (!ts) return "";
const d = new Date(ts);
return isNaN(d.getTime()) ? "" : d.toTimeString().slice(0, 5);
};
const clockEl = time !== undefined ? jsxs("span", {
style: {
display: "inline-flex", alignItems: "center", gap: "4px",
fontSize: "12px", color: "var(--dsw-alias-label-tertiary, #9ca3af)",
},
children: [
jsx(ClockIcon, {}),
jsx("span", { children: formatTime(time) }),
],
}) : null;
return jsxs("div", {
style: {
display: "flex", alignItems: "center", gap: "6px",
marginTop: "2px",
},
children: [
clock === "start" ? clockEl : null,
extraActions,
text !== undefined ? jsx(Tooltip, {
label: copied ? (t ? t("msgCopied") : "已复制") : (t ? t("msgCopy") : "复制"),
side: "bottom",
children: jsx("button", {
type: "button",
onClick: copy,
style: {
flexShrink: 0,
display: "inline-flex", alignItems: "center", justifyContent: "center",
width: "24px", height: "24px", borderRadius: "6px", padding: 0,
font: "inherit",
color: "var(--dsw-alias-label-tertiary, #9ca3af)",
background: "transparent",
border: "1px solid transparent",
cursor: "pointer",
},
onMouseEnter: (event) => {
event.currentTarget.style.color = "var(--dsw-alias-label-primary, #f3f4f6)";
event.currentTarget.style.background = "rgba(148, 163, 184, 0.18)";
},
onMouseLeave: (event) => {
event.currentTarget.style.color = "var(--dsw-alias-label-tertiary, #9ca3af)";
event.currentTarget.style.background = "transparent";
},
children: copied ? jsx(IconCheckOutline16, {}) : jsx(IconCopyOutline16, {}),
}),
}) : null,
clock === "end" ? clockEl : null,
],
});
}
// ── locale ────────────────────────────────────────────────────────────────
const NS = "toolkit";
const zh = {
cardTitle: "DSH-Toolkit",
cardDesc: "实用小优化集合",
cardOpen: "展开 DSH-Toolkit 配置",
cardStatus: "已启用 {enabled}/{total}",
quickOn: "全部启用",
quickOff: "全部关闭",
close: "关闭",
done: "完成",
statusSaved: "已保存",
statusReadOnly: "设置只读(Host 文档不可写)",
statusMemory: "设置仅保存在本进程内存",
statusLoading: "正在读取设置…",
optChatTitle: "免选工作区对话",
optChatShort: "打开即可对话,无需先选工作区",
optChatDesc:
"打开页面且没有可自动接入的工作区时,自动接入默认 chat 工作区,无需先选择工作区即可直接对话(即其他 agent 的默认项目行为)。",
chatPathLabel: "默认对话目录",
chatPathHint:
"留空使用 <DSH_HOME>/chat。目录由服务端自动创建;修改后下一次自动接入即生效。",
chatPathSave: "保存",
toggleOn: "已开启",
toggleOff: "已关闭",
saveError: "保存失败",
aiError: "AI 处理失败",
aiBatchError: "AI 批量处理失败",
unsavedChanges: "有未保存的修改",
optEditTitle: "编辑上一条并重发",
optEditShort: "改上一条消息,重新发送",
optEditDesc:
"点击上一条消息旁的编辑按钮,直接修改内容并发送——该消息之后的内容会被移除,以新内容继续。",
optEnableLabel: "启用此功能",
editOpen: "编辑",
editHintInline: "Ctrl+Enter 发送 · Esc 取消",
editSave: "发送",
editCancel: "取消",
editBusy: "发送中…",
editUnsupported: "当前 host 不支持改写",
editError: "重发失败",
msgCopy: "复制",
msgCopied: "已复制",
msgExtraBlock: "附加内容",
msgReferenceSummary: "引用了会话 {labels}",
msgReferenceSeparator: "、",
msgTruncated: "已截断({total} 项)",
optActivityTitle: "查看活动",
optActivityShort: "最近对话按运行中 + 日期分组",
optActivityDesc:
"在工作区侧栏添加一个「活动」图标:点击后侧栏会话列表就地重排——运行中的对话置于顶部「优先级」组,其余按 今天/昨天/星期X/更早 分组;再点一次恢复原分组。",
activityOpen: "查看活动",
activityBtnActive: "按工作区分组查看",
activityBtnInactive: "会话活动视图(按时间 / 进行中分组)",
activityPriority: "进行中",
activityToday: "今天",
activityYesterday: "昨天",
activityEarlier: "更早",
activityEmpty: "暂无会话",
activityRunning: "正在运行",
activityDone: "已完成",
activityNewSession: "新会话",
activityJustNow: "刚刚",
activityMinutesAgo: "{n} 分钟前",
activityHoursAgo: "{n} 小时前",
activityYesterdayAt: "昨天 {time}",
optI18nTitle: "斜杠菜单中文描述",
optI18nShort: "/ 菜单的命令与技能描述显示中文",
optI18nDesc:
"将「/」菜单里 host 返回的命令/技能英文描述按内置词典译为中文(仅界面显示,条目名与执行不变)。未收录的文案保持原文;dsh 更新改写文案后词典未命中会自动回退英文。仅在界面语言为中文时生效。",
optReportTitle: "改动报告",
optReportShort: "回合结束后汇总本轮文件改动",
optReportDesc:
"每回合结束后,若本轮有文件改动(edit / write / str_replace_editor),在回合尾部显示改动报告卡片:文件数与 +N -M 汇总、按文件行数清单(点击打开文件)、审核按钮弹出完整 diff。不追踪 bash 内的文件写入;不含撤销功能。",
optSessionTitle: "OpenCode 会话头",
optSessionShort: "为 OpenCode Go 请求补上 x-opencode-session",
optSessionDesc:
"OpenCode Go 要求每个请求携带 x-opencode-session(每段对话一个稳定会话 ID),缺失会被 400 拒绝。开启后,发往 opencode.ai(Go/Zen)的请求会自动带上当前对话的会话 ID,用于服务端路由与提示词缓存。100% 插件侧实现,不改 dsh 源码、不影响一键升级;User-Agent 已由 dsh 自身标识满足。",
optModelsTitle: "模型能力",
optModelsShort: "同步模型清单、补全上下文与图像能力",
optModelsDesc:
"把每条 llm-pi-ai 路由端点实时的模型清单与已安装目录合并:新增模型立即出现在选择器中、无需重启。缺失的上下文/输出上限由 models.dev 注册表与同族模型补全;图像输入按其他已注册 provider 的同 id 声明借用;也可在此强制某个模型支持或不支持图像。",
optReplyTitle: "建议回复",
optReplyShort: "输入框灰字提示建议回复,Tab 填入",
optReplyDesc:
"每轮回答结束后,把最后一条 AI 回复交给一个小模型,生成 1-3 条「你接下来可能说的话」,以灰色文字显示在输入框里;按 Tab 填入,↑↓ 切换候选,Esc 取消。只在输入框为空且本轮空闲时出现,打字即消失。调用失败会静默隐藏,不会打断正常输入。",
smartReplyKeyLabel: "建议模型",
smartReplyKeyPlaceholder: "留空则用该路由的第一个模型",
smartReplyKeyHint: "建议回复每轮都会调用一次,建议填一个便宜、快的小模型;留空时复用「模型能力」的路由与密钥。",
smartReplyCountLabel: "候选条数",
smartReplyRouteLabel: "调用路由",
smartReplyRouteHint: "留空则复用「模型能力」的主路由 {route}。",
smartReplyRouteDefault: "默认({route})",
smartReplyLanguageLabel: "回复语言",
smartReplyLanguageFollow: "跟随界面语言",
smartReplyLanguageHint: "留空则每次请求跟随界面语言。",
smartReplyTimeoutLabel: "超时(秒)",
smartReplyLearnLabel: "学习我的表达习惯",
smartReplyLearnHint:
"状态文件只留在本机;一小段聚合风格提示(最多 2 条示例)会随建议请求发给你配置的建议模型。可随时关闭。",
smartReplyHabitStats: "已从 {turns} 轮对话学习表达习惯,接受过 {accepted} 条建议",
smartReplyRuntimeHint: "建议调用走 DSH 运行时,任何已配置路由(包括 anthropic-messages)都可使用。",
smartReplyRouteWarning: "该路由不能用于建议调用,请改选可用路由。",
hint: "Tab 填入 · {tab}/{total}",
modelsKeyLabel: "OpenCode API Key",
modelsKeyPlaceholder: "粘贴 opencode.ai 的 API Key",
modelsKeyHint: "留空时回退读取环境变量 {env}",
modelsInfoTitle: "模型清单与能力",
modelsInfoDesc:
"候选来自该路由({route})当前已配置的模型,保存后立即生效。要新增模型请到 dsh 自己的模型设置里点「获取可用模型」:本插件把该动作接到了实时端点,拉到的清单由你确认后再写入目录。",
modelsVisionLabel: "强制视觉模型",
modelsVisionHint: "从该路由的已知模型里搜索选择;加入视觉会自动从纯文本里移除,保存后立即生效。",
modelsTextOnlyLabel: "强制纯文本模型",
modelsTextOnlyHint: "从该路由的已知模型里搜索选择;加入纯文本会自动从视觉里移除,保存后立即生效。",
modelsPickSearch: "搜索模型 id 或名称…",
modelsPickLoading: "正在加载模型列表…",
modelsPickFailed: "模型列表加载失败:{message}",
modelsPickUnavailable: "模型列表加载器未注入(插件接线有问题)",
modelsPickEmpty: "没有匹配的模型",
modelsPickNoOptions: "该路由暂无已配置的模型:请到 dsh 的模型设置里添加,或直接输入 id 后回车加入。",
modelsPickAddFree: "回车把 “{id}” 直接加入",
modelsPickRemove: "移除 {id}",
modelsPickCount: "已选 {count}",
modelsSave: "保存",
slashI18nSearchPlaceholder: "搜索命令名或描述关键词…",
slashI18nFilterAll: "全部",
slashI18nFilterUntranslated: "仅未翻译",
slashI18nFilterCommand: "命令",
slashI18nFilterHint: "提示",
slashI18nFilterSkill: "技能",
slashI18nStats: "共 {total} 项 · {translated} 项已翻译 · {untranslated} 项未翻译",
slashI18nBatchAi: "一键 AI 翻译",
slashI18nBatchAiOptimize: "一键翻译并归纳优化",
slashI18nBatchAiLoading: "AI 翻译中 ({n})…",
slashI18nBatchAiOptimizeLoading: "AI 归纳中 ({n})…",
slashI18nSave: "保存翻译",
slashI18nSavedNotice: "翻译已保存并即时生效",
slashI18nPlaceholderTranslation: "请输入中文翻译或点击 AI 操作…",
slashI18nAiButton: "AI 翻译",
slashI18nAiOptimize: "翻译并归纳",
slashI18nResetButton: "恢复默认",
slashI18nTagBuiltin: "内置",
slashI18nTagCustom: "自定义",
slashI18nTagLong: "长文案",
slashI18nEmptyFilter: "没有匹配的命令或技能",
reportTitle: "已编辑 {count} 个文件",
reportTitleOne: "已编辑 1 个文件",
reportMore: "再显示 {count} 个文件",
reportShowFolder: "在文件夹中显示",
reportOpenAria: "打开 {name}",
};
const en = {
cardTitle: "DSH-Toolkit",
cardDesc: "Practical small optimizations",
cardOpen: "Expand DSH-Toolkit configuration",
cardStatus: "{enabled}/{total} on",
quickOn: "Enable all",
quickOff: "Disable all",
close: "Close",
done: "Done",
statusSaved: "Saved",
statusReadOnly: "Settings read-only (host document not writable)",
statusMemory: "Settings kept in local memory only",
statusLoading: "Loading settings…",
optChatTitle: "Chat without picking a workspace",
optChatShort: "Chat right away without picking a workspace",
optChatDesc:
"When the page opens and no workspace can be auto-connected, connect the default chat workspace automatically so conversation works immediately.",
chatPathLabel: "Default chat directory",
chatPathHint:
"Empty uses <DSH_HOME>/chat. The server creates the directory; edits apply from the next auto-connect.",
chatPathSave: "Save",
toggleOn: "On",
toggleOff: "Off",
saveError: "Save failed",
aiError: "AI processing failed",
aiBatchError: "AI batch processing failed",
unsavedChanges: "Unsaved changes",
optEditTitle: "Edit & resend last message",
optEditShort: "Edit the last message and send again",
optEditDesc:
"Click the edit button next to the last message, change the content, and send — everything after that message is removed and the model continues from the edited content.",
optEnableLabel: "Enable this feature",
editOpen: "Edit",
editHintInline: "Ctrl+Enter to send · Esc to cancel",
editSave: "Send",
editCancel: "Cancel",
editBusy: "Sending…",
editUnsupported: "This host does not support rewriting",
editError: "Resend failed",
msgCopy: "Copy",
msgCopied: "Copied",
msgExtraBlock: "Extra content",
msgReferenceSummary: "Referenced sessions: {labels}",
msgReferenceSeparator: ", ",
msgTruncated: "Truncated ({total} items)",
optActivityTitle: "View activity",
optActivityShort: "Recent conversations grouped by running + date",
optActivityDesc:
"Add an activity icon to the workspace sidebar: clicking it re-sorts the sidebar's conversation list in place - running conversations in a leading Priority group, then history grouped by Today / Yesterday / Weekday / Earlier; click again to restore.",
activityOpen: "View activity",
activityBtnActive: "Group by workspace",
activityBtnInactive: "Activity view (running first, grouped by day)",
activityPriority: "In progress",
activityToday: "Today",
activityYesterday: "Yesterday",
activityEarlier: "Earlier",
activityEmpty: "No conversations yet",
activityRunning: "Running",
activityDone: "Done",
activityNewSession: "New conversation",
activityJustNow: "just now",
activityMinutesAgo: "{n} min ago",
activityHoursAgo: "{n} h ago",
activityYesterdayAt: "Yesterday {time}",
optI18nTitle: "Chinese slash-menu descriptions",
optI18nShort: "Show / menu command & skill descriptions in Chinese",
optI18nDesc:
"Translate the English descriptions the host returns for '/' menu commands and skills into Chinese via a built-in dictionary (display only; names and execution unchanged). Unlisted text stays as-is, and a dsh update that rewords a description falls back to English until the dictionary catches up. Applies only while the UI language is Chinese.",
optReportTitle: "Change report",
optReportShort: "Per-turn file-change summary at the turn tail",
optReportDesc:
"After each turn, if it changed files (edit / write / str_replace_editor), a codex-style report card renders at the turn tail: file count with +N -M totals, a per-file line-count list (click opens the file), and a Review button opening the full diff. bash-side file writes are not tracked; undo is out of scope.",
optSessionTitle: "OpenCode session header",
optSessionShort: "Send x-opencode-session on OpenCode Go requests",
optSessionDesc:
"OpenCode Go requires x-opencode-session on every request (one stable session id per conversation) and 400-rejects requests without it. When enabled, requests served by opencode.ai (Go/Zen) endpoints automatically carry the current conversation's session id, which the server uses for routing and prompt caching. 100% plugin-side: no dsh source changes, no upgrade friction; the user-agent requirement is already satisfied by dsh's own attribution headers.",
optModelsTitle: "Model capability",
optModelsShort: "Sync the model list, capacities and image support",
optModelsDesc:
"Merge every llm-pi-ai route's live endpoint listing over the installed catalog: new models become selectable immediately, with no restart. Missing context/output limits are filled from the models.dev registry and from sized siblings; image input is borrowed from any other registered provider that declares the same id multimodal, and a model can be forced vision-capable or text-only here.",
optReplyTitle: "Suggested reply",
optReplyShort: "Ghost-text suggested reply in the composer, Tab to fill",
optReplyDesc:
"After each answer settles, the last AI reply is handed to a small model that drafts a few things you might say next, shown as gray text in the composer. Tab fills it, ↑↓ switch candidate, Esc dismisses. It appears only while the composer is empty and the turn is idle, and disappears as soon as you type. A failed call hides silently and never blocks normal input.",
smartReplyKeyLabel: "Suggestion model",
smartReplyKeyPlaceholder: "Empty uses the route's first model",
smartReplyKeyHint: "Suggestions are requested once per turn — name a cheap, fast model here. Empty reuses Model capability's route and key.",
smartReplyCountLabel: "Candidates",
smartReplyRouteLabel: "Calling route",
smartReplyRouteHint: "Empty reuses Model capability's primary route ({route}).",
smartReplyRouteDefault: "Default ({route})",
smartReplyLanguageLabel: "Reply language",
smartReplyLanguageFollow: "Follow UI language",
smartReplyLanguageHint: "Empty follows the UI language per request.",
smartReplyTimeoutLabel: "Timeout (seconds)",
smartReplyLearnLabel: "Learn my phrasing",
smartReplyLearnHint:
"The state file stays on this machine; a short aggregate style hint (up to 2 examples) rides in the suggestion request sent to your configured suggestion model. Turn it off anytime.",
smartReplyHabitStats: "Learned your phrasing from {turns} turns; {accepted} accepted suggestions so far",
smartReplyRuntimeHint: "Suggestions go through the DSH runtime, so any configured route works — including anthropic-messages.",
smartReplyRouteWarning: "This route cannot serve suggestion calls; pick a usable route.",
hint: "Tab to fill · {tab}/{total}",
modelsKeyLabel: "OpenCode API key",
modelsKeyPlaceholder: "Paste your opencode.ai API key",
modelsKeyHint: "Empty falls back to the {env} environment variable",
modelsInfoTitle: "Model list and capabilities",
modelsInfoDesc:
"Candidates are the models already configured on this route ({route}); changes apply on save. To adopt new models, use \"fetch available models\" in dsh's own model settings — this plugin wires that action to the live endpoint, and the fetched list is yours to confirm before it is written.",
modelsVisionLabel: "Force vision models",
modelsVisionHint: "Search and pick from the route's known models; adding one here removes it from text-only. Applies on save.",
modelsTextOnlyLabel: "Force text-only models",
modelsTextOnlyHint: "Search and pick from the route's known models; adding one here removes it from vision. Applies on save.",
modelsPickSearch: "Search model id or name…",
modelsPickLoading: "Loading model list…",
modelsPickFailed: "Could not load the model list: {message}",
modelsPickUnavailable: "Model-list loader was not injected (plugin wiring bug)",
modelsPickEmpty: "No matching model",
modelsPickNoOptions: "This route has no configured model yet — add one in dsh's model settings, or type an id and press Enter.",
modelsPickAddFree: "Press Enter to add “{id}”",
modelsPickRemove: "Remove {id}",
modelsPickCount: "{count} selected",
modelsSave: "Save",
slashI18nSearchPlaceholder: "Search command or description...",
slashI18nFilterAll: "All",
slashI18nFilterUntranslated: "Untranslated",
slashI18nFilterCommand: "Commands",
slashI18nFilterSkill: "Skills",
slashI18nStats: "{total} total · {translated} translated · {untranslated} untranslated",
slashI18nBatchAi: "Batch AI Translate",
slashI18nBatchAiOptimize: "Translate & Summarize",
slashI18nBatchAiLoading: "Translating ({n})...",
slashI18nBatchAiOptimizeLoading: "Summarizing ({n})...",
slashI18nSave: "Save Translations",
slashI18nSavedNotice: "Translations saved and active immediately",
slashI18nPlaceholderTranslation: "Enter translation or click AI action...",
slashI18nAiButton: "AI Translate",
slashI18nAiOptimize: "Summarize",
slashI18nResetButton: "Reset",
slashI18nTagBuiltin: "Built-in",
slashI18nTagCustom: "Custom",
slashI18nTagLong: "Long",
slashI18nEmptyFilter: "No matching commands or skills",
reportTitle: "{count} files edited",
reportTitleOne: "1 file edited",
reportMore: "Show {count} more files",
reportShowFolder: "Show in folder",
reportOpenAria: "Open {name}",
};
/**
* The active UI locale, normalized to a tag this bundle can format with:
* "zh" or "en". The locale service is the source of truth; the document
* language is the fallback for the moment before it boots, and zh is the
* final default (this plugin's primary audience).
* @returns {"zh" | "en"}
*/
function activeLocaleTag() {
const fromService = localeFace?.getSnapshot?.()?.active;
const raw = fromService ?? (typeof document === "undefined" ? undefined : document.documentElement?.lang);
if (raw === undefined || raw === null || String(raw) === "") return "zh";
return String(raw).toLowerCase().startsWith("zh") ? "zh" : "en";
}
/**
* Translate one toolkit key outside React.
*
* The activity view builds its DOM by hand (it re-sorts the host's own
* sidebar list in place), so it has no `t` prop from the slot system — but
* that is no reason for it to hardcode Chinese into an English UI. This
* resolves through the same dictionaries every component uses, with the
* same "a miss falls back" behaviour.
* @param {string} key - key in the toolkit dictionary.
* @param {Record<string, string | number>} [params] - `{name}` substitutions.
* @returns {string} the localized string.
*/
function toolkitText(key, params) {
const tag = activeLocaleTag();
const dict = tag === "en" ? en : zh;
let text = dict[key] ?? zh[key] ?? key;
if (params !== undefined) {
for (const [name, value] of Object.entries(params)) {
text = text.split("{" + name + "}").join(String(value));
}
}
return text;
}
// ── editLastMessage: edit the last user message and resend in place ──────
/**
* Extract the plain text and image references of a user message node's
* content blocks (mirrors the stock bubble's contentParts read).
*/
function userTextOf(content) {
let text = "";
const images = [];
const files = [];
const rest = [];
for (const block of content || []) {
if (block?.type === "text" && typeof block.text === "string") text += block.text;
else if (block?.type === "image" && block.attachment !== undefined) images.push({ attachment: block.attachment });
else if (block?.type === "file" && block.attachment !== undefined) files.push(block.attachment);
else rest.push(block);
}
return { text, images, files, rest };
}
/**
* One `truncatedLabel` function per `t`. JsonBlock memoizes its rendered
* string on the identity of this prop, so handing it a fresh arrow on
* every render would re-run the truncation on every render.
*/
const truncatedLabels = new WeakMap();
function truncatedLabelFor(t) {
let label = truncatedLabels.get(t);
if (label === undefined) {
label = (total) => t("msgTruncated", { total });
truncatedLabels.set(t, label);
}
return label;
}
/**
* The last user node's seq in one chat snapshot. The scan runs backwards
* from the tail, so memoizing per snapshot turns what would be an
* O(messages) walk per user bubble — O(messages²) per update — into one
* walk per snapshot.
*/
const lastUserSeqCache = new WeakMap();
function lastUserSeq(snapshot) {
const cached = lastUserSeqCache.get(snapshot);
if (cached !== undefined) return cached;
let seq = -1;
for (let index = snapshot.order.length - 1; index >= 0; index -= 1) {
const candidate = snapshot.nodes.get(snapshot.order[index]);
if (candidate?.kind === "user") {
seq = candidate.data.seq;
break;
}
}
lastUserSeqCache.set(snapshot, seq);
return seq;
}
/**
* User-message renderer for the editLastMessage optimization: shadows the
* stock `user` node renderer (slot priority -1 vs stock 0) and renders the
* bubble with this package's own chrome (inline styles over the shared CSS
* variables), adding a pencil edit action to the actions row when this is
* the LAST user message and the optimization is enabled. Clicking the
* pencil turns the bubble into an inline editor — no modal.
*
* `memo` matches the stock renderer: this shadows EVERY user node, so an
* unmemoized replacement would re-render every bubble on every store
* change.
*/
const ToolkitUserMessageNodeView = memo(function ToolkitUserMessageNodeView(props) {
const { node, renderMessageImages, openFile, openSkill, t, useChat, sessionId, editLast } = props;
const data = node.data;
const enabled = editLast?.isEnabled?.() !== false;
const { text, images, files, rest } = userTextOf(data.content);
const refs = data.referenceLabels ?? [];
const skillNames = data.skillNames ?? [];
const truncated = truncatedLabelFor(t);
const showBubble = text !== "" || rest.length > 0;
// Only the LAST user message offers the edit action.
const tailSeq = useChat(lastUserSeq);
// Offering the pencil when the host cannot rewrite guarantees a click
// that can only fail; require the capability up front.
const canRewrite = typeof editLast?.sessions?.binding === "function";
const isTail = enabled && canRewrite && tailSeq === data.seq && text !== "";
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const editRef = useRef(null);
const session = editLast?.sessions?.binding?.(sessionId)?.session;
const openEdit = () => {
setDraft(text);
setError(null);
setEditing(true);
};
const cancelEdit = () => {
if (busy) return;
setEditing(false);
setError(null);
};
const save = async () => {
const next = draft.trim();
if (next === "" || busy) return;
setBusy(true);
setError(null);
try {
if (session === undefined || typeof session.rewrite !== "function") {
setError(t("editUnsupported"));
return;
}
const result = await session.rewrite(data.seq, [{ type: "text", text: next }]);
if (result.ok) {
setEditing(false);
} else {
setError(result.error?.message ?? t("editError"));
}
} catch (cause) {
setError(cause?.message ?? String(cause));
} finally {
setBusy(false);
}
};
// The bubble column shared by the read view and the inline editor.
const bubbleColumnStyle = {
display: "flex", flexDirection: "column", alignItems: "flex-end", gap: "8px",
minWidth: 0, maxWidth: "min(calc(var(--dsh-chat-content-width, 748px) * 0.702), 82%)",
};
const bubbleStyle = {
maxWidth: "100%", background: "var(--dsw-specific-bubble)", borderRadius: "22px",
padding: "10px 16px", fontSize: "var(--dsh-content-font-size, 14px)",
lineHeight: "calc(22px + var(--dsh-content-font-delta, 0px))",
color: "var(--dsw-alias-label-primary)", whiteSpace: "pre-wrap", wordBreak: "break-word",
};
const pencil = isTail && !editing ? jsx("button", {
type: "button",
"aria-label": t("editOpen"),
title: t("editOpen"),
onClick: openEdit,
style: {
flexShrink: 0,
display: "inline-flex", alignItems: "center", justifyContent: "center",
width: "24px", height: "24px", borderRadius: "6px", padding: 0,
font: "inherit",
color: "var(--dsw-alias-label-tertiary, #9ca3af)",
background: "transparent",
border: "1px solid transparent",
cursor: "pointer",
},
onMouseEnter: (event) => {
event.currentTarget.style.color = "var(--dsw-alias-label-primary, #f3f4f6)";
event.currentTarget.style.background = "rgba(148, 163, 184, 0.18)";
},
onMouseLeave: (event) => {
event.currentTarget.style.color = "var(--dsw-alias-label-tertiary, #9ca3af)";
event.currentTarget.style.background = "transparent";
},
children: jsx(IconEditOutline16, {}),
}) : null;
// Inline edit view: the bubble column becomes an editor (same chrome),
// with a small action row (hint + cancel + regenerate) beneath it.
if (editing) {
const lines = Math.min(14, Math.max(3, draft.split("\n").length + 1));
return jsxs("div", {
style: { display: "flex", flexDirection: "column", alignItems: "flex-end", gap: "6px" },
children: [
jsx("div", {
style: { ...bubbleColumnStyle, width: "100%" },
children: jsx("textarea", {
ref: editRef,
value: draft,
disabled: busy,
spellCheck: false,
rows: lines,
autoFocus: true,
onChange: (event) => { setDraft(event.target.value); },
onKeyDown: (event) => {
if (event.key === "Escape") {
event.preventDefault();
cancelEdit();
return;
}
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
void save();
}
},
onFocus: (event) => {
const el = event.currentTarget;
el.setSelectionRange(el.value.length, el.value.length);
},
"aria-label": t("editOpen"),
style: {
...bubbleStyle,
width: "100%", boxSizing: "border-box",
font: "inherit", resize: "none", outline: "none",
border: "1px solid var(--dsw-alias-state-business-primary, #2563eb)",
whiteSpace: "pre-wrap",
},
}),
}),
jsxs("div", {
style: { display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap", justifyContent: "flex-end" },
children: [
error !== null ? jsx("span", {
role: "alert",
style: { fontSize: "12.5px", lineHeight: 1.5, color: "var(--dsw-alias-state-error-primary, #ef4444)" },
children: error,
}) : null,
error === null ? jsx("span", {
style: { fontSize: "11.5px", lineHeight: 1.5, color: "var(--dsw-alias-label-tertiary, #9ca3af)" },
children: t("editHintInline"),
}) : null,
jsx("button", {
type: "button",
disabled: busy,
onClick: cancelEdit,
style: {
height: "28px", padding: "0 12px", borderRadius: "14px", fontSize: "12px",
lineHeight: "18px", font: "inherit", cursor: busy ? "default" : "pointer",
color: "var(--dsw-alias-label-secondary, #d1d5db)",
background: "transparent",
border: "1px solid var(--dsw-alias-border-l2, #333)",
},
children: t("editCancel"),
}),
jsx("button", {
type: "button",
disabled: busy || draft.trim() === "",
onClick: () => { void save(); },
style: {
height: "28px", padding: "0 12px", borderRadius: "14px", fontSize: "12px",
lineHeight: "18px", font: "inherit", cursor: busy || draft.trim() === "" ? "default" : "pointer",
color: "var(--dsw-alias-label-primary-foreground, #fff)",
background: "var(--dsw-alias-button-primary-fill, #2563eb)",
border: "none",
opacity: busy || draft.trim() === "" ? 0.4 : 1,
},
children: busy ? t("editBusy") : t("editSave"),
}),
],
}),
],
});
}
return jsxs("div", {
style: { display: "flex", flexDirection: "column", alignItems: "flex-end", gap: "6px" },
children: [
jsxs("div", {
style: bubbleColumnStyle,
children: [
renderMessageImages({ images, align: "end" }),
files.length > 0 ? jsx("div", {
style: { display: "flex", flexWrap: "wrap", gap: "6px", justifyContent: "flex-end" },
children: files.map((file, index) => jsxs("span", {
key: "file:" + index,
title: file.name,
style: {
display: "inline-flex", alignItems: "center", gap: "6px",
maxWidth: "100%", padding: "4px 8px", borderRadius: "8px",
fontSize: "12px", color: "var(--dsw-alias-label-secondary, #d1d5db)",
background: "var(--dsw-alias-bg-layer-2, #1e1e1e)",
border: "1px solid var(--dsw-alias-border-l2, #333)",
},
children: [
jsx(FileTypeIcon, { path: file.name }),
jsx("span", {
style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
children: file.name,
}),
jsx("span", {
style: { color: "var(--dsw-alias-label-tertiary, #9ca3af)" },
// fileSizeText(undefined) falls through every branch and
// renders "NaNGB", which is truthy and survives the join.
children: [
fileExtension(file.name).toUpperCase().slice(0, 8),
typeof file.bytes === "number" ? fileSizeText(file.bytes) : "",
].filter(Boolean).join(" "),
}),
],
}, "file:" + index)),
}) : null,
showBubble ? jsx("div", {
style: bubbleStyle,
children: [
projectUserText(text, refs, skillNames, "skill", { openFile, openSkill }),
...rest.map((block, index) => jsx(JsonBlock, {
key: index,
label: t("msgExtraBlock"),
payload: block,
truncatedLabel: truncated,
})),
],
}) : null,
refs.length > 0 ? jsx("div", {
style: {
color: "var(--dsw-alias-label-tertiary)",
fontSize: "var(--dsh-content-font-size-secondary, 13px)",
lineHeight: "calc(18px + var(--dsh-content-font-delta-secondary, 0px))",
},
children: t("msgReferenceSummary", { labels: refs.join(t("msgReferenceSeparator")) }),
}) : null,
],
}),
jsx(MessageIconActions, {
text,
time: data.time,
clock: "start",
t,
extraActions: pencil,
}),
],
});
});
// ── viewActivity: workspace-header activity toggle (in-sidebar re-sort) ──
/**
* Install the viewActivity optimization.
* 100% non-invasive: attaches a clock toggle button to the sidebar header (.r_*_headerActions),
* and when active, swaps the native tree body with a cleanly formatted, live-reactive
* activity view grouped by Priority (Running), Today, Yesterday, Weekday, and Earlier.
* @param {{ workspaces: any, sessions: any }} services - injected runtime faces.
* @param {any} scope - bound settings scope, or undefined when unavailable.
* @returns {() => void} disposer for the effect teardown.
*/
function installActivityView(services, scope, ctx) {
if (typeof document === "undefined" || typeof document.querySelector !== "function") {
return () => {};
}
const workspaces = services?.workspaces ?? services?.get?.("workspaces");
const sessions = services?.sessions ?? services?.get?.("sessions");
const readStored = (key, fallback) => {
try { const stored = localStorage.getItem(key); return stored === null ? fallback : stored; }
catch { return fallback; }
};
const writeStored = (key, value) => {
try { localStorage.setItem(key, value); } catch { /* private mode / no storage */ }
};
let active = readStored("dsh:activity-view-active", "false") === "true";
let disposed = false;
let treeDirty = true;
let syncScheduled = false;
const readValue = () => {
if (scope === undefined) return undefined;
const snap = scope.getSnapshot?.();
return snap?.status === "ready" ? snap.value : undefined;
};
const isEnabled = () => {
const val = readValue();
return val?.optimizations?.viewActivity !== false;
};
const escapeHtml = (str) =>
String(str ?? "")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
const pad = (n) => String(n).padStart(2, "0");
/** Local-midnight start of the calendar day containing a timestamp. */
const dayStartOf = (ts) => {
const day = new Date(ts);
day.setHours(0, 0, 0, 0);
return day.getTime();
};
const formatTime = (ts) => {
if (!ts) return "";
const now = Date.now();
const diffMs = now - ts;
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return toolkitText("activityJustNow");
if (diffMin < 60) return toolkitText("activityMinutesAgo", { n: diffMin });
const d = new Date(ts);
const dayStart = dayStartOf(ts);
const todayStart = dayStartOf(now);
const diffHours = Math.floor(diffMs / 3600000);
if (dayStart === todayStart && diffHours < 24) {
return toolkitText("activityHoursAgo", { n: diffHours });
}
const yesterday = new Date(todayStart);
yesterday.setDate(yesterday.getDate() - 1);
if (dayStart === yesterday.getTime()) {
return toolkitText("activityYesterdayAt", { time: `${pad(d.getHours())}:${pad(d.getMinutes())}` });
}
return `${d.getMonth() + 1}/${d.getDate()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
/**
* Locale-aware weekday name. Intl replaces a hardcoded Chinese table, so
* an English UI gets "Wednesday" instead of "星期三".
*/
const weekdayName = (date) => {
try {
return new Intl.DateTimeFormat(
activeLocaleTag() === "zh" ? "zh-CN" : "en-US",
{ weekday: "long" },
).format(date);
} catch {
return "";
}
};
const renderActivityTree = (container) => {
if (!container || disposed) return;
const sSnap = sessions?.list?.getSnapshot?.();
const wSnap = workspaces?.list?.getSnapshot?.();
if (!sSnap || !wSnap) return;
let currentId = sSnap.current;
if (!currentId) {
try {
const raw = localStorage.getItem("dsh.sessions.current");
if (raw) {
const parsed = JSON.parse(raw);
if (parsed?.sessionId) currentId = parsed.sessionId;
}
} catch {}
}
const archived = new Set(wSnap.archivedSessionIds || []);
const workspaceBySession = new Map();
if (Array.isArray(wSnap.items)) {
for (const ws of wSnap.items) {
if (Array.isArray(ws.sessionIds)) {
for (const sid of ws.sessionIds) {
if (!workspaceBySession.has(sid)) workspaceBySession.set(sid, ws.title || "");
}
}
}
}
const labelOf = (s) => {
const fromWs = workspaceBySession.get(s.id);
if (fromWs) return fromWs;
if (s.cwd) {
return String(s.cwd).replace(/[\\/]+$/, "").split(/[\\/]/).pop() || "";
}
return "";
};
const running = [];
const byDay = new Map();
const ids = sSnap.ids || [];
const byId = sSnap.byId || {};
for (const id of ids) {
const s = byId[id];
if (!s) continue;
if (archived.has(s.id)) continue;
if (s.parentId !== undefined || s.origin === "subagent") continue;
if (s.blank && s.id !== currentId) continue;
if (s.running) {
running.push(s);
} else {
const d = new Date(s.updatedAt || 0);
d.setHours(0, 0, 0, 0);
const dayKey = d.getTime();
const list = byDay.get(dayKey);
if (list) list.push(s);
else byDay.set(dayKey, [s]);
}
}
const byRecency = (a, b) => (b.updatedAt || 0) - (a.updatedAt || 0);
running.sort(byRecency);
const now = Date.now();
const todayD = new Date(now);
todayD.setHours(0, 0, 0, 0);
const todayStart = todayD.getTime();
const groups = [];
if (running.length > 0) {
groups.push({
key: "priority",
title: toolkitText("activityPriority"),
icon: '<svg class="tk-label-spin" width="13" height="13" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M2.871 13.1286C0.0387669 10.2962 0.0387669 5.70383 2.871 2.87141C5.70341 0.0390029 10.2957 0.0391154 13.1282 2.87141L12.1387 3.86094C9.85292 1.57538 6.1469 1.57596 3.86123 3.86163C1.57573 6.14732 1.57573 9.85269 3.86123 12.1384C6.1469 14.424 9.85292 14.4246 12.1387 12.1391L13.1282 13.1286C10.2957 15.9609 5.70341 15.961 2.871 13.1286Z"/></svg>',
sessions: running,
});
}
const dayEntries = Array.from(byDay.entries()).sort((a, b) => b[0] - a[0]);
for (const [dayStart, sList] of dayEntries) {
sList.sort(byRecency);
const diffDays = Math.round((todayStart - dayStart) / 86400000);
let title = toolkitText("activityEarlier");
if (diffDays <= 0) {
title = toolkitText("activityToday");
} else if (diffDays === 1) {
title = toolkitText("activityYesterday");
} else if (diffDays < 7) {
title = weekdayName(new Date(dayStart)) || toolkitText("activityEarlier");
} else {
const d = new Date(dayStart);
title = `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`;
}
groups.push({ key: String(dayStart), title, sessions: sList });
}
if (groups.length === 0) {