dsh-tiddlywiki 0.1.2 → 0.2.1

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
@@ -24,7 +24,7 @@ TiddlyWiki 的特性正好合适:单文件、纯文本、wiki 语法、自带
24
24
  |---|---|
25
25
  | Agent 工具 | `tiddlywiki_search` / `get` / `put` / `delete` / `git_sync` |
26
26
  | 人编辑 | 侧边栏「TiddlyWiki」入口 → 中央列内嵌完整 TW 编辑器(iframe 直连 TW 服务) |
27
- | 快速笔记 | 聊天区右下角常驻悬浮控件(可折叠,标题/tag 可自定义,Ctrl+Enter 保存) |
27
+ | 快速笔记 | 聊天区右下角常驻悬浮控件(可折叠,可多选/自动补全 tagCtrl+Enter 保存;设置页可整体隐藏) |
28
28
  | 零摩擦生命周期 | 随 dsh 自动启动/关闭;TW 子进程崩溃自动重启(退避);端口/目录/首次 git init 全自动 |
29
29
  | 同步 | 单线程交替模型:开工 pull,收工 commit+push,自动 commit(60s 防抖,可关) |
30
30
 
@@ -43,13 +43,13 @@ dsh plugin --profile web add github:bbqisbbq/dsh-tiddlywiki
43
43
  dsh plugin --profile web add link:C:\Users\bbq\.dsh\plugins\dsh-tiddlywiki
44
44
  ```
45
45
 
46
- 首次启动会向 wiki **幂等写入**一篇「dsh-tiddlywiki 插件说明」笔记(tag `docs`)。
46
+ 首次启动会向 wiki **写入一次**「dsh-tiddlywiki 插件说明」笔记(tag `docs`)。
47
47
 
48
48
  ## 发布 / 仓库
49
49
 
50
50
  - **GitHub(公开)**:https://github.com/bbqisbbq/dsh-tiddlywiki
51
51
  - **npm**:`dsh-tiddlywiki`(`npm i dsh-tiddlywiki`;https://www.npmjs.com/package/dsh-tiddlywiki )
52
- - **说明笔记**:插件在首次启动时向 wiki **幂等写入**一篇「`dsh-tiddlywiki 插件说明`」笔记(tag `docs`)——不存在才写、手动编辑不被覆盖、删除后重启会重建;当前 live wiki 里已有一份。
52
+ - **说明笔记**:插件在**首次启动**时向 wiki **写入一次**「`dsh-tiddlywiki 插件说明`」笔记(tag `docs`)——只写一次、手动编辑不被覆盖、**删除后重启不会自动恢复**(一次性标记;清空 wiki 重装会再写入)。当前 live wiki 里已有一份。
53
53
 
54
54
  ## 可被检索的标准字段
55
55
 
@@ -85,6 +85,9 @@ dsh plugin --profile web add link:C:\Users\bbq\.dsh\plugins\dsh-tiddlywiki
85
85
  branch: "main"
86
86
  note:
87
87
  tag: "inbox"
88
+ ui:
89
+ showQuickNote: true # 是否显示右下角「快速笔记」按钮
90
+ showPanelStatus: true # 是否显示 TW 面板右下角「状态/重载」悬浮按钮
88
91
  auth:
89
92
  username: "" # 默认 loopback 匿名;暴露到非 loopback 时才需要
90
93
  password: ""
@@ -95,7 +98,7 @@ dsh plugin --profile web add link:C:\Users\bbq\.dsh\plugins\dsh-tiddlywiki
95
98
 
96
99
  > 运行时配置:设置页(见「设置页」)写入的 `$:/plugins/dsh-tiddlywiki/config`
97
100
  > tiddler 是 `config:` 块之上的覆盖层(tiddler 优先、随 wiki 的 git 同步)。
98
- > 无需改动 cordis 也能改 note tag / git 开关等。
101
+ > 无需改动 cordis 也能改 note tag / git 开关 / ui 开关等。
99
102
 
100
103
  ## 使用
101
104
 
@@ -48,6 +48,21 @@ const CSS_TEXT = `
48
48
  .dsh-tw-entry[data-active="true"] { background: var(--dsw-alias-interactive-bg-active, rgba(128,128,128,.18)); color: var(--dsw-alias-label-primary, inherit); font-weight: 500; }
49
49
  .dsh-tw-entry svg { flex: none; }
