opencode-session-turns 0.1.16 → 0.1.17

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
@@ -21,6 +21,8 @@ click a turn to jump straight to that message.
21
21
 
22
22
  - 列出当前会话的每一轮用户提问(从会话消息接口分页拉取,`type=user`)。
23
23
  - 鼠标移到窗口右缘的细条上自动展开,移开自动收起;长历史跳转期间面板保持展开。
24
+ - **搜索轮次**:≥ 8 轮时面板顶部出现搜索框,按关键字即时过滤(匹配数显示在标题里);`↑`/`↓` 在结果间移动光标,`Enter` 跳转,无鼠标也能导航。
25
+ - **面板固定**:标题栏图钉按钮或 `⌘⌥P`(斜杠命令 `/turns-pin`)把面板钉住,鼠标移开不收起;`Esc` 先解固定、再关面板。
24
26
  - 点击任意一轮,**跳转到对应消息**——包括很久以前、尚未渲染的历史轮次。
25
27
  - 深历史需要逐页加载时,面板头部显示进度,目标轮次带徽标并琥珀色脉冲;
26
28
  按 `Esc` 或点 × 可随时取消。
@@ -30,7 +32,9 @@ click a turn to jump straight to that message.
30
32
  - **点击与预热的配合**:点到的轮次已预热 → 直接瞬时跳转;还没预热到 → 头部显示
31
33
  「预热中 · 稍等一下」,等预热翻到该轮后自动落地(不打断预热)。
32
34
  - 定位成功后精准描边闪烁**那一条用户气泡**(跟随气泡自身圆角),不再整行填色。
33
- - 自动高亮当前视口所在的轮次。
35
+ - 自动高亮当前视口所在的轮次(IntersectionObserver 驱动,后台窗口零开销)。
36
+ - 跨天会话的时间标注会带上「昨天」或日期,不再只有 `HH:mm` 造成歧义。
37
+ - 尊重系统的「减弱动态效果」(`prefers-reduced-motion`):保留状态色彩,去掉动画。
34
38
  - 斜杠命令 `/turns`、快捷键 `⌘⌥R`(Windows/Linux 为 `Ctrl+Alt+R`)展开轮次条。
35
39
 
36
40
  ## 安装
@@ -88,6 +92,17 @@ npm run build # -> dist/gui-experimental.cjs
88
92
  改完源码后重新构建,并在 OpenCode Desktop 里 Reload 插件
89
93
  (Settings → Extensions → Plugins → Reload)或重启应用。
90
94
 
95
+ ## 发布
96
+
97
+ 版本发布只通过 GitHub Actions(`.github/workflows/publish.yml`);本地 `npm publish` 已被 `prepublishOnly` 守卫禁止:
98
+
99
+ ```sh
100
+ npm version patch # 或 minor / major:自动改版本、提交并打 v* tag
101
+ git push --follow-tags origin main
102
+ ```
103
+
104
+ CI 会校验 tag 与 `package.json` 版本一致,随后构建并发布到 npm。
105
+
91
106
  ## 许可证
92
107
 
93
108
  MIT © meiyx7
@@ -1,5 +1,5 @@
1
- "use strict";Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}});const s=require("solid-js/web"),F=require("@opencode/plugin/gui"),a=require("solid-js");var Je=s.template("<div class=sr-status>"),nt=s.template('<span class="sr-title sr-progress">'),st=s.template("<div class=sr-foot>"),ot=s.template("<div class=sr-rail><div class=sr-head><button type=button class=sr-close>×</button></div><div class=sr-list>"),it=s.template("<span class=sr-title>会话轮次"),at=s.template("<div class=sr-note>"),lt=s.template("<span class=sr-badge>"),ct=s.template("<button type=button class=sr-turn><span class=sr-text><span class=sr-num>#</span><span class=sr-time></span></span><span class=sr-dash>");const Oe="v20",fe="message-",Ne="session-turns:diag",ge="session-turns:open",dt=40,ut=200,pt=50,ft=30,gt=45e3,ht=2e4,mt=8e3,bt=12e3,ze=220,wt=800;let We=0,he="",me="",be="";const Fe=l=>{const o=String(l?.text??"").split(`
2
- `).map(f=>f.trim()).find(f=>f.length>0);if(o)return o;const d=l?.files?.length??0;return d>0?`[附件 ×${d}]`:"(空消息)"},He=l=>String(l).padStart(2,"0"),xt=l=>{const o=new Date(l??0);return`${He(o.getHours())}:${He(o.getMinutes())}`},re=l=>new Promise(o=>setTimeout(o,l)),we=()=>document.querySelector('[data-slot="session-timeline-scroll"] > .scroll-view__viewport')??document.querySelector('[data-slot="session-timeline-scroll"] [data-scrollable]')??(()=>{let o=document.querySelector("[data-message-id]")?.parentElement;for(;o;){const d=getComputedStyle(o);if(/(auto|scroll)/.test(d.overflowY)&&o.scrollHeight>o.clientHeight+1)return o;o=o.parentElement}})(),$=l=>{const o=document.getElementById(`${fe}${l}`);if(o)return o;const d=`[data-message-id="${l.replace(/"/g,'\\"')}"]`;for(const f of document.querySelectorAll(d))if(f.getAttribute("data-timeline-row")==="UserMessage"||f.querySelector('[data-slot="user-message-text"]'))return f},ue=l=>{const o=we(),d=[...document.querySelectorAll("[data-message-id]")],f=d[0]?.getAttribute("data-message-id")??"",C=be?` · 目标 ${$(be)?"在":"不在"} DOM`:"",T=f?` · 行ID ${f.slice(0,20)}`:"";let A="(空)";try{A=l.router.path()||"(空)"}catch{}return`轮次 ${We} · 容器 ${o?"OK":"缺失"} · DOM ${d.length} 行${C}${T} · 路由 ${A} · hash ${window.location.hash||"(空)"}${me?` · ${me}`:""}${he?` · ${he}`:""}`},vt=l=>{const o=l.getBoundingClientRect().top;let d=o;for(const f of l.querySelectorAll(".sticky, [data-session-title]")){if(!(f instanceof HTMLElement)||getComputedStyle(f).position!=="sticky"&&!f.hasAttribute("data-session-title"))continue;const T=f.getBoundingClientRect();T.height!==0&&(T.top>o+4||(d=Math.max(d,T.bottom)))}return d},pe=l=>{try{const o=we();if(!o||!l.isConnected)return;const d=l.querySelector('[data-slot="user-message-text"]')??l,f=vt(o),C=d.getBoundingClientRect().top-(f+18);Math.abs(C)>2&&(o.scrollTop+=C),me=`inset ${Math.round(f)} · delta ${Math.round(C)}`}catch{}},ne=l=>{pe(l),setTimeout(()=>pe(l),450),setTimeout(()=>pe(l),1e3)},Ue=l=>{let o="";try{o=l.router.path()||""}catch{}return o.startsWith("/")?o:""},yt=l=>{const o=document.createElement("a");o.setAttribute("href",l),o.setAttribute("replace",""),o.setAttribute("noscroll",""),o.style.display="none",document.body.appendChild(o);try{o.dispatchEvent(new MouseEvent("click",{bubbles:!0,cancelable:!0,button:0,view:window}))}finally{o.remove()}},kt=`
1
+ "use strict";Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}});const r=require("solid-js/web"),F=require("@opencode/plugin/gui"),a=require("solid-js");var tt=r.template("<div class=sr-status>"),xt=r.template('<span class="sr-title sr-progress">'),yt=r.template('<svg><path d="M6 3l7 7-3.5.5L7 13 3 9l2.5-2.5z"></svg>',!1,!0,!1),kt=r.template('<svg><path d="M4 12l-1.5 1.5"></svg>',!1,!0,!1),$t=r.template("<button type=button class=sr-search-clear aria-label=清除搜索>×"),St=r.template("<div class=sr-search><input type=text class=sr-search-input placeholder=搜索轮次…>"),Ct=r.template("<div class=sr-foot>"),Et=r.template('<div class=sr-rail><div class=sr-head><span class=sr-head-actions><button type=button class=sr-iconbtn><svg viewBox="0 0 16 16"fill=none stroke=currentColor stroke-width=1.5 stroke-linecap=round stroke-linejoin=round aria-hidden=true></svg></button><button type=button class=sr-close>×</button></span></div><div class=sr-list tabindex=-1>'),_t=r.template("<span class=sr-title>会话轮次"),At=r.template('<svg><path d="M9.5 2.5l4 4-2.6.9-1.4 3.5-4.4-4.4L8.6 5.1z"></svg>',!1,!0,!1),Mt=r.template('<svg><path d="M5.1 6.5L2.5 9.1l4.4 4.4 2.6-2.6"></svg>',!1,!0,!1),Tt=r.template("<div class=sr-note>"),Dt=r.template("<span class=sr-badge>"),It=r.template("<button type=button class=sr-turn><span class=sr-text><span class=sr-num>#</span><span class=sr-time></span></span><span class=sr-dash>");const rt="v21",Se="message-",nt="session-turns:diag",Ce="session-turns:open",Ee="session-turns:pin",Lt=40,Pt=200,jt=50,qt=30,Rt=45e3,Bt=2e4,Ot=8e3,zt=12e3,st=220,Ft=800;let at=0,_e="",Ae="",Me="";const ot=l=>{const s=String(l?.text??"").split(`
2
+ `).map(m=>m.trim()).find(m=>m.length>0);if(s)return s;const c=l?.files?.length??0;return c>0?`[附件 ×${c}]`:"(空消息)"},P=l=>String(l).padStart(2,"0"),Jt=l=>{const s=new Date(l??0),c=new Date;if(s.getFullYear()===c.getFullYear()&&s.getMonth()===c.getMonth()&&s.getDate()===c.getDate())return`${P(s.getHours())}:${P(s.getMinutes())}`;const $=new Date(c);return $.setDate(c.getDate()-1),s.getFullYear()===$.getFullYear()&&s.getMonth()===$.getMonth()&&s.getDate()===$.getDate()?`昨天 ${P(s.getHours())}:${P(s.getMinutes())}`:`${P(s.getMonth()+1)}-${P(s.getDate())} ${P(s.getHours())}:${P(s.getMinutes())}`},de=l=>new Promise(s=>setTimeout(s,l)),Te=()=>document.querySelector('[data-slot="session-timeline-scroll"] > .scroll-view__viewport')??document.querySelector('[data-slot="session-timeline-scroll"] [data-scrollable]')??(()=>{let s=document.querySelector("[data-message-id]")?.parentElement;for(;s;){const c=getComputedStyle(s);if(/(auto|scroll)/.test(c.overflowY)&&s.scrollHeight>s.clientHeight+1)return s;s=s.parentElement}})(),E=l=>{const s=document.getElementById(`${Se}${l}`);if(s)return s;const c=`[data-message-id="${l.replace(/"/g,'\\"')}"]`;for(const m of document.querySelectorAll(c))if(m.getAttribute("data-timeline-row")==="UserMessage"||m.querySelector('[data-slot="user-message-text"]'))return m},ke=l=>{const s=Te(),c=[...document.querySelectorAll("[data-message-id]")],m=c[0]?.getAttribute("data-message-id")??"",$=Me?` · 目标 ${E(Me)?"在":"不在"} DOM`:"",T=m?` · 行ID ${m.slice(0,20)}`:"";let S="(空)";try{S=l.router.path()||"(空)"}catch{}return`轮次 ${at} · 容器 ${s?"OK":"缺失"} · DOM ${c.length} 行${$}${T} · 路由 ${S} · hash ${window.location.hash||"(空)"}${Ae?` · ${Ae}`:""}${_e?` · ${_e}`:""}`},Nt=l=>{const s=l.getBoundingClientRect().top;let c=s;for(const m of l.querySelectorAll(".sticky, [data-session-title]")){if(!(m instanceof HTMLElement)||getComputedStyle(m).position!=="sticky"&&!m.hasAttribute("data-session-title"))continue;const T=m.getBoundingClientRect();T.height!==0&&(T.top>s+4||(c=Math.max(c,T.bottom)))}return c},$e=l=>{try{const s=Te();if(!s||!l.isConnected)return;const c=l.querySelector('[data-slot="user-message-text"]')??l,m=Nt(s),$=c.getBoundingClientRect().top-(m+18);Math.abs($)>2&&(s.scrollTop+=$),Ae=`inset ${Math.round(m)} · delta ${Math.round($)}`}catch{}},ue=l=>{$e(l),setTimeout(()=>$e(l),450),setTimeout(()=>$e(l),1e3)},it=l=>{let s="";try{s=l.router.path()||""}catch{}return s.startsWith("/")?s:""},Ht=l=>{const s=document.createElement("a");s.setAttribute("href",l),s.setAttribute("replace",""),s.setAttribute("noscroll",""),s.style.display="none",document.body.appendChild(s);try{s.dispatchEvent(new MouseEvent("click",{bubbles:!0,cancelable:!0,button:0,view:window}))}finally{s.remove()}},Ut=`
3
3
  .sr-rail {
4
4
  position: fixed;
5
5
  top: 50%;
@@ -46,6 +46,73 @@
46
46
  color: rgba(255,170,60,.95);
47
47
  font-variant-numeric: tabular-nums;
48
48
  }
49
+ .sr-head-actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
50
+ .sr-iconbtn {
51
+ display: inline-flex;
52
+ align-items: center;
53
+ justify-content: center;
54
+ width: 22px;
55
+ height: 22px;
56
+ padding: 0;
57
+ border: 0;
58
+ background: transparent;
59
+ color: inherit;
60
+ cursor: pointer;
61
+ border-radius: 4px;
62
+ opacity: .5;
63
+ }
64
+ .sr-iconbtn svg { width: 13px; height: 13px; }
65
+ .sr-iconbtn:hover { opacity: 1; background: var(--surface-base-hover, rgba(127,127,127,.14)); }
66
+ .sr-iconbtn[data-on="true"] { opacity: .95; color: rgba(255,170,60,.95); }
67
+ .sr-iconbtn:focus-visible { outline: 1px solid currentColor; outline-offset: -1px; }
68
+ /* search box */
69
+ .sr-search {
70
+ position: relative;
71
+ flex-shrink: 0;
72
+ margin: 0 0 6px;
73
+ }
74
+ .sr-search-input {
75
+ width: 100%;
76
+ box-sizing: border-box;
77
+ height: 26px;
78
+ padding: 0 24px 0 8px;
79
+ border: 1px solid rgba(127,127,127,.25);
80
+ border-radius: 6px;
81
+ background: color-mix(in srgb, currentColor 6%, transparent);
82
+ color: inherit;
83
+ font: inherit;
84
+ font-size: 12px;
85
+ outline: none;
86
+ transition: border-color .12s ease, box-shadow .12s ease;
87
+ }
88
+ .sr-search-input::placeholder { color: currentColor; opacity: .4; }
89
+ .sr-search-input:focus {
90
+ border-color: rgba(255,170,60,.6);
91
+ box-shadow: 0 0 0 2px rgba(255,170,60,.14);
92
+ }
93
+ .sr-search-clear {
94
+ position: absolute;
95
+ right: 4px;
96
+ top: 50%;
97
+ transform: translateY(-50%);
98
+ display: inline-flex;
99
+ align-items: center;
100
+ justify-content: center;
101
+ width: 16px;
102
+ height: 16px;
103
+ padding: 0;
104
+ border: 0;
105
+ border-radius: 50%;
106
+ background: transparent;
107
+ color: inherit;
108
+ font-size: 12px;
109
+ line-height: 1;
110
+ cursor: pointer;
111
+ opacity: .5;
112
+ }
113
+ .sr-search-clear:hover { opacity: 1; background: var(--surface-base-hover, rgba(127,127,127,.14)); }
114
+ /* keyboard cursor (↑/↓) */
115
+ .sr-turn[data-cursor="true"] { background: var(--surface-base-hover, rgba(127,127,127,.14)); }
49
116
  .sr-close {
50
117
  border: 0;
51
118
  background: transparent;
@@ -242,4 +309,10 @@
242
309
  white-space: pre-wrap;
243
310
  max-width: 260px;
244
311
  }