50
50
  .dsh-tw-entry .dsh-tw-entry-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
51
+ /* Collapsed rail: the shell narrows the sidebar to a 56px icon rail (dual
52
+ signals — the frame's data-sidebar-collapsed + the sidebar root's hashed
53
+ *_collapsed class, same doctrine as dsh-taskboard 0.4.3). Mirror the
54
+ native 36×36 icon-button geometry: center the icon, hide the label, and
55
+ scale it up to match the shell's 18px rail icons. */
56
+ [data-sidebar-collapsed] [data-dsh-tw-entry],
57
+ [class*="_collapsed"] [data-dsh-tw-entry] {
58
+ width: 36px; height: 36px; min-width: 36px;
59
+ margin: 0 0 12px; padding: 0;
60
+ justify-content: center; gap: 0; text-align: center;
61
+ }
62
+ [data-sidebar-collapsed] [data-dsh-tw-entry] .dsh-tw-entry-label,
63
+ [class*="_collapsed"] [data-dsh-tw-entry] .dsh-tw-entry-label { display: none; }
64
+ [data-sidebar-collapsed] [data-dsh-tw-entry] svg,
65
+ [class*="_collapsed"] [data-dsh-tw-entry] svg { width: 18px; height: 18px; }
51
66
 
52
67
  /* ── center-column panel (fixed overlay, JS-pinned to the column rect) ── */
53
68
  .dsh-tw-view {
@@ -60,31 +75,31 @@ html[data-dsh-tw-active] [data-pane="conversation"] > :not([data-dsh-tw-view]),
60
75
  html[data-dsh-tw-active] [class*="centerCol"] > :not([data-dsh-tw-view]),
61
76
  html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view]) { display: none !important; }
62
77
 
63
- .dsh-tw-panel-bar {
64
- display: flex; align-items: center; gap: 8px; padding: 6px 10px;
65
- border-bottom: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.18));
66
- background: var(--dsw-alias-bg-layer-1, transparent);
67
- font-size: 12px; color: var(--dsw-alias-label-secondary, inherit);
68
- flex: none;
78
+ /* ── bottom-right status / reload floating buttons ───────────────────
79
+ The top panel bar (title + status + reload row) is gone so TW's menubar
80
+ reaches the panel top; status/reload now live here as floating buttons,
81
+ same visual language as the quick-note toggle. The ui.showPanelStatus
82
+ setting controls whether they mount at all. */
83
+ .dsh-tw-panel-status {
84
+ /* 状态/重载悬浮按钮:右对齐到与快速笔记按钮同一条竖线(right:24),
85
+ 垂直排在快速笔记按钮(bottom:88,高约 38px)上方;
86
+ 两个按钮竖直依次排列(状态在上、重载在下),与其他插件按钮列对齐、不堆叠。 */
87
+ position: fixed; right: 24px; bottom: 140px; z-index: 950;
88
+ display: flex; flex-direction: column; align-items: flex-end; gap: 8px;
69
89
  }
70
- .dsh-tw-panel-bar button {
71
- border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.25));
72
- background: transparent; color: inherit; font: inherit; font-size: 12px;
73
- padding: 2px 8px; border-radius: 6px; cursor: pointer;
90
+ .dsh-tw-panel-status-btn {
91
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
92
+ background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
93
+ font: inherit; font-size: 12px; padding: 7px 12px; border-radius: 999px;
94
+ box-shadow: var(--dsw-shadow-lv3, 0 4px 16px rgba(0,0,0,.16)); cursor: pointer;
95
+ display: inline-flex; align-items: center; gap: 6px;
96
+ transition: background-color 120ms ease;
74
97
  }