245
- `;function St(l){const o=F.usePlugin(),[d,f]=a.createSignal([]),[C,T]=a.createSignal(""),[A,M]=a.createSignal(!1),[Ge,Ve]=a.createSignal(""),[j,xe]=a.createSignal(""),[E,H]=a.createSignal(!1),[ve,R]=a.createSignal(0),[ye,q]=a.createSignal(0),[Et,B]=a.createSignal(0),[U,W]=a.createSignal(""),[ke,J]=a.createSignal(!1),_=new Map;let se=0,O=!1,G=!1,V="",N;const h=()=>l.session()?.id,oe=()=>{const e=[..._.values()].sort((r,i)=>r.created-i.created),t=a.untrack(d);t.length===e.length&&t.every((r,i)=>r===e[i])||(f(e),We=e.length)},Se=e=>{const t=_.get(e.id);t&&t.created===e.time&&t.text===e.text||_.set(e.id,{id:e.id,created:e.time,text:e.text})},Ye=async(e,t,r)=>{const i=r?{sessionID:e,type:"user",limit:t,cursor:r}:{sessionID:e,type:"user",limit:t,order:"desc"};return await o.client.message.list(i)},ie=async(e,t)=>{const r=++se;O=!0,t==="full"&&(z="",de="");let i="";try{let n;const c=t==="full"?dt:1,u=t==="full"?ut:pt;let m=t!=="full";for(let x=0;x<c;x++){let v;try{v=await Ye(e,u,n)}catch(g){i=String(g?.message??g);break}if(r!==se)return;m||(_.clear(),m=!0);let b=0;for(const g of v?.data??[])_.has(g.id)||(Se({id:g.id,time:g.time?.created??0,text:Fe(g)}),b++);oe();const p=v?.cursor?.next;if(!p||b===0)break;n=p}}finally{O=!1,V=e,he=i,Ve(_.size===0?i||"暂无用户消息":""),G&&(G=!1,$e()),t==="full"&&!i&&e===a.untrack(h)&&_.size>=15&&setTimeout(()=>ee(),1200)}},$e=()=>{N||(N=setTimeout(()=>{N=void 0;const e=a.untrack(h);e&&ie(e,"light")},300))},Ee=e=>{if(!(!e||e!==a.untrack(h))){if(O){G=!0;return}$e()}},Ke=e=>{const t=e?.type;if(t==="message.updated"){const r=e?.data??{},i=r.info;i?.role==="user"&&i?.id&&(Se({id:i.id,time:i.time?.created??0,text:Fe(i)}),oe()),Ee(r.sessionID)}else if(t==="message.removed"){const r=e?.data??{};r.messageID&&_.delete(r.messageID)&&oe(),Ee(r.sessionID)}else if(t==="location.shutdown"){const r=a.untrack(h);r&&V===r&&ie(r,"full")}};a.onMount(()=>{let e=!1,t=0,r;(async()=>{for(;!e;){const c=o.client.event.subscribe()[Symbol.asyncIterator]();try{for(;!e;){const u=await c.next();if(u.done)break;t=0,Ke(u.value)}}catch{}try{await c.return?.()}catch{}if(e)break;t++,await re(Math.min(2e3*t,15e3))}})(),a.onCleanup(()=>{e=!0,clearTimeout(r),N&&clearTimeout(N)})}),a.createEffect(()=>{const e=h();e&&a.untrack(()=>{V!==e&&(se++,O=!1,G=!1,_.clear(),f([]),T(""),le="",ie(e,"full"))})});let Y;const I=()=>(Y?.isConnected||(Y=we()),Y),Ze=()=>{const e=I();if(!e)return;const t=e.querySelectorAll("[data-message-id]");if(t.length===0)return;const r=e.getBoundingClientRect().top+40;for(const i of t)if(i.getBoundingClientRect().bottom>=r){const n=i.getAttribute("data-message-id");n&&T(n);break}};let ae=!1;const _e=()=>{ae||(ae=!0,requestAnimationFrame(()=>{ae=!1,Ze()}))};a.onMount(()=>{const e=()=>_e();document.addEventListener("scroll",e,!0);const t=setInterval(_e,1500);a.onCleanup(()=>{document.removeEventListener("scroll",e,!0),clearInterval(t)})});let Ce=0;const K=(e,t=2600)=>{const r=++Ce;xe(e),setTimeout(()=>{r===Ce&&xe("")},t)};a.onMount(()=>{const e=r=>{if(r.key==="Escape"){if(a.untrack(E)){Le(),r.stopPropagation();return}a.untrack(A)&&(M(!1),r.stopPropagation())}},t=r=>{if(!a.untrack(A))return;(r.composedPath?.()??[]).some(c=>c instanceof Element&&c.closest?.(".sr-rail"))||M(!1)};window.addEventListener("keydown",e,!0),document.addEventListener("pointerdown",t,!0),a.onCleanup(()=>{window.removeEventListener("keydown",e,!0),document.removeEventListener("pointerdown",t,!0)})}),a.onMount(()=>{const e=()=>K(`${Oe} · ${ue(o)}`),t=()=>{M(r=>!r),ee()};window.addEventListener(Ne,e),window.addEventListener(ge,t),a.onCleanup(()=>{window.removeEventListener(Ne,e),window.removeEventListener(ge,t)})});let le="",S=0,Te=0,Ae="";const Z=e=>{try{const t=e.querySelector('[data-slot="user-message-text"]')??e.querySelector('[data-slot="user-message-body"]')??e;t.classList.remove("sr-flash-ring"),t.offsetWidth,t.classList.add("sr-flash-ring"),setTimeout(()=>t.classList.remove("sr-flash-ring"),1900)}catch{}},Me=()=>{for(const e of d())if($(e.id))return e},L=(e,t)=>{yt(`${e}#${fe}${t}`),le=t},X=()=>{const t=I()?.querySelectorAll("[data-message-id]")??[];if(t.length===0)return"0";const r=t[0].getAttribute("data-message-id")??"",i=t[t.length-1].getAttribute("data-message-id")??"";return`${t.length}:${r}:${i}`},Ie=(e,t,r,i)=>new Promise(n=>{let c=!1,u,m,x;const v=Date.now();let p=X(),g=v,w=0;const y=te=>{if(!c){c=!0;try{u?.disconnect()}catch{}clearInterval(m),clearTimeout(x),n(te??null)}},k=te=>{if(t!==S)return y(null);const Re=te?$(e):document.getElementById(`${fe}${e}`);if(Re)return y(Re);if(!te)return;const qe=X(),Be=Date.now();if(qe!==p){p=qe,g=Be,w++,i?.(w);return}const rt=w>0?mt:bt;Be-g>rt&&y(null)},je=$(e);if(je)return y(je);u=new MutationObserver(()=>k(!1)),u.observe(document.documentElement,{childList:!0,subtree:!0}),m=setInterval(()=>k(!0),120),x=setTimeout(()=>y($(e)),Math.max(400,r))}),Le=()=>{S++,H(!1),R(0),q(0),B(0),W(""),J(!1),K("已取消跳转")},Xe=async e=>{if(E()&&U()===e.id)return;const t=Ue(o),r=I(),i=$(e.id);if(i&&r){D()&&(Q(),t&&L(t,e.id)),ne(i),Z(i);return}const n=Date.now();if(e.id===Ae&&n-Te<wt){const b=$(e.id);if(b&&r){ne(b),Z(b);return}}if(Te=n,Ae=e.id,be=e.id,!t){K(`无法读取当前路由,无法定位 · ${ue(o)}`);return}const c=++S,u=h();H(!0),W(e.id),R(0),q(0),B(0),J(D()),M(!0);const m=Date.now(),x=setInterval(()=>{q(Math.round((Date.now()-m)/1e3))},500);let v=0;try{if(D()){const p=await Ie(e.id,c,2e4,g=>R(g));if(c!==S||h()!==u)return;if(p){Q(),L(t,e.id),ne(p),Z(p);return}if(D()&&Q(),J(!1),c!==S||h()!==u)return}const b=Date.now()+gt;for(let p=0;p<ft&&Date.now()<b;p++){if(c!==S||h()!==u)return;if(B(p+1),le===e.id){const k=Me()??d()[0];k&&(L(t,k.id),await re(ze))}if(c!==S||h()!==u)return;L(t,e.id);let g=0;const w=Math.min(ht,b-Date.now()),y=await Ie(e.id,c,w,k=>{g=k,R(v+k)});if(v+=g,c!==S||h()!==u)return;if(y){ne(y),Z(y);return}}c===S&&K(`未能定位 · ${ue(o)}`)}finally{clearInterval(x),c===S&&(H(!1),R(0),q(0),B(0),W(""),J(!1))}},[D,ce]=a.createSignal(!1);let P=0,z="",de="";const Qe=()=>{const e=I();if(!e)return;const t=e.getBoundingClientRect();for(const r of e.querySelectorAll("[data-message-id]")){const i=r.getBoundingClientRect();if(i.bottom<=t.top+1)continue;const n=r.getAttribute("data-message-id");if(n)return{el:r,id:n,offset:i.top-t.top}}},De=e=>{if(!e)return;const t=I();if(!t)return;const r=e.el?.isConnected?e.el:$(e.id);if(!r)return;const i=t.getBoundingClientRect(),n=r.getBoundingClientRect().top-i.top-e.offset;Math.abs(n)>2&&(t.scrollTop+=n)},Q=()=>{P++,ce(!1)},ee=()=>{const e=h();!e||D()||z!==e&&(d().length<15||et())},et=async()=>{if(D())return;const e=Ue(o),t=d()[0],r=h();if(!e||!t||!r||z===r&&$(t.id))return;const i=++P,n=Qe(),c=I(),u=c?c.scrollTop+c.clientHeight>=c.scrollHeight-32:!0;ce(!0);try{L(e,t.id);const m=Date.now()+6e4;let x=X(),v=Date.now(),b=0;const p=setInterval(()=>De(n),150);try{for(;Date.now()<m&&i===P&&!($(t.id)||(await re(250),i!==P));){const w=Date.now(),y=X();if(y!==x)x=y,v=w,b=0;else if(w-v>5e3){if(b>=2)break;const k=Me();if(k&&k.id!==t.id&&(L(e,k.id),await re(ze),i!==P))break;L(e,t.id),v=Date.now(),b++}}}finally{clearInterval(p)}if(i!==P)return;const g=!!$(t.id);if(De(n),u){const w=I();w&&(w.scrollTop=w.scrollHeight)}g?z=r:de=r}finally{i===P&&ce(!1)}};let Pe=a.untrack(h);a.createEffect(()=>{const e=h();e!==Pe&&(Pe=e,Q(),S++,H(!1),R(0),q(0),B(0),W(""),J(!1))}),a.createEffect(()=>{const e=h(),t=d().length,r=D();if(!e||t<15||r||z===e||de===e||O||V!==e)return;const i=setTimeout(()=>ee(),1200);a.onCleanup(()=>clearTimeout(i))}),a.createEffect(()=>{E()&&U()&&requestAnimationFrame(()=>{try{document.querySelector('.sr-list .sr-turn[data-busy="true"]')?.scrollIntoView({block:"nearest"})}catch{}})});const tt=[s.createComponent(a.Show,{get when(){return j()},get children(){var e=Je();return s.insert(e,j),e}}),(()=>{var e=ot(),t=e.firstChild,r=t.firstChild,i=t.nextSibling;return e.addEventListener("mouseleave",()=>{E()||M(!1)}),e.addEventListener("mouseenter",()=>{M(!0),ee()}),s.insert(t,s.createComponent(a.Show,{get when(){return E()},get fallback(){return(()=>{var n=it();return n.firstChild,s.insert(n,(()=>{var c=s.memo(()=>d().length>0);return()=>c()?` · ${d().length}`:""})(),null),n})()},get children(){var n=nt();return s.insert(n,()=>ke()?"预热中 · 稍等一下":"加载历史中",null),s.insert(n,(()=>{var c=s.memo(()=>ve()>0);return()=>c()?` · ${ve()} 页`:""})(),null),s.insert(n,(()=>{var c=s.memo(()=>ye()>0);return()=>c()?` · ${ye()}s`:""})(),null),n}}),r),r.$$click=()=>{E()?Le():M(!1)},s.insert(i,s.createComponent(a.Show,{get when(){return d().length>0},get fallback(){return(()=>{var n=at();return s.insert(n,()=>Ge()||`… ${Oe}`),n})()},get children(){return s.createComponent(a.For,{get each(){return d()},children:(n,c)=>(()=>{var u=ct(),m=u.firstChild,x=m.firstChild;x.firstChild;var v=x.nextSibling,b=m.nextSibling;return u.$$click=()=>{Xe(n)},s.insert(x,()=>c()+1,null),s.insert(v,()=>xt(n.created)),s.insert(m,()=>n.text,null),s.insert(u,s.createComponent(a.Show,{get when(){return s.memo(()=>!!E())()&&U()===n.id},get children(){var p=lt();return s.insert(p,()=>ke()?"稍等":"加载中"),p}}),b),s.effect(p=>{var g=C()===n.id?"true":"false",w=E()&&U()===n.id?"true":"false";return g!==p.e&&s.setAttribute(u,"data-active",p.e=g),w!==p.t&&s.setAttribute(u,"data-busy",p.t=w),p},{e:void 0,t:void 0}),u})()})}})),s.insert(e,s.createComponent(a.Show,{get when(){return s.memo(()=>!!A())()&&j()},get children(){var n=st();return s.insert(n,j),n}}),null),s.effect(n=>{var c=A()?"true":"false",u=E()?"true":"false",m=E()?"取消跳转 (Esc)":"关闭 (Esc)";return c!==n.e&&s.setAttribute(e,"data-open",n.e=c),u!==n.t&&s.setAttribute(e,"data-jumping",n.t=u),m!==n.a&&s.setAttribute(r,"title",n.a=m),n},{e:void 0,t:void 0,a:void 0}),e})(),s.createComponent(a.Show,{get when(){return s.memo(()=>!A())()&&j()},get children(){var e=Je();return s.insert(e,j),e}})];return s.createComponent(s.Portal,{get mount(){return document.body},children:tt})}const $t=F.Plugin.define({i18n:{en:{"command.jump":"Session turns: show / jump","command.jump.description":"Reveal the turn rail; click a turn to jump to that message"}},setup(l){l.add(F.Style,kt),l.add(F.Slot,{at:"window.bottom",render:()=>s.createComponent(a.Show,{get when(){return l.sessions.current()},get children(){return s.createComponent(St,{session:()=>l.sessions.current()})}})}),l.add(F.Command,()=>({id:"jump",title:l.t("command.jump"),description:l.t("command.jump.description"),group:l.t("command.jump"),section:"navigation",bind:"mod+alt+r",slash:{name:"turns"},run:()=>{window.dispatchEvent(new Event(ge))}}))}});s.delegateEvents(["click"]);exports.default=$t;
312
+ /* Reduced motion: keep the signals (colors, widths) but drop the animation. */
313
+ @media (prefers-reduced-motion: reduce) {
314
+ .sr-rail, .sr-dash, .sr-text { transition: none !important; }
315
+ .sr-turn[data-busy="true"] .sr-dash { animation: none !important; opacity: 1 !important; }
316
+ .sr-flash-ring { animation: none !important; box-shadow: inset 0 0 0 2px rgba(255,170,60,.5) !important; }
317
+ }
318
+ `;function Wt(l){const s=F.usePlugin(),[c,m]=a.createSignal([]),[$,T]=a.createSignal(""),[S,I]=a.createSignal(!1),[lt,ct]=a.createSignal(""),[O,De]=a.createSignal(""),[Q,Ie]=a.createSignal(""),[_,pe]=a.createSignal(!1),[A,X]=a.createSignal(!1),[Le,z]=a.createSignal(0),[Pe,J]=a.createSignal(0),[Gt,N]=a.createSignal(0),[ee,te]=a.createSignal(""),[je,H]=a.createSignal(!1),D=new Map;let fe=0,U=!1,re=!1,ne="",W;const k=()=>l.session()?.id,ge=()=>{const e=[...D.values()].sort((n,i)=>n.created-i.created),t=a.untrack(c);t.length===e.length&&t.every((n,i)=>n===e[i])||(m(e),at=e.length)},qe=e=>{const t=D.get(e.id);t&&t.created===e.time&&t.text===e.text||D.set(e.id,{id:e.id,created:e.time,text:e.text})},dt=async(e,t,n)=>{const i=n?{sessionID:e,type:"user",limit:t,cursor:n}:{sessionID:e,type:"user",limit:t,order:"desc"};return await s.client.message.list(i)},he=async(e,t)=>{const n=++fe;U=!0,t==="full"&&(G="",xe="");let i="";try{let g;const d=t==="full"?Lt:1,v=t==="full"?Pt:jt;let o=t!=="full";for(let u=0;u<d;u++){let f;try{f=await dt(e,v,g)}catch(w){i=String(w?.message??w);break}if(n!==fe)return;o||(D.clear(),o=!0);let h=0;for(const w of f?.data??[])D.has(w.id)||(qe({id:w.id,time:w.time?.created??0,text:ot(w)}),h++);ge();const b=f?.cursor?.next;if(!b||h===0)break;g=b}}finally{U=!1,ne=e,_e=i,ct(D.size===0?i||"暂无用户消息":""),re&&(re=!1,Re()),t==="full"&&!i&&e===a.untrack(k)&&D.size>=15&&setTimeout(()=>V(),1200)}},Re=()=>{W||(W=setTimeout(()=>{W=void 0;const e=a.untrack(k);e&&he(e,"light")},300))},Be=e=>{if(!(!e||e!==a.untrack(k))){if(U){re=!0;return}Re()}},ut=e=>{const t=e?.type;if(t==="message.updated"){const n=e?.data??{},i=n.info;i?.role==="user"&&i?.id&&(qe({id:i.id,time:i.time?.created??0,text:ot(i)}),ge()),Be(n.sessionID)}else if(t==="message.removed"){const n=e?.data??{};n.messageID&&D.delete(n.messageID)&&ge(),Be(n.sessionID)}else if(t==="location.shutdown"){const n=a.untrack(k);n&&ne===n&&he(n,"full")}};a.onMount(()=>{let e=!1,t=0,n;(async()=>{for(;!e;){const d=s.client.event.subscribe()[Symbol.asyncIterator]();try{for(;!e;){const v=await d.next();if(v.done)break;t=0,ut(v.value)}}catch{}try{await d.return?.()}catch{}if(e)break;t++,await de(Math.min(2e3*t,15e3))}})(),a.onCleanup(()=>{e=!0,clearTimeout(n),W&&clearTimeout(W)})}),a.createEffect(()=>{const e=k();e&&a.untrack(()=>{ne!==e&&(fe++,U=!1,re=!1,D.clear(),m([]),T(""),we="",he(e,"full"))})});let se;const j=()=>(se?.isConnected||(se=Te()),se),Oe=new WeakSet;let Y;const pt=()=>Y||(Y=new IntersectionObserver(e=>{e.length!==0&&be()},{rootMargin:"-40px 0px 0px 0px",threshold:0}),Y),ft=()=>{const e=pt();for(const t of document.querySelectorAll("[data-message-id]"))Oe.has(t)||(Oe.add(t),e.observe(t))},gt=()=>{const e=j();if(!e)return;const t=e.querySelectorAll("[data-message-id]");if(t.length===0)return;const n=e.getBoundingClientRect().top+40;for(const i of t)if(i.getBoundingClientRect().bottom>=n){const g=i.getAttribute("data-message-id");g&&g!==$()&&T(g);break}};let me=!1;const be=()=>{me||(me=!0,requestAnimationFrame(()=>{me=!1,gt(),ft()}))};a.onMount(()=>{const e=()=>be();document.addEventListener("scroll",e,!0);const t=setInterval(()=>{document.hidden||be()},4e3);a.onCleanup(()=>{document.removeEventListener("scroll",e,!0),clearInterval(t),Y?.disconnect()})});let ze=0;const oe=(e,t=2600)=>{const n=++ze;De(e),setTimeout(()=>{n===ze&&De("")},t)};a.onMount(()=>{const e=n=>{if(n.key==="Escape"){if(a.untrack(A)){Ue(),n.stopPropagation();return}a.untrack(S)&&(a.untrack(_)?pe(!1):I(!1),n.stopPropagation())}},t=n=>{if(!a.untrack(S)||a.untrack(_))return;(n.composedPath?.()??[]).some(d=>d instanceof Element&&d.closest?.(".sr-rail"))||I(!1)};window.addEventListener("keydown",e,!0),document.addEventListener("pointerdown",t,!0),a.onCleanup(()=>{window.removeEventListener("keydown",e,!0),document.removeEventListener("pointerdown",t,!0)})}),a.onMount(()=>{const e=()=>oe(`${rt} · ${ke(s)}`),t=()=>{I(i=>!i),V()},n=()=>{pe(i=>!i),a.untrack(S)||(I(!0),V())};window.addEventListener(nt,e),window.addEventListener(Ce,t),window.addEventListener(Ee,n),a.onCleanup(()=>{window.removeEventListener(nt,e),window.removeEventListener(Ce,t),window.removeEventListener(Ee,n)})});let we="",C=0,Fe=0,Je="";const ie=e=>{try{const t=e.querySelector('[data-slot="user-message-text"]')??e.querySelector('[data-slot="user-message-body"]')??e;t.classList.remove("sr-flash-ring"),t.offsetWidth,t.classList.add("sr-flash-ring"),setTimeout(()=>t.classList.remove("sr-flash-ring"),1900)}catch{}},Ne=()=>{for(const e of c())if(E(e.id))return e},q=(e,t)=>{Ht(`${e}#${Se}${t}`),we=t},ae=()=>{const t=j()?.querySelectorAll("[data-message-id]")??[];if(t.length===0)return"0";const n=t[0].getAttribute("data-message-id")??"",i=t[t.length-1].getAttribute("data-message-id")??"";return`${t.length}:${n}:${i}`},He=(e,t,n,i)=>new Promise(g=>{let d=!1,v,o,u;const f=Date.now();let b=ae(),w=f,y=0;const p=M=>{if(!d){d=!0;try{v?.disconnect()}catch{}clearInterval(o),clearTimeout(u),g(M??null)}},x=M=>{if(t!==C)return p(null);const Z=M?E(e):document.getElementById(`${Se}${e}`);if(Z)return p(Z);if(!M)return;const Xe=ae(),et=Date.now();if(Xe!==b){b=Xe,w=et,y++,i?.(y);return}const vt=y>0?Ot:zt;et-w>vt&&p(null)},L=E(e);if(L)return p(L);v=new MutationObserver(()=>x(!1)),v.observe(document.documentElement,{childList:!0,subtree:!0}),o=setInterval(()=>x(!0),120),u=setTimeout(()=>p(E(e)),Math.max(400,n))}),Ue=()=>{C++,X(!1),z(0),J(0),N(0),te(""),H(!1),oe("已取消跳转")},We=async e=>{if(A()&&ee()===e.id)return;const t=it(s),n=j(),i=E(e.id);if(i&&n){R()&&(le(),t&&q(t,e.id)),ue(i),ie(i);return}const g=Date.now();if(e.id===Je&&g-Fe<Ft){const h=E(e.id);if(h&&n){ue(h),ie(h);return}}if(Fe=g,Je=e.id,Me=e.id,!t){oe(`无法读取当前路由,无法定位 · ${ke(s)}`);return}const d=++C,v=k();X(!0),te(e.id),z(0),J(0),N(0),H(R()),I(!0);const o=Date.now(),u=setInterval(()=>{J(Math.round((Date.now()-o)/1e3))},500);let f=0;try{if(R()){const b=await He(e.id,d,2e4,w=>z(w));if(d!==C||k()!==v)return;if(b){le(),q(t,e.id),ue(b),ie(b);return}if(R()&&le(),H(!1),d!==C||k()!==v)return}const h=Date.now()+Rt;for(let b=0;b<qt&&Date.now()<h;b++){if(d!==C||k()!==v)return;if(N(b+1),we===e.id){const x=Ne()??c()[0];x&&(q(t,x.id),await de(st))}if(d!==C||k()!==v)return;q(t,e.id);let w=0;const y=Math.min(Bt,h-Date.now()),p=await He(e.id,d,y,x=>{w=x,z(f+x)});if(f+=w,d!==C||k()!==v)return;if(p){ue(p),ie(p);return}}d===C&&oe(`未能定位 · ${ke(s)}`)}finally{clearInterval(u),d===C&&(X(!1),z(0),J(0),N(0),te(""),H(!1))}},[R,ve]=a.createSignal(!1);let B=0,G="",xe="";const ht=()=>{const e=j();if(!e)return;const t=e.getBoundingClientRect();for(const n of e.querySelectorAll("[data-message-id]")){const i=n.getBoundingClientRect();if(i.bottom<=t.top+1)continue;const g=n.getAttribute("data-message-id");if(g)return{el:n,id:g,offset:i.top-t.top}}},Ye=e=>{if(!e)return;const t=j();if(!t)return;const n=e.el?.isConnected?e.el:E(e.id);if(!n)return;const i=t.getBoundingClientRect(),g=n.getBoundingClientRect().top-i.top-e.offset;Math.abs(g)>2&&(t.scrollTop+=g)},le=()=>{B++,ve(!1)},V=()=>{const e=k();!e||R()||G!==e&&(c().length<15||mt())},mt=async()=>{if(R())return;const e=it(s),t=c()[0],n=k();if(!e||!t||!n||G===n&&E(t.id))return;const i=++B,g=ht(),d=j(),v=d?d.scrollTop+d.clientHeight>=d.scrollHeight-32:!0;ve(!0);try{q(e,t.id);const o=Date.now()+6e4;let u=ae(),f=Date.now(),h=0;const b=setInterval(()=>Ye(g),150);try{for(;Date.now()<o&&i===B&&!(E(t.id)||(await de(250),i!==B));){const y=Date.now(),p=ae();if(p!==u)u=p,f=y,h=0;else if(y-f>5e3){if(h>=2)break;const x=Ne();if(x&&x.id!==t.id&&(q(e,x.id),await de(st),i!==B))break;q(e,t.id),f=Date.now(),h++}}}finally{clearInterval(b)}if(i!==B)return;const w=!!E(t.id);if(Ye(g),v){const y=j();y&&(y.scrollTop=y.scrollHeight)}w?G=n:xe=n}finally{i===B&&ve(!1)}};let Ge=a.untrack(k);a.createEffect(()=>{const e=k();e!==Ge&&(Ge=e,le(),C++,X(!1),z(0),J(0),N(0),te(""),H(!1))}),a.createEffect(()=>{const e=k(),t=c().length,n=R();if(!e||t<15||n||G===e||xe===e||U||ne!==e)return;const i=setTimeout(()=>V(),1200);a.onCleanup(()=>clearTimeout(i))});const K=a.createMemo(()=>{const e=c(),t=Q().trim().toLowerCase();return t?e.filter(n=>n.text.toLowerCase().includes(t)):e}),[ce,Ve]=a.createSignal(-1),Ke=()=>Ve(-1),Ze=e=>{const t=K();if(t.length===0)return;let n=ce();if(n<0){const i=$();n=t.findIndex(g=>g.id===i),n<0&&(n=e>0?-1:0)}n=Math.min(t.length-1,Math.max(0,n+e)),Ve(n),requestAnimationFrame(()=>{document.querySelector(`.sr-list .sr-turn[data-index="${n}"]`)?.scrollIntoView({block:"nearest"})})},bt=()=>{const e=K()[ce()];e&&We(e)};let ye;const Qe=e=>{e.key==="ArrowDown"?(e.preventDefault(),e.stopPropagation(),Ze(1)):e.key==="ArrowUp"?(e.preventDefault(),e.stopPropagation(),Ze(-1)):e.key==="Enter"&&ce()>=0&&(e.preventDefault(),e.stopPropagation(),bt())};a.createEffect(()=>{A()&&ee()&&requestAnimationFrame(()=>{try{document.querySelector('.sr-list .sr-turn[data-busy="true"]')?.scrollIntoView({block:"nearest"})}catch{}})});const wt=[r.createComponent(a.Show,{get when(){return O()},get children(){var e=tt();return r.insert(e,O),e}}),(()=>{var e=Et(),t=e.firstChild,n=t.firstChild,i=n.firstChild,g=i.firstChild,d=i.nextSibling,v=t.nextSibling;return e.addEventListener("mouseleave",()=>{!A()&&!_()&&I(!1)}),e.addEventListener("mouseenter",()=>{I(!0),V()}),r.insert(t,r.createComponent(a.Show,{get when(){return A()},get fallback(){return(()=>{var o=_t();return o.firstChild,r.insert(o,(()=>{var u=r.memo(()=>c().length>0);return()=>u()?` · ${c().length}`:""})(),null),r.insert(o,r.createComponent(a.Show,{get when(){return Q().trim()},get children(){return[" · 匹配 ",r.memo(()=>K().length)]}}),null),o})()},get children(){var o=xt();return r.insert(o,()=>je()?"预热中 · 稍等一下":"加载历史中",null),r.insert(o,(()=>{var u=r.memo(()=>Le()>0);return()=>u()?` · ${Le()} 页`:""})(),null),r.insert(o,(()=>{var u=r.memo(()=>Pe()>0);return()=>u()?` · ${Pe()}s`:""})(),null),o}}),n),i.$$click=()=>pe(!_()),r.insert(g,r.createComponent(a.Show,{get when(){return _()},get fallback(){return[At(),Mt()]},get children(){return[yt(),kt()]}})),d.$$click=()=>{A()?Ue():I(!1)},r.insert(e,r.createComponent(a.Show,{get when(){return c().length>=8},get children(){var o=St(),u=o.firstChild;u.$$keydown=Qe,u.$$input=h=>{Ie(h.currentTarget.value),Ke()};var f=ye;return typeof f=="function"?r.use(f,u):ye=u,r.insert(o,r.createComponent(a.Show,{get when(){return Q()},get children(){var h=$t();return h.$$click=()=>{Ie(""),Ke(),ye?.focus()},h}}),null),r.effect(()=>u.value=Q()),o}}),v),v.$$keydown=Qe,r.insert(v,r.createComponent(a.Show,{get when(){return K().length>0},get fallback(){return(()=>{var o=Tt();return r.insert(o,()=>lt()||`… ${rt}`),o})()},get children(){return r.createComponent(a.For,{get each(){return K()},children:(o,u)=>(()=>{var f=It(),h=f.firstChild,b=h.firstChild;b.firstChild;var w=b.nextSibling,y=h.nextSibling;return f.$$click=()=>{We(o)},r.insert(b,()=>c().indexOf(o)+1,null),r.insert(w,()=>Jt(o.created)),r.insert(h,()=>o.text,null),r.insert(f,r.createComponent(a.Show,{get when(){return r.memo(()=>!!A())()&&ee()===o.id},get children(){var p=Dt();return r.insert(p,()=>je()?"稍等":"加载中"),p}}),y),r.effect(p=>{var x=$()===o.id?"true":"false",L=ce()===u()?"true":"false",M=A()&&ee()===o.id?"true":"false",Z=u();return x!==p.e&&r.setAttribute(f,"data-active",p.e=x),L!==p.t&&r.setAttribute(f,"data-cursor",p.t=L),M!==p.a&&r.setAttribute(f,"data-busy",p.a=M),Z!==p.o&&r.setAttribute(f,"data-index",p.o=Z),p},{e:void 0,t:void 0,a:void 0,o:void 0}),f})()})}})),r.insert(e,r.createComponent(a.Show,{get when(){return r.memo(()=>!!S())()&&O()},get children(){var o=Ct();return r.insert(o,O),o}}),null),r.effect(o=>{var u=S()?"true":"false",f=A()?"true":"false",h=_()?"true":"false",b=S()?"dialog":void 0,w=S()?"会话轮次":void 0,y=_()?"true":"false",p=_()?"取消固定 (⌘⌥P)":"固定面板 (⌘⌥P)",x=_()?"取消固定面板":"固定面板",L=_()?"true":"false",M=A()?"取消跳转 (Esc)":"关闭 (Esc)";return u!==o.e&&r.setAttribute(e,"data-open",o.e=u),f!==o.t&&r.setAttribute(e,"data-jumping",o.t=f),h!==o.a&&r.setAttribute(e,"data-pinned",o.a=h),b!==o.o&&r.setAttribute(e,"role",o.o=b),w!==o.i&&r.setAttribute(e,"aria-label",o.i=w),y!==o.n&&r.setAttribute(i,"data-on",o.n=y),p!==o.s&&r.setAttribute(i,"title",o.s=p),x!==o.h&&r.setAttribute(i,"aria-label",o.h=x),L!==o.r&&r.setAttribute(i,"aria-pressed",o.r=L),M!==o.d&&r.setAttribute(d,"title",o.d=M),o},{e:void 0,t:void 0,a:void 0,o:void 0,i:void 0,n:void 0,s:void 0,h:void 0,r:void 0,d:void 0}),e})(),r.createComponent(a.Show,{get when(){return r.memo(()=>!S())()&&O()},get children(){var e=tt();return r.insert(e,O),e}})];return r.createComponent(r.Portal,{get mount(){return document.body},children:wt})}const Yt=F.Plugin.define({i18n:{en:{"command.jump":"Session turns: show / jump","command.jump.description":"Reveal the turn rail; click a turn to jump to that message","command.pin":"Session turns: pin panel","command.pin.description":"Toggle pinning the turn rail open (⌘⌥P)"}},setup(l){l.add(F.Style,Ut),l.add(F.Slot,{at:"window.bottom",render:()=>r.createComponent(a.Show,{get when(){return l.sessions.current()},get children(){return r.createComponent(Wt,{session:()=>l.sessions.current()})}})}),l.add(F.Command,()=>({id:"jump",title:l.t("command.jump"),description:l.t("command.jump.description"),section:"navigation",bind:"mod+alt+r",slash:{name:"turns"},run:()=>{window.dispatchEvent(new Event(Ce))}})),l.add(F.Command,()=>({id:"pin",title:l.t("command.pin"),description:l.t("command.pin.description"),section:"navigation",bind:"mod+alt+p",slash:{name:"turns-pin"},run:()=>{window.dispatchEvent(new Event(Ee))}}))}});r.delegateEvents(["click","input","keydown"]);exports.default=Yt;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-session-turns",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
4
4
  "description": "OpenCode Desktop 插件:在会话右缘列出每一轮用户提问,点击即可跳转到对应消息。A right-edge rail of user turns; click one to jump to that message.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -39,7 +39,7 @@
39
39
  "scripts": {
40
40
  "build": "vite build",
41
41
  "dev": "vite build --watch",
42
- "prepublishOnly": "npm run build"
42
+ "prepublishOnly": "node scripts/ensure-ci-publish.mjs && npm run build"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@opencode/plugin": "^2.2.6",
package/src/gui.tsx CHANGED
@@ -1,15 +1,16 @@
1
1
  import { Command, Plugin, Slot, Style, usePlugin } from "@opencode/plugin/gui"
2
- import { For, Show, createEffect, createSignal, onCleanup, onMount, untrack } from "solid-js"
2
+ import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount, untrack } from "solid-js"
3
3
  import { Portal } from "solid-js/web"
4
4
 
5
5
  // ---------------------------------------------------------------------------
6
6
  // constants / helpers
7
7
  // ---------------------------------------------------------------------------
8
8
 
9
- const VERSION = "v20"
9
+ const VERSION = "v21"
10
10
  const HASH_PREFIX = "message-"
11
11
  const DIAG_EVENT = "session-turns:diag"
12
12
  const OPEN_EVENT = "session-turns:open"
13
+ const PIN_EVENT = "session-turns:pin"
13
14
 
14
15
  // paging: a full load walks every page; a light refresh pulls just the newest
15
16
  // page (new messages always land at the head of a desc listing).