75
- .dsh-tw-panel-bar button:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); }
76
- .dsh-tw-panel-title { font-weight: 600; margin-right: auto; }
77
- /* dsh-better-sidebar coexistence: the floating toggle cluster (two 28px
78
- buttons at the viewport's top-right) lives above the panel (z-index is
79
- handled in JS the panel stays below the host layer), so reserve the
80
- cluster's width at the panel bar's right end — mirroring how
81
- dsh-better-sidebar pads the DSH session header — and the panel's own
82
- 状态/重载 buttons never sit underneath it. */
83
- .dsh-tw-view[data-sidebar-host] .dsh-tw-panel-bar { padding-right: 80px; }
84
- .dsh-tw-status-chip { font-variant-numeric: tabular-nums; }
85
- .dsh-tw-status-chip[data-state="running"] { color: var(--dsw-alias-state-success-primary, #3eaa5f); }
86
- .dsh-tw-status-chip[data-state="starting"] { color: var(--dsw-alias-state-warning-primary, #d9822b); }
87
- .dsh-tw-status-chip[data-state="failed"], .dsh-tw-status-chip[data-state="stopped"] { color: var(--dsw-alias-state-error-primary, #d13b3b); }
98
+ .dsh-tw-panel-status-btn:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.1)); }
99
+ .dsh-tw-status-dot { width: 8px; height: 8px; border-radius: 50%; background: #999; flex: none; }
100
+ .dsh-tw-status-dot[data-state="running"] { background: var(--dsw-alias-state-success-primary, #3eaa5f); }
101
+ .dsh-tw-status-dot[data-state="starting"] { background: var(--dsw-alias-state-warning-primary, #d9822b); }
102
+ .dsh-tw-status-dot[data-state="failed"], .dsh-tw-status-dot[data-state="stopped"] { background: var(--dsw-alias-state-error-primary, #d13b3b); }
88
103
 
89
104
  .dsh-tw-panel-frame { flex: 1; min-height: 0; border: 0; width: 100%; display: block; background: #fff; }
90
105
  /* [hidden] must beat the author display rules above (UA hidden is overridden). */
@@ -163,6 +178,34 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
163
178
  }
164
179
  .dsh-tw-note-edit:hover:not(:disabled) { filter: brightness(.96); }
165
180
  .dsh-tw-note-edit:disabled { opacity: .55; cursor: default; }
181
+ /* ── Multi-tag chip editor (quick-note tags) ─────────────────────────────── */
182
+ .dsh-tw-note-tags { position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
183
+ .dsh-tw-note-chips { display: flex; flex-wrap: wrap; gap: 4px; }
184
+ .dsh-tw-note-tagchip {
185
+ display: inline-flex; align-items: center; gap: 4px;
186
+ padding: 2px 6px 2px 8px; border-radius: 999px; font-size: 11px; line-height: 1.4;
187
+ background: color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 10%, var(--dsw-alias-bg-layer-1, #fff));
188
+ border: 1px solid color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 30%, transparent);
189
+ color: var(--dsw-alias-label-primary, #222);
190
+ }
191
+ .dsh-tw-note-tagchip-x {
192
+ cursor: pointer; font-size: 12px; line-height: 1; padding: 0 2px; border-radius: 50%;
193
+ color: var(--dsw-alias-label-secondary, #888);
194
+ }
195
+ .dsh-tw-note-tagchip-x:hover { color: var(--dsw-alias-state-error-primary, #d13b3b); }
196
+ .dsh-tw-note-taginput { width: 100%; box-sizing: border-box; }
197
+ .dsh-tw-note-tagsuggest {
198
+ position: absolute; left: 0; right: 0; top: calc(100% + 2px); z-index: 60;
199
+ max-height: 168px; overflow-y: auto; border-radius: 8px; padding: 4px;
200
+ background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
201
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
202
+ box-shadow: var(--dsw-shadow-lv2, 0 4px 16px rgba(0,0,0,.16));
203
+ font-size: 12px;
204
+ }
205
+ .dsh-tw-note-tagsuggest-item {
206
+ padding: 6px 9px; border-radius: 6px; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
207
+ }
208
+ .dsh-tw-note-tagsuggest-item:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); }
166
209
  /* ── Native-editor popup iframe (quick-note "在 TW 中编辑") ──────────────── */
167
210
  .dsh-tw-editor-popup {
168
211
  position: fixed; left: 0; right: 0; top: 0; bottom: 0; margin: auto;
@@ -497,34 +540,14 @@ function mountPanel(state) {
497
540
  let frameArea;
498
541
  let errorArea;
499
542
  let chip;
543
+ let statusDot;
544
+ let statusFloater;
500
545
  let refreshTimer;
501
546
  let refreshAttempts = 0;
502
547
  const build = () => {
503
548
  const view = document.createElement("div");
504
549
  view.dataset.dshTwView = "";
505
550
  view.className = "dsh-tw-view";
506
- const bar = document.createElement("div");
507
- bar.className = "dsh-tw-panel-bar";
508
- const title = document.createElement("span");
509
- title.className = "dsh-tw-panel-title";
510
- title.textContent = "TiddlyWiki 知识库";
511
- chip = document.createElement("span");
512
- chip.className = "dsh-tw-status-chip";
513
- chip.dataset.state = "unknown";
514
- chip.textContent = "—";
515
- const reload = document.createElement("button");
516
- reload.type = "button";
517
- reload.textContent = "重载";
518
- reload.addEventListener("click", () => {
519
- if (iframe !== void 0 && !iframe.hidden) iframe.src = iframe.src;
520
- });
521
- const refresh = document.createElement("button");
522
- refresh.type = "button";
523
- refresh.textContent = "状态";
524
- refresh.addEventListener("click", () => {
525
- doRefresh();
526
- });
527
- bar.append(title, chip, refresh, reload);
528
551
  frameArea = document.createElement("div");
529
552
  frameArea.className = "dsh-tw-panel-frame-wrap";
530
553
  frameArea.style.cssText = "flex:1;min-height:0;display:flex;flex-direction:column";
@@ -536,13 +559,47 @@ function mountPanel(state) {
536
559
  errorArea = document.createElement("div");
537
560
  errorArea.className = "dsh-tw-panel-error";
538
561
  errorArea.hidden = true;
539
- view.append(bar, frameArea, errorArea);
562
+ view.append(frameArea, errorArea);
540
563
  return view;
541
564
  };
565
+ /**
566
+ * Bottom-right floating status/reload buttons (same visual language as the
567
+ * quick-note toggle) — the top panel bar is gone, so TW's menubar reaches
568
+ * the very top of the panel and clears the host's top-right buttons. Mount
569
+ * is gated by `ui.showPanelStatus` (settings page toggle).
570
+ */
571
+ const buildStatusFloater = () => {
572
+ const wrap = document.createElement("div");
573
+ wrap.className = "dsh-tw-panel-status";
574
+ const statusBtn = document.createElement("button");
575
+ statusBtn.type = "button";
576
+ statusBtn.className = "dsh-tw-panel-status-btn";
577
+ statusBtn.title = "刷新状态";
578
+ statusDot = document.createElement("span");
579
+ statusDot.className = "dsh-tw-status-dot";
580
+ statusDot.dataset.state = "unknown";
581
+ chip = document.createElement("span");
582
+ chip.className = "dsh-tw-panel-status-text";
583
+ chip.textContent = "—";
584
+ statusBtn.append(statusDot, chip);
585
+ statusBtn.addEventListener("click", () => {
586
+ doRefresh();
587
+ });
588
+ const reloadBtn = document.createElement("button");
589
+ reloadBtn.type = "button";
590
+ reloadBtn.className = "dsh-tw-panel-status-btn";
591
+ reloadBtn.textContent = "重载";
592
+ reloadBtn.title = "重载 TiddlyWiki 面板";
593
+ reloadBtn.addEventListener("click", () => {
594
+ if (iframe !== void 0 && !iframe.hidden) iframe.src = iframe.src;
595
+ });
596
+ wrap.append(statusBtn, reloadBtn);
597
+ document.body.append(wrap);
598
+ return wrap;
599
+ };
542
600
  const setChip = (stateName, text) => {
543
- if (chip === void 0) return;
544
- chip.dataset.state = stateName;
545
- chip.textContent = text;
601
+ if (statusDot !== void 0) statusDot.dataset.state = stateName;
602
+ if (chip !== void 0) chip.textContent = text;
546
603
  };
547
604
  /** Pin the overlay to the center column's current viewport rect. */
548
605
  const syncRect = () => {
@@ -696,6 +753,10 @@ function mountPanel(state) {
696
753
  const unsubscribe = state.subscribe(applyActive);
697
754
  ensure();
698
755
  applyActive();
756
+ (async () => {
757
+ if ((await fetchStatus())?.ui?.showPanelStatus === false) return;
758
+ statusFloater = buildStatusFloater();
759
+ })();
699
760
  return () => {
700
761
  if (refreshTimer !== void 0) window.clearTimeout(refreshTimer);
701
762
  window.clearInterval(syncInterval);
@@ -708,6 +769,7 @@ function mountPanel(state) {
708
769
  unsubscribe();
709
770
  document.documentElement.removeAttribute(ACTIVE_ATTR);
710
771
  container?.remove();
772
+ statusFloater?.remove();
711
773
  };
712
774
  }
713
775
  //#endregion
@@ -847,14 +909,23 @@ function ensurePopup() {
847
909
  * collapsible, independent of any shell DOM. Lets the human jot drafts /
848
910
  * scratch notes while waiting for the AI or drafting the next prompt.
849
911
  *
850
- * Save posts to /dsh-tiddlywiki/note → an independent tiddler (title & tag
912
+ * Save posts to /dsh-tiddlywiki/note → an independent tiddler (title & tags
851
913
  * editable; defaults: timestamp title + config tag, usually "inbox").
852
914
  *
915
+ * ui.showQuickNote config: the widget is mounted async and stays hidden (and
916
+ * never appended) when the option is off, so the toggle button can be disabled
917
+ * from the settings page without touching the shell.
918
+ *
919
+ * Tag editor: multi-select chips + autocomplete from the wiki's existing tags
920
+ * (GET /dsh-tiddlywiki/tags). Enter/`,` commits the typed value; Backspace on
921
+ * an empty field removes the last chip; the dropdown filters on typing.
922
+ *
853
923
  * @module dsh-tiddlywiki/client/note-widget
854
924
  */
855
925
  const NOTE_ENDPOINT = "/dsh-tiddlywiki/note";
856
926
  const EDIT_ENDPOINT = "/dsh-tiddlywiki/edit";
857
927
  const STATUS_ENDPOINT = "/dsh-tiddlywiki/status";
928
+ const TAGS_ENDPOINT = "/dsh-tiddlywiki/tags";
858
929
  function pad(n) {
859
930
  return n < 10 ? `0${n}` : String(n);
860
931
  }
@@ -862,16 +933,137 @@ function pad(n) {
862
933
  function timestampTitle(date = /* @__PURE__ */ new Date()) {
863
934
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
864
935
  }
865
- async function fetchDefaultTag() {
936
+ async function fetchUiOptions() {
937
+ const fallback = {
938
+ showQuickNote: true,
939
+ defaultTag: "inbox"
940
+ };
866
941
  try {
867
942
  const res = await fetch(STATUS_ENDPOINT, { signal: AbortSignal.timeout(5e3) });
868
- if (!res.ok) return "inbox";
869
- return (await res.json()).note?.tag ?? "inbox";
943
+ if (!res.ok) return fallback;
944
+ const payload = await res.json();
945
+ return {
946
+ showQuickNote: payload.ui?.showQuickNote !== false,
947
+ defaultTag: payload.note?.tag ?? "inbox"
948
+ };
870
949
  } catch {
871
- return "inbox";
950
+ return fallback;
872
951
  }
873
952
  }
874
- function mountNoteWidget() {
953
+ /** Multi-tag chip editor with autocomplete from the wiki's existing tags. */
954
+ function buildTagEditor() {
955
+ const wrap = document.createElement("div");
956
+ wrap.className = "dsh-tw-note-tags";
957
+ const chipWrap = document.createElement("div");
958
+ chipWrap.className = "dsh-tw-note-chips";
959
+ const input = document.createElement("input");
960
+ input.className = "dsh-tw-note-taginput";
961
+ input.placeholder = "tag(可多选,自动补全)";
962
+ const suggest = document.createElement("div");
963
+ suggest.className = "dsh-tw-note-tagsuggest";
964
+ suggest.hidden = true;
965
+ wrap.append(chipWrap, input, suggest);
966
+ const chips = [];
967
+ const hideSuggest = () => {
968
+ suggest.hidden = true;
969
+ };
970
+ const renderChips = () => {
971
+ chipWrap.replaceChildren();
972
+ for (const tag of chips) {
973
+ const chip = document.createElement("span");
974
+ chip.className = "dsh-tw-note-tagchip";
975
+ chip.textContent = tag;
976
+ const x = document.createElement("span");
977
+ x.className = "dsh-tw-note-tagchip-x";
978
+ x.textContent = "×";
979
+ x.title = `移除 tag「${tag}」`;
980
+ x.addEventListener("click", (event) => {
981
+ event.stopPropagation();
982
+ const i = chips.indexOf(tag);
983
+ if (i >= 0) {
984
+ chips.splice(i, 1);
985
+ renderChips();
986
+ }
987
+ });
988
+ chip.append(x);
989
+ chipWrap.append(chip);
990
+ }
991
+ };
992
+ const addTag = (tag) => {
993
+ const t = tag.trim();
994
+ if (t.length === 0 || chips.includes(t)) return;
995
+ chips.push(t);
996
+ input.value = "";
997
+ renderChips();
998
+ hideSuggest();
999
+ input.focus();
1000
+ };
1001
+ const commitInput = () => {
1002
+ for (const raw of input.value.split(/\s+/)) addTag(raw);
1003
+ };
1004
+ let knownTags = [];
1005
+ let tagsPromise;
1006
+ const ensureTags = () => {
1007
+ tagsPromise ??= fetch(TAGS_ENDPOINT, { signal: AbortSignal.timeout(5e3) }).then((r) => r.ok ? r.json() : Promise.resolve({})).then((p) => [...p.tags ?? []].sort((a, b) => a.localeCompare(b, "zh"))).catch(() => []);
1008
+ tagsPromise.then((list) => {
1009
+ knownTags = list;
1010
+ });
1011
+ return tagsPromise;
1012
+ };
1013
+ const showSuggest = () => {
1014
+ const q = input.value.trim().toLowerCase();
1015
+ const matches = knownTags.filter((t) => !chips.includes(t) && (q.length === 0 || t.toLowerCase().includes(q))).slice(0, 8);
1016
+ suggest.replaceChildren();
1017
+ for (const tag of matches) {
1018
+ const item = document.createElement("div");
1019
+ item.className = "dsh-tw-note-tagsuggest-item";
1020
+ item.textContent = tag;
1021
+ item.addEventListener("mousedown", (event) => {
1022
+ event.preventDefault();
1023
+ addTag(tag);
1024
+ });
1025
+ suggest.append(item);
1026
+ }
1027
+ suggest.hidden = matches.length === 0;
1028
+ };
1029
+ input.addEventListener("focus", () => {
1030
+ ensureTags().then(showSuggest);
1031
+ });
1032
+ input.addEventListener("input", () => {
1033
+ if (knownTags.length === 0) ensureTags().then(showSuggest);
1034
+ else showSuggest();
1035
+ });
1036
+ input.addEventListener("keydown", (event) => {
1037
+ if (event.key === "Enter" || event.key === ",") {
1038
+ event.preventDefault();
1039
+ commitInput();
1040
+ } else if (event.key === "Backspace" && input.value.length === 0 && chips.length > 0) {
1041
+ chips.pop();
1042
+ renderChips();
1043
+ } else if (event.key === "Escape") hideSuggest();
1044
+ });
1045
+ document.addEventListener("click", (event) => {
1046
+ if (!wrap.contains(event.target)) hideSuggest();
1047
+ }, true);
1048
+ return {
1049
+ el: wrap,
1050
+ getTags: () => {
1051
+ commitInput();
1052
+ return [...chips];
1053
+ },
1054
+ setDefault: (tag) => {
1055
+ if (chips.length === 0) {
1056
+ const t = tag.trim();
1057
+ if (t.length > 0) {
1058
+ chips.push(t);
1059
+ renderChips();
1060
+ }
1061
+ }
1062
+ }
1063
+ };
1064
+ }
1065
+ /** Build the whole widget DOM and wire it up (returns the appended root). */
1066
+ function buildWidget() {
875
1067
  const root = document.createElement("div");
876
1068
  root.className = "dsh-tw-note";
877
1069
  const card = document.createElement("div");
@@ -893,10 +1085,8 @@ function mountNoteWidget() {
893
1085
  const titleInput = document.createElement("input");
894
1086
  titleInput.className = "dsh-tw-note-title";
895
1087
  titleInput.placeholder = "标题(默认时间戳)";
896
- const tagInput = document.createElement("input");
897
- tagInput.className = "dsh-tw-note-tag";
898
- tagInput.placeholder = "tag(默认 inbox)";
899
- fields.append(titleInput, tagInput);
1088
+ const tagEditor = buildTagEditor();
1089
+ fields.append(titleInput, tagEditor.el);
900
1090
  const textarea = document.createElement("textarea");
901
1091
  textarea.className = "dsh-tw-note-text";
902
1092
  textarea.placeholder = "写点东西…(Ctrl+Enter 保存)";
@@ -931,10 +1121,9 @@ function mountNoteWidget() {
931
1121
  card.hidden = false;
932
1122
  opened = true;
933
1123
  resetTitle();
934
- if (tagInput.value.length === 0) tagInput.value = defaultTag;
935
1124
  textarea.focus();
936
- defaultTag = await fetchDefaultTag();
937
- if (tagInput.value.length === 0) tagInput.value = defaultTag;
1125
+ defaultTag = (await fetchUiOptions()).defaultTag;
1126
+ tagEditor.setDefault(defaultTag);
938
1127
  };
939
1128
  const close = () => {
940
1129
  card.hidden = true;
@@ -958,7 +1147,7 @@ function mountNoteWidget() {
958
1147
  headers: { "content-type": "application/json" },
959
1148
  body: JSON.stringify({
960
1149
  title: titleInput.value.trim(),
961
- tag: tagInput.value.trim(),
1150
+ tags: tagEditor.getTags(),
962
1151
  text
963
1152
  }),
964
1153
  signal: AbortSignal.timeout(1e4)
@@ -992,7 +1181,7 @@ function mountNoteWidget() {
992
1181
  const doEdit = async () => {
993
1182
  const title = titleInput.value.trim().length > 0 ? titleInput.value.trim() : timestampTitle();
994
1183
  const text = textarea.value;
995
- const tag = tagInput.value.trim();
1184
+ const tags = tagEditor.getTags();
996
1185
  edit.disabled = true;
997
1186
  edit.textContent = "打开中…";
998
1187
  try {
@@ -1001,7 +1190,7 @@ function mountNoteWidget() {
1001
1190
  headers: { "content-type": "application/json" },
1002
1191
  body: JSON.stringify({
1003
1192
  title,
1004
- tag,
1193
+ tags,
1005
1194
  text
1006
1195
  }),
1007
1196
  signal: AbortSignal.timeout(1e4)
@@ -1027,10 +1216,27 @@ function mountNoteWidget() {
1027
1216
  edit.addEventListener("click", () => {
1028
1217
  doEdit();
1029
1218
  });
1030
- return () => {
1031
- root.remove();
1219
+ return root;
1220
+ }
1221
+ /**
1222
+ * Mount the floating quick-note widget. Fetches /status first: when
1223
+ * `ui.showQuickNote` is off the widget is never created (no DOM side effects).
1224
+ * Returns a disposer that removes it.
1225
+ */
1226
+ function mountNoteWidget() {
1227
+ let disposed = false;
1228
+ let root;
1229
+ const disposer = () => {
1230
+ disposed = true;
1231
+ root?.remove();
1032
1232
  document.querySelector(".dsh-tw-toast")?.remove();
1033
1233
  };
1234
+ (async () => {
1235
+ const ui = await fetchUiOptions();
1236
+ if (disposed || !ui.showQuickNote) return;
1237
+ root = buildWidget();
1238
+ })();
1239
+ return disposer;
1034
1240
  }
1035
1241
  //#endregion
1036
1242
  //#region src/client/settings-page.ts
@@ -1148,6 +1354,7 @@ function renderConfigSection(body, config, refresh) {
1148
1354
  section.append(make("h3", "dsh-tw-settings-h", "常规配置"));
1149
1355
  const note = config.note ?? {};
1150
1356
  const git = config.git ?? {};
1357
+ const ui = config.ui ?? {};
1151
1358
  const fields = [];
1152
1359
  const textField = (key, label, initial) => {
1153
1360
  const input = make("input", "dsh-tw-settings-input");
@@ -1198,6 +1405,8 @@ function renderConfigSection(body, config, refresh) {
1198
1405
  numField("git.debounceMs", "自动 commit 防抖(ms)", typeof git.debounceMs === "number" ? git.debounceMs : 6e4);
1199
1406
  textField("git.remote", "git 远端(空=仅本地)", typeof git.remote === "string" ? git.remote : "");
1200
1407
  textField("git.branch", "git 分支", typeof git.branch === "string" ? git.branch : "main");
1408
+ checkField("ui.showQuickNote", "显示「快速笔记」悬浮按钮", ui.showQuickNote !== false);
1409
+ checkField("ui.showPanelStatus", "显示 TW 面板右下角「状态/重载」悬浮按钮", ui.showPanelStatus !== false);
1201
1410
  const save = make("button", "dsh-tw-settings-btn dsh-tw-settings-primary", "保存配置");
1202
1411
  save.type = "button";
1203
1412
  save.addEventListener("click", () => {