@@ -53,7 +54,21 @@ const pad = (value) => String(value).padStart(2, "0")
53
54
 
54
55
  const formatTime = (created) => {
55
56
  const date = new Date(created ?? 0)
56
- return `${pad(date.getHours())}:${pad(date.getMinutes())}`
57
+ const now = new Date()
58
+ const sameDay =
59
+ date.getFullYear() === now.getFullYear() &&
60
+ date.getMonth() === now.getMonth() &&
61
+ date.getDate() === now.getDate()
62
+ if (sameDay) return `${pad(date.getHours())}:${pad(date.getMinutes())}`
63
+ const yesterday = new Date(now)
64
+ yesterday.setDate(now.getDate() - 1)
65
+ const wasYesterday =
66
+ date.getFullYear() === yesterday.getFullYear() &&
67
+ date.getMonth() === yesterday.getMonth() &&
68
+ date.getDate() === yesterday.getDate()
69
+ if (wasYesterday) return `昨天 ${pad(date.getHours())}:${pad(date.getMinutes())}`
70
+ // Older turns need a date: a bare HH:mm is ambiguous in multi-day sessions.
71
+ return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
57
72
  }
58
73
 
59
74
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
@@ -239,6 +254,73 @@ const CSS = `
239
254
  color: rgba(255,170,60,.95);
240
255
  font-variant-numeric: tabular-nums;
241
256
  }
257
+ .sr-head-actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
258
+ .sr-iconbtn {
259
+ display: inline-flex;
260
+ align-items: center;
261
+ justify-content: center;
262
+ width: 22px;
263
+ height: 22px;
264
+ padding: 0;
265
+ border: 0;
266
+ background: transparent;
267
+ color: inherit;
268
+ cursor: pointer;
269
+ border-radius: 4px;
270
+ opacity: .5;
271
+ }
272
+ .sr-iconbtn svg { width: 13px; height: 13px; }
273
+ .sr-iconbtn:hover { opacity: 1; background: var(--surface-base-hover, rgba(127,127,127,.14)); }
274
+ .sr-iconbtn[data-on="true"] { opacity: .95; color: rgba(255,170,60,.95); }
275
+ .sr-iconbtn:focus-visible { outline: 1px solid currentColor; outline-offset: -1px; }
276
+ /* search box */
277
+ .sr-search {
278
+ position: relative;
279
+ flex-shrink: 0;
280
+ margin: 0 0 6px;
281
+ }
282
+ .sr-search-input {
283
+ width: 100%;
284
+ box-sizing: border-box;
285
+ height: 26px;
286
+ padding: 0 24px 0 8px;
287
+ border: 1px solid rgba(127,127,127,.25);
288
+ border-radius: 6px;
289
+ background: color-mix(in srgb, currentColor 6%, transparent);
290
+ color: inherit;
291
+ font: inherit;
292
+ font-size: 12px;
293
+ outline: none;
294
+ transition: border-color .12s ease, box-shadow .12s ease;
295
+ }
296
+ .sr-search-input::placeholder { color: currentColor; opacity: .4; }
297
+ .sr-search-input:focus {
298
+ border-color: rgba(255,170,60,.6);
299
+ box-shadow: 0 0 0 2px rgba(255,170,60,.14);
300
+ }
301
+ .sr-search-clear {
302
+ position: absolute;
303
+ right: 4px;
304
+ top: 50%;
305
+ transform: translateY(-50%);
306
+ display: inline-flex;
307
+ align-items: center;
308
+ justify-content: center;
309
+ width: 16px;
310
+ height: 16px;
311
+ padding: 0;
312
+ border: 0;
313
+ border-radius: 50%;
314
+ background: transparent;
315
+ color: inherit;
316
+ font-size: 12px;
317
+ line-height: 1;
318
+ cursor: pointer;
319
+ opacity: .5;
320
+ }
321
+ .sr-search-clear:hover { opacity: 1; background: var(--surface-base-hover, rgba(127,127,127,.14)); }
322
+ /* keyboard cursor (↑/↓) */
323
+ .sr-turn[data-cursor="true"] { background: var(--surface-base-hover, rgba(127,127,127,.14)); }
242
324
  .sr-close {
243
325
  border: 0;
244
326
  background: transparent;
@@ -435,6 +517,12 @@ const CSS = `
435
517
  white-space: pre-wrap;
436
518
  max-width: 260px;
437
519
  }
520
+ /* Reduced motion: keep the signals (colors, widths) but drop the animation. */
521
+ @media (prefers-reduced-motion: reduce) {
522
+ .sr-rail, .sr-dash, .sr-text { transition: none !important; }
523
+ .sr-turn[data-busy="true"] .sr-dash { animation: none !important; opacity: 1 !important; }
524
+ .sr-flash-ring { animation: none !important; box-shadow: inset 0 0 0 2px rgba(255,170,60,.5) !important; }
525
+ }
438
526
  `
439
527
 
440
528
  // ---------------------------------------------------------------------------
@@ -448,6 +536,10 @@ function Rail(props) {
448
536
  const [open, setOpen] = createSignal(false)
449
537
  const [note, setNote] = createSignal("")
450
538
  const [status, setStatus] = createSignal("")
539
+ // Search box over the turn list; empty means show everything.
540
+ const [query, setQuery] = createSignal("")
541
+ // Pinned panels stay open when the pointer leaves (toggle: ⌘⌥P / /turns pin).
542
+ const [pinned, setPinned] = createSignal(false)
451
543
  // Jump progress. While `jumping` is true the panel stays pinned open and the
452
544
  // header reports what the host is doing; `busyID` marks the target row.
453
545
  const [jumping, setJumping] = createSignal(false)
@@ -662,9 +754,11 @@ function Rail(props) {
662
754
 
663
755
  // ---- active-turn tracking ----------------------------------------------
664
756
  // Driven by scroll events (captured at document level so it also sees the
665
- // real scroller without querying first) plus one rAF-throttled pass, with a
666
- // slow interval as a fallback for rows mounting without a scroll (history
667
- // paging, virtual list swaps).
757
+ // real scroller without querying first) plus one rAF-throttled pass. An
758
+ // IntersectionObserver on the timeline rows replaces the old 1.5s interval:
759
+ // a row leaving the sentinel line at the top of the viewport means the turn
760
+ // under it became active, with zero work when nothing moves (and none at
761
+ // all in a background window, where rAF is throttled to zero).
668
762
  let scrollerCache
669
763
  const scroller = () => {
670
764
  if (scrollerCache?.isConnected) return scrollerCache
@@ -672,6 +766,35 @@ function Rail(props) {
672
766
  return scrollerCache
673
767
  }
674
768
 
769
+ // Rows already wired to the observer, keyed by element so history paging
770
+ // (rows mounting/unmounting) never re-observes the same node.
771
+ const observed = new WeakSet()
772
+ let rowObserver
773
+ const ensureObserver = () => {
774
+ if (rowObserver) return rowObserver
775
+ rowObserver = new IntersectionObserver(
776
+ (entries) => {
777
+ // Any intersection change along the sentinel line can shift which turn
778
+ // is topmost; recompute from the DOM (rAF-throttled, cheap).
779
+ if (entries.length === 0) return
780
+ scheduleTrack()
781
+ },
782
+ { rootMargin: "-40px 0px 0px 0px", threshold: 0 },
783
+ )
784
+ return rowObserver
785
+ }
786
+ // Wire up rows that appeared since the last pass. Called from trackActive,
787
+ // so it only runs while the document is actually scrolling (rAF-gated).
788
+ const observeRows = () => {
789
+ const observer = ensureObserver()
790
+ for (const row of document.querySelectorAll("[data-message-id]")) {
791
+ if (!observed.has(row)) {
792
+ observed.add(row)
793
+ observer.observe(row)
794
+ }
795
+ }
796
+ }
797
+
675
798
  const trackActive = () => {
676
799
  const sc = scroller()
677
800
  if (!sc) return
@@ -681,7 +804,7 @@ function Rail(props) {
681
804
  for (const row of rows) {
682
805
  if (row.getBoundingClientRect().bottom >= top) {
683
806
  const id = row.getAttribute("data-message-id")
684
- if (id) setActive(id)
807
+ if (id && id !== active()) setActive(id)
685
808
  break
686
809
  }
687
810
  }
@@ -694,16 +817,25 @@ function Rail(props) {
694
817
  requestAnimationFrame(() => {
695
818
  rafPending = false
696
819
  trackActive()
820
+ observeRows()
697
821
  })
698
822
  }
699
823
 
700
824
  onMount(() => {
701
825
  const onScroll = () => scheduleTrack()
702
826
  document.addEventListener("scroll", onScroll, true)
703
- const fallback = setInterval(scheduleTrack, 1500)
827
+ // One slow liveness pass only while the tab is visible: catches rows that
828
+ // mount without a scroll event and reconnects after DOM swaps, without
829
+ // burning cycles in background windows (rAF never fires there, and the
830
+ // interval self-pauses via document.hidden).
831
+ const fallback = setInterval(() => {
832
+ if (document.hidden) return
833
+ scheduleTrack()
834
+ }, 4000)
704
835
  onCleanup(() => {
705
836
  document.removeEventListener("scroll", onScroll, true)
706
837
  clearInterval(fallback)
838
+ rowObserver?.disconnect()
707
839
  })
708
840
  })
709
841
 
@@ -731,7 +863,12 @@ function Rail(props) {
731
863
  return
732
864
  }
733
865
  if (untrack(open)) {
734
- setOpen(false)
866
+ // Unpin first when pinned; a second Esc then closes the panel.
867
+ if (untrack(pinned)) {
868
+ setPinned(false)
869
+ } else {
870
+ setOpen(false)
871
+ }
735
872
  // Only swallow Esc while the rail is actually open, so the host keeps
736
873
  // its own Escape handling the rest of the time.
737
874
  event.stopPropagation()
@@ -739,6 +876,7 @@ function Rail(props) {
739
876
  }
740
877
  const onPointerDown = (event) => {
741
878
  if (!untrack(open)) return
879
+ if (untrack(pinned)) return
742
880
  const path = event.composedPath?.() ?? []
743
881
  const inside = path.some((node) => node instanceof Element && node.closest?.(".sr-rail"))
744
882
  if (!inside) setOpen(false)
@@ -753,17 +891,27 @@ function Rail(props) {
753
891
 
754
892
  onMount(() => {
755
893
  const onDiag = () => flash(`${VERSION} · ${diagnostics(ctx)}`)
756
- // /turns toggles the panel; Esc / outside click / × also close it. No more
757
- // forced auto-close after 4s.
894
+ // /turns toggles the panel; ⌘⌥P / the pin button toggles "pinned" (stay
895
+ // open when the pointer leaves). Esc / outside click / × still close an
896
+ // unpinned panel.
758
897
  const onOpen = () => {
759
898
  setOpen((value) => !value)
760
899
  maybePrewarm()
761
900
  }
901
+ const onPin = () => {
902
+ setPinned((value) => !value)
903
+ if (!untrack(open)) {
904
+ setOpen(true)
905
+ maybePrewarm()
906
+ }
907
+ }
762
908
  window.addEventListener(DIAG_EVENT, onDiag)
763
909
  window.addEventListener(OPEN_EVENT, onOpen)
910
+ window.addEventListener(PIN_EVENT, onPin)
764
911
  onCleanup(() => {
765
912
  window.removeEventListener(DIAG_EVENT, onDiag)
766
913
  window.removeEventListener(OPEN_EVENT, onOpen)
914
+ window.removeEventListener(PIN_EVENT, onPin)
767
915
  })
768
916
  // No startup toast: the plugin should be invisible when everything works.
769
917
  })
@@ -1201,8 +1349,64 @@ function Rail(props) {
1201
1349
  onCleanup(() => clearTimeout(timer))
1202
1350
  })
1203
1351
 
1204
- // Keep the busy row visible in the panel list while the jump pages through
1205
- // history, so the "which turn am I waiting for" answer stays on screen.
1352
+ // ---- search / keyboard navigation --------------------------------------
1353
+ // Filtered view of the turns; identity-stable so <For> keeps DOM nodes.
1354
+ const visibleTurns = createMemo(() => {
1355
+ const list = turns()
1356
+ const needle = query().trim().toLowerCase()
1357
+ if (!needle) return list
1358
+ return list.filter((turn) => turn.text.toLowerCase().includes(needle))
1359
+ })
1360
+
1361
+ // The row the ↑/↓ keys currently point at (index within visibleTurns).
1362
+ const [cursorIndex, setCursorIndex] = createSignal(-1)
1363
+ const clearCursor = () => setCursorIndex(-1)
1364
+
1365
+ const moveCursor = (delta) => {
1366
+ const list = visibleTurns()
1367
+ if (list.length === 0) return
1368
+ let index = cursorIndex()
1369
+ if (index < 0) {
1370
+ // Start from the active turn when the cursor is fresh, so ↓ walks to the
1371
+ // next turn relative to where the viewport already is.
1372
+ const activeID = active()
1373
+ index = list.findIndex((turn) => turn.id === activeID)
1374
+ if (index < 0) index = delta > 0 ? -1 : 0
1375
+ }
1376
+ index = Math.min(list.length - 1, Math.max(0, index + delta))
1377
+ setCursorIndex(index)
1378
+ requestAnimationFrame(() => {
1379
+ document
1380
+ .querySelector(`.sr-list .sr-turn[data-index="${index}"]`)
1381
+ ?.scrollIntoView({ block: "nearest" })
1382
+ })
1383
+ }
1384
+
1385
+ const activateCursor = () => {
1386
+ const turn = visibleTurns()[cursorIndex()]
1387
+ if (!turn) return
1388
+ void jump(turn)
1389
+ }
1390
+
1391
+ // The search input, for focus round-trips.
1392
+ let searchInput: HTMLInputElement | undefined
1393
+ const listKeyDown = (event) => {
1394
+ if (event.key === "ArrowDown") {
1395
+ event.preventDefault()
1396
+ event.stopPropagation()
1397
+ moveCursor(1)
1398
+ } else if (event.key === "ArrowUp") {
1399
+ event.preventDefault()
1400
+ event.stopPropagation()
1401
+ moveCursor(-1)
1402
+ } else if (event.key === "Enter" && cursorIndex() >= 0) {
1403
+ event.preventDefault()
1404
+ event.stopPropagation()
1405
+ activateCursor()
1406
+ }
1407
+ }
1408
+
1409
+ // Keep the highlighted row visible while a jump pages through history.
1206
1410
  createEffect(() => {
1207
1411
  if (!jumping()) return
1208
1412
  if (!busyID()) return
@@ -1224,22 +1428,30 @@ function Rail(props) {
1224
1428
  class="sr-rail"
1225
1429
  data-open={open() ? "true" : "false"}
1226
1430
  data-jumping={jumping() ? "true" : "false"}
1431
+ data-pinned={pinned() ? "true" : "false"}
1432
+ role={open() ? "dialog" : undefined}
1433
+ aria-label={open() ? "会话轮次" : undefined}
1227
1434
  onMouseEnter={() => {
1228
1435
  setOpen(true)
1229
1436
  maybePrewarm()
1230
1437
  }}
1231
- // Keep the panel pinned while a long history jump runs; otherwise the
1232
- // mouse drifting off the rail hides exactly the progress the user is
1233
- // waiting for. Esc / × still close (or cancel) it.
1438
+ // Keep the panel pinned while a long history jump runs or when the
1439
+ // user pinned it; otherwise the mouse drifting off the rail hides
1440
+ // exactly the progress the user is waiting for. Esc / × still close
1441
+ // (or cancel) it.
1234
1442
  onMouseLeave={() => {
1235
- if (!jumping()) setOpen(false)
1443
+ if (!jumping() && !pinned()) setOpen(false)
1236
1444
  }}
1237
1445
  >
1238
1446
  <div class="sr-head">
1239
1447
  <Show
1240
1448
  when={jumping()}
1241
1449
  fallback={
1242
- <span class="sr-title">会话轮次{turns().length > 0 ? ` · ${turns().length}` : ""}</span>
1450
+ <span class="sr-title">
1451
+ 会话轮次
1452
+ {turns().length > 0 ? ` · ${turns().length}` : ""}
1453
+ <Show when={query().trim()}> · 匹配 {visibleTurns().length}</Show>
1454
+ </span>
1243
1455
  }
1244
1456
  >
1245
1457
  <span class="sr-title sr-progress">
@@ -1248,31 +1460,93 @@ function Rail(props) {
1248
1460
  {jumpElapsed() > 0 ? ` · ${jumpElapsed()}s` : ""}
1249
1461
  </span>
1250
1462
  </Show>
1251
- <button
1252
- type="button"
1253
- class="sr-close"
1254
- title={jumping() ? "取消跳转 (Esc)" : "关闭 (Esc)"}
1255
- onClick={() => {
1256
- if (jumping()) cancelJump()
1257
- else setOpen(false)
1258
- }}
1259
- >
1260
- ×
1261
- </button>
1463
+ <span class="sr-head-actions">
1464
+ <button
1465
+ type="button"
1466
+ class="sr-iconbtn"
1467
+ data-on={pinned() ? "true" : "false"}
1468
+ title={pinned() ? "取消固定 (⌘⌥P)" : "固定面板 (⌘⌥P)"}
1469
+ aria-label={pinned() ? "取消固定面板" : "固定面板"}
1470
+ aria-pressed={pinned() ? "true" : "false"}
1471
+ onClick={() => setPinned(!pinned())}
1472
+ >
1473
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1474
+ <Show
1475
+ when={pinned()}
1476
+ fallback={
1477
+ <>
1478
+ <path d="M9.5 2.5l4 4-2.6.9-1.4 3.5-4.4-4.4L8.6 5.1z" />
1479
+ <path d="M5.1 6.5L2.5 9.1l4.4 4.4 2.6-2.6" />
1480
+ </>
1481
+ }
1482
+ >
1483
+ <path d="M6 3l7 7-3.5.5L7 13 3 9l2.5-2.5z" />
1484
+ <path d="M4 12l-1.5 1.5" />
1485
+ </Show>
1486
+ </svg>
1487
+ </button>
1488
+ <button
1489
+ type="button"
1490
+ class="sr-close"
1491
+ title={jumping() ? "取消跳转 (Esc)" : "关闭 (Esc)"}
1492
+ onClick={() => {
1493
+ if (jumping()) cancelJump()
1494
+ else setOpen(false)
1495
+ }}
1496
+ >
1497
+ ×
1498
+ </button>
1499
+ </span>
1262
1500
  </div>
1263
- <div class="sr-list">
1264
- <Show when={turns().length > 0} fallback={<div class="sr-note">{note() || `… ${VERSION}`}</div>}>
1265
- <For each={turns()}>
1501
+ <Show when={turns().length >= 8}>
1502
+ <div class="sr-search">
1503
+ <input
1504
+ ref={searchInput}
1505
+ type="text"
1506
+ class="sr-search-input"
1507
+ placeholder="搜索轮次…"
1508
+ value={query()}
1509
+ onInput={(event) => {
1510
+ setQuery(event.currentTarget.value)
1511
+ clearCursor()
1512
+ }}
1513
+ onKeyDown={listKeyDown}
1514
+ />
1515
+ <Show when={query()}>
1516
+ <button
1517
+ type="button"
1518
+ class="sr-search-clear"
1519
+ aria-label="清除搜索"
1520
+ onClick={() => {
1521
+ setQuery("")
1522
+ clearCursor()
1523
+ searchInput?.focus()
1524
+ }}
1525
+ >
1526
+ ×
1527
+ </button>
1528
+ </Show>
1529
+ </div>
1530
+ </Show>
1531
+ <div
1532
+ class="sr-list"
1533
+ onKeyDown={listKeyDown}
1534
+ tabIndex={-1}
1535
+ >
1536
+ <Show when={visibleTurns().length > 0} fallback={<div class="sr-note">{note() || `… ${VERSION}`}</div>}>
1537
+ <For each={visibleTurns()}>
1266
1538
  {(turn, index) => (
1267
1539
  <button
1268
1540
  type="button"
1269
1541
  class="sr-turn"
1270
1542
  data-active={active() === turn.id ? "true" : "false"}
1543
+ data-cursor={cursorIndex() === index() ? "true" : "false"}
1271
1544
  data-busy={jumping() && busyID() === turn.id ? "true" : "false"}
1545
+ data-index={index()}
1272
1546
  onClick={() => void jump(turn)}
1273
1547
  >
1274
1548
  <span class="sr-text">
1275
- <span class="sr-num">#{index() + 1}</span>
1549
+ <span class="sr-num">#{turns().indexOf(turn) + 1}</span>
1276
1550
  <span class="sr-time">{formatTime(turn.created)}</span>
1277
1551
  {turn.text}
1278
1552
  </span>
@@ -1312,6 +1586,8 @@ export default Plugin.define({
1312
1586
  en: {
1313
1587
  "command.jump": "Session turns: show / jump",
1314
1588
  "command.jump.description": "Reveal the turn rail; click a turn to jump to that message",
1589
+ "command.pin": "Session turns: pin panel",
1590
+ "command.pin.description": "Toggle pinning the turn rail open (⌘⌥P)",
1315
1591
  },
1316
1592
  },
1317
1593
  setup(ctx) {
@@ -1333,7 +1609,6 @@ export default Plugin.define({
1333
1609
  id: "jump",
1334
1610
  title: ctx.t("command.jump"),
1335
1611
  description: ctx.t("command.jump.description"),
1336
- group: ctx.t("command.jump"),
1337
1612
  section: "navigation",
1338
1613
  bind: "mod+alt+r",
1339
1614
  slash: { name: "turns" },
@@ -1341,5 +1616,17 @@ export default Plugin.define({
1341
1616
  window.dispatchEvent(new Event(OPEN_EVENT))
1342
1617
  },
1343
1618
  }))
1619
+
1620
+ ctx.add(Command, () => ({
1621
+ id: "pin",
1622
+ title: ctx.t("command.pin"),
1623
+ description: ctx.t("command.pin.description"),
1624
+ section: "navigation",
1625
+ bind: "mod+alt+p",
1626
+ slash: { name: "turns-pin" },
1627
+ run: () => {
1628
+ window.dispatchEvent(new Event(PIN_EVENT))
1629
+ },
1630
+ }))
1344
1631
  },
1345
1632
  })