opencode-notepad 0.0.0-stage → 0.0.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/LICENSE ADDED
@@ -0,0 +1,46 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 parzival1l
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ src/gui.css adapts task-list styling rules from Markdown Preview
26
+ (https://github.com/pluk-inc/markdown-preview), used under the MIT License:
27
+
28
+ Copyright (c) 2026 Pluk
29
+
30
+ Permission is hereby granted, free of charge, to any person obtaining a copy
31
+ of this software and associated documentation files (the "Software"), to deal
32
+ in the Software without restriction, including without limitation the rights
33
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
34
+ copies of the Software, and to permit persons to whom the Software is
35
+ furnished to do so, subject to the following conditions:
36
+
37
+ The above copyright notice and this permission notice shall be included in all
38
+ copies or substantial portions of the Software.
39
+
40
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
41
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
42
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
43
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
44
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
45
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
46
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,81 @@
1
- # Temporary Holding Version
1
+ # opencode-notepad
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An OpenCode Desktop plugin that adds a Markdown notepad to the session side panel, one per chat. The note's top section is a checklist. The coding agent can add to it, so "add this to the list" in chat is enough.
4
+
5
+ ## Parts
6
+
7
+ - **Server** (`src/index.ts`) owns the note file and serializes every edit. It registers the agent tools and the `notepad` RPC.
8
+ - **GUI** (`src/gui.tsx`, built to `dist/gui-experimental.cjs`) adds the **Notepad** side tab, a composer pill with the open-item count, and the **Toggle Notepad** command (`mod+alt+n`).
9
+ - **RPC** (`src/rpc.ts`) is the contract between the two parts. The server emits `updated` on every change, including edits made in other apps.
10
+
11
+ ## Send a selection
12
+
13
+ Highlight text anywhere in the window, such as part of a chat message. A floating bar appears above it:
14
+
15
+ - **Quote** attaches the selection to your next message as a quote and opens its comment box, in one step.
16
+ - **Add to list** adds each selected line as a checklist item. It drops list markers like `-`, `1.`, and `•`.
17
+ - **Add to notes** appends the selection to the notes as a blockquote.
18
+
19
+ The keyboard does the same: `mod+alt+l` sends the selection to the list, and `mod+alt+k` sends it to the notes. The bar stays hidden for selections inside inputs, the composer, and the Notepad itself. The GUI API has no hook into the timeline, so the bar reads the window's selection. Selected text arrives as plain text, without Markdown formatting.
20
+
21
+ ## Note format
22
+
23
+ Each chat has its own plain Markdown file, `~/.local/share/opencode-notepad/<session ID>.md` (under `$XDG_DATA_HOME` when set). A subagent writes to its parent chat's note. A chat gets a file on its first edit. Set `options.dir` to keep the files elsewhere. The run of task lines at the very top is the checklist. Everything after the first non-task line is free Markdown.
24
+
25
+ ```markdown
26
+ - [ ] Review the citation flow
27
+ - [x] Ship the PR
28
+
29
+ ## Notes
30
+
31
+ Anything you like.
32
+ ```
33
+
34
+ ## Agent tools
35
+
36
+ All tools work on the calling chat's note.
37
+
38
+ | Tool | What it does |
39
+ |------|--------------|
40
+ | `notepad.add` | Adds checklist items. Triggered by "add this to the list", "remind me to", and similar. |
41
+ | `notepad.check` | Checks or unchecks items by text `query` or by `index`. |
42
+ | `notepad.read` | Returns the numbered checklist and the notes. |
43
+ | `notepad.append_note` | Appends Markdown to the notes section. |
44
+
45
+ ## Install
46
+
47
+ Requires OpenCode Desktop 2.2.0 or later.
48
+
49
+ 1. Add the package to `plugins` in the global `~/.config/opencode/opencode.jsonc`. OpenCode Desktop loads GUI parts only from the global config.
50
+
51
+ ```jsonc
52
+ {
53
+ "plugins": [
54
+ "opencode-notepad",
55
+ // Or keep the notes in another folder:
56
+ // { "package": "opencode-notepad", "options": { "dir": "~/notes/opencode" } },
57
+ ],
58
+ }
59
+ ```
60
+
61
+ 2. Open a session, then pick **Notepad** from the side panel's **+** menu, or press `mod+alt+n`.
62
+
63
+ OpenCode installs the package and reloads plugins when the config changes.
64
+
65
+ ## Develop
66
+
67
+ 1. Clone the repo, then run `bun install` and `bun run build`.
68
+ 2. In `plugins`, replace `"opencode-notepad"` with the absolute path of your clone.
69
+
70
+ - `bun run dev` rebuilds the GUI on every change. Each rebuild reloads the GUI in every window.
71
+ - The server reloads when a file in `src/` changes.
72
+ - `bun test` covers the note format and the file store. `bun run typecheck` runs `tsc`.
73
+
74
+ ## Markdown Preview
75
+
76
+ The toolbar button opens the note in [Markdown Preview](https://github.com/pluk-inc/markdown-preview) through its `md-preview://file/<path>` URL scheme. There you can read it with an outline, or click its checkboxes. The panel picks up those edits. The checklist styles adapt Markdown Preview's task-list rules (MIT): done items are muted, not struck through.
77
+
78
+ ## Limits
79
+
80
+ - The server runs one plugin instance per project directory. Each instance serializes its own writes. Two instances that write to one chat's note in the same few milliseconds can still lose one edit.
81
+ - The GUI API is experimental. This plugin pins `@opencode/plugin@2.2.0`, which matches OpenCode Desktop 2.2.0.
@@ -0,0 +1,5 @@
1
+ Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:`Module`}});let e=require("solid-js/web"),t=require("solid-js"),n=require("solid-js/store"),r=require("@opencode/plugin/gui"),i=require("@opencode/plugin/rpc");var a=/^\s*[-*+]\s+\[( |x|X)\]\s?(.*)$/;function o(e){let t=e.replace(/\r\n?/g,`
2
+ `).split(`
3
+ `),n=[],r=0;for(;r<t.length;r++){let e=a.exec(t[r]);if(!e)break;n.push({checked:e[1]!==` `,text:e[2].trim()})}let i=t.slice(r);for(;i.length>0&&i[0].trim()===``;)i.shift();return{tasks:n,body:i.join(`
4
+ `).trimEnd()}}function s(e){return e.replace(/^\s*(?:[-*+•]|\d+[.)])\s+(\[( |x|X)\]\s*)?/,``).replace(/\s*\n\s*/g,` `).trim()}function c(e){return e.split(/\r?\n/).map(s).filter(Boolean)}function l(e){return e.trim().split(/\r?\n/).map(e=>e.trim()?`> ${e.trimEnd()}`:`>`).join(`
5
+ `)}var u={type:`string`,minLength:1},d={type:`object`,properties:{sessionID:u,doc:{type:`string`},path:{type:`string`}},required:[`sessionID`,`doc`,`path`],additionalProperties:!1},f={type:`integer`,minimum:0};function p(e){return{type:`object`,properties:{sessionID:u,...e},required:[`sessionID`,...Object.keys(e)],additionalProperties:!1}}var m=i.Rpc.define({id:`notepad`,methods:{read:{input:p({}),output:d},add:{input:p({items:{type:`array`,items:{type:`string`}}}),output:d},check:{input:p({index:f,checked:{type:`boolean`}}),output:d},remove:{input:p({index:f}),output:d},clearDone:{input:p({}),output:d},appendNote:{input:p({markdown:{type:`string`}}),output:d},setBody:{input:p({body:{type:`string`}}),output:d}},events:{updated:{schema:d}}}),h=(0,e.template)(`<div class=notepad-selection>`),g=(0,e.template)(`<span class=notepad-selection-divider>`),_=(0,e.template)(`<button type=button>`),v=`input, textarea, select, [contenteditable=''], [contenteditable='true'], .notepad, .notepad-selection`;function y(){let e=window.getSelection();if(!e||e.isCollapsed||e.rangeCount===0)return;let t=e.toString();if(!t.trim())return;let n=e.anchorNode;if((n instanceof Element?n:n?.parentElement)?.closest(v))return;let r=e.getRangeAt(0).getBoundingClientRect();if(r.width!==0||r.height!==0)return{text:t,top:r.top,left:r.left+r.width/2}}function b(n){let[r,i]=(0,t.createSignal)(),a,o=()=>{clearTimeout(a),a=setTimeout(()=>i(y()),120)},s=()=>i(void 0);(0,t.onMount)(()=>{document.addEventListener(`selectionchange`,o),window.addEventListener(`scroll`,s,!0),window.addEventListener(`resize`,s)}),(0,t.onCleanup)(()=>{clearTimeout(a),document.removeEventListener(`selectionchange`,o),window.removeEventListener(`scroll`,s,!0),window.removeEventListener(`resize`,s)});function c(e){let t=r();t&&(window.getSelection()?.removeAllRanges(),s(),e.run(t.text))}return(0,e.createComponent)(t.Show,{get when(){return r()},children:r=>(0,e.createComponent)(e.Portal,{get children(){var i=h();return i.$$mousedown=e=>e.preventDefault(),(0,e.insert)(i,(0,e.createComponent)(t.For,{get each(){return n.actions},children:(n,r)=>[(0,e.createComponent)(t.Show,{get when(){return r()>0},get children(){return g()}}),(()=>{var t=_();return t.$$click=()=>c(n),(0,e.insert)(t,()=>n.label),t})()]})),(0,e.effect)(t=>{var n=`${r().top<48?r().top+28:r().top-40}px`,a=`${Math.min(Math.max(r().left,160),window.innerWidth-160)}px`;return n!==t.e&&(0,e.setStyleProperty)(i,`top`,t.e=n),a!==t.t&&(0,e.setStyleProperty)(i,`left`,t.t=a),t},{e:void 0,t:void 0}),i}})})}(0,e.delegateEvents)([`mousedown`,`click`]);var x=`.notepad{box-sizing:border-box;flex-direction:column;gap:8px;height:100%;padding:10px 12px 16px;display:flex;overflow-y:auto}.notepad-toolbar{align-items:center;gap:4px;display:flex}.notepad-toolbar>:not(.notepad-add){flex-shrink:0}.notepad-add{flex:1;min-width:0;margin-right:2px}.notepad-add>*{width:100%}.notepad-tasks{margin:0;padding:0;list-style:none}.notepad-task{border-radius:6px;align-items:flex-start;gap:4px;padding:2px 2px 2px 4px;display:flex}.notepad-task:hover{background:color-mix(in srgb, currentColor 6%, transparent)}.notepad-task label{cursor:pointer;overflow-wrap:anywhere;flex:1;align-items:flex-start;gap:8px;min-width:0;padding:3px 0;line-height:1.4;display:flex}.notepad-task input[type=checkbox]{width:14px;height:14px;accent-color:var(--text-interactive-base,currentColor);cursor:pointer;flex-shrink:0;margin:2px 0 0}.notepad-task.is-done label span{opacity:.5}.notepad-remove{opacity:0;flex-shrink:0}.notepad-task:hover .notepad-remove,.notepad-remove:focus-visible{opacity:1}.notepad-clear{justify-content:flex-end;display:flex}.notepad-notes-header{border-top:1px solid color-mix(in srgb, currentColor 12%, transparent);opacity:.8;justify-content:space-between;align-items:center;margin-top:4px;padding-top:8px;font-size:12px;font-weight:600;display:flex}.notepad-empty{opacity:.5;margin:0;font-size:13px}.notepad-body{min-height:2em}.notepad-editor{resize:vertical;border:1px solid color-mix(in srgb, currentColor 18%, transparent);min-height:240px;color:inherit;tab-size:2;background:0 0;border-radius:6px;flex:1;padding:8px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px;line-height:1.5}.notepad-editor:focus{border-color:color-mix(in srgb, currentColor 35%, transparent);outline:none}.notepad-selection{z-index:2147483000;color:#f4f4f4;background:#1f1f1f;border-radius:8px;align-items:center;padding:3px;font-size:12px;animation:.12s ease-out notepad-pop;display:flex;position:fixed;transform:translate(-50%);box-shadow:0 6px 20px #00000047,0 0 0 1px #ffffff14}.notepad-selection button{all:unset;cursor:pointer;white-space:nowrap;border-radius:5px;padding:4px 9px}.notepad-selection button:hover,.notepad-selection button:focus-visible{background:#ffffff24}.notepad-selection-divider{background:#ffffff2e;width:1px;height:14px}@keyframes notepad-pop{0%{opacity:0;transform:translate(-50%)translateY(4px)}}@media (prefers-reduced-motion:reduce){.notepad-selection{animation:none}}`,S=(0,e.template)(`<div class=notepad-clear>`),C=(0,e.template)(`<textarea class=notepad-editor autofocus>`),w=(0,e.template)(`<div class=notepad><div class=notepad-toolbar><div class=notepad-add></div></div><ul class=notepad-tasks></ul><div class=notepad-notes-header><span>`),T=(0,e.template)(`<li class=notepad-task><label><input type=checkbox><span>`),E=(0,e.template)(`<div class=notepad-body>`),D=(0,e.template)(`<p class=notepad-empty>`),O=r.Plugin.define({i18n:{en:{title:`Notepad`,"command.toggle":`Toggle Notepad`,"add.label":`Add a checklist item`,"add.placeholder":`Add an item and press Enter`,remove:`Remove item`,clearDone:`Clear done`,notes:`Notes`,"notes.empty":`No notes yet. Select Edit to write Markdown.`,edit:`Edit`,done:`Done`,openPreview:`Open in Markdown Preview`,openFile:`Open file in default app`,copyPath:`Copy file path`,pill:`{{count}} open`,"pill.title":`Notepad checklist`,"pill.open":`Open Notepad`,error:`Notepad: {{message}}`,"selection.quote":`Quote`,"selection.list":`Add to list`,"selection.notes":`Add to notes`,"command.selectionList":`Add selection to Notepad list`,"command.selectionNotes":`Add selection to Notepad notes`,"added.list":`Added {{count}} item(s) to the Notepad list`,"added.notes":`Added the selection to Notepad notes`,"quote.source":`Conversation`,"quote.unavailable":`This chat can't take a quote right now`,noChat:`Open a chat first`}},setup(i){let a=i.ui,u=`${i.id}:main`,[d,f]=(0,n.createStore)({}),p=new Set,h=e=>o(e&&d[e]?.doc||``);function g(e,t){f(e,t)}function _(e){d[e]||p.has(e)||(p.add(e),i.client.rpc(m).read({sessionID:e}).then(t=>g(e,t),v).finally(()=>p.delete(e)))}(0,r.createKeyed)(()=>i.servers.get(`sidecar`)?.client,e=>{let n=e.rpc(m);for(let e of Object.keys(d))n.read({sessionID:e}).then(t=>g(e,t),v);(0,t.onCleanup)(n.events.on(`updated`,e=>{let t=e.data;for(let[e,n]of Object.entries(d))n.sessionID===t.sessionID&&g(e,t);d[t.sessionID]||g(t.sessionID,t)}))});function v(e){i.toast.show({title:i.t(`error`,{message:e instanceof Error?e.message:String(e)}),variant:`error`})}function O(e,t,n){t(i.client.rpc(m)).then(t=>{g(e,t),n?.()},v)}function k(){let e=i.sessions.current();return e||i.toast.show({title:i.t(`noChat`)}),e}function A(e){let t=k(),n=c(e);t&&n.length!==0&&O(t.id,e=>e.add({sessionID:t.id,items:n}),()=>i.toast.show({title:i.t(`added.list`,{count:n.length}),variant:`success`,duration:2e3}))}function j(e){let t=k();t&&O(t.id,n=>n.appendNote({sessionID:t.id,markdown:l(e)}),()=>i.toast.show({title:i.t(`added.notes`),variant:`success`,duration:2e3}))}function M(e){let t=i.screen.current()?.composer;if(!t?.quotable()){i.toast.show({title:i.t(`quote.unavailable`)});return}let n=crypto.randomUUID();t.attach({type:`quote`,commentID:n,text:e.trim(),source:{label:i.t(`quote.source`),icon:`speech-bubble`,subject:`this conversation`}}),t.focus(n)}function N(){let e=i.sessions.current();e&&i.layout.toggle(u,e)}let P={id:`main`,get title(){return i.t(`title`)},closable:`compact`,fallback:!0};i.add(r.Style,x),i.add(r.Panel,{id:`main`,region:`side`,mobile:{get title(){return i.t(`title`)},order:60,kind:`menu`,icon:`checklist`},list:e=>e.open.includes(`main`)?[P]:[],enter:()=>document.querySelector(`.notepad-add input`)?.focus(),render:t=>(0,e.createComponent)(I,{get session(){return t.session}})}),i.add(r.Command,()=>({id:`toggle`,title:i.t(`command.toggle`),keywords:`notepad todo checklist notes markdown`,bind:`mod+alt+n`,enabled:!!i.sessions.current(),run:N}));let F=[{id:`quote`,get label(){return i.t(`selection.quote`)},run:M},{id:`list`,get label(){return i.t(`selection.list`)},run:A},{id:`notes`,get label(){return i.t(`selection.notes`)},run:j}];i.add(r.Slot,{at:`window.bottom`,render:()=>(0,e.createComponent)(b,{actions:F})});for(let[e,t,n]of[[`selection-list`,`mod+alt+l`,A],[`selection-notes`,`mod+alt+k`,j]])i.add(r.Command,()=>({id:e,title:i.t(e===`selection-list`?`command.selectionList`:`command.selectionNotes`),keywords:`notepad selection highlight quote`,bind:t,run:()=>{let e=y();if(!e)return!1;window.getSelection()?.removeAllRanges(),n(e.text)}}));i.add(r.MenuItem,()=>{let e=i.sessions.current();return{menu:`session.panel`,id:`open`,title:i.t(`title`),icon:`checklist`,keybind:`${i.id}.toggle`,opened:!!e&&i.layout.state(u,e)!==`closed`,run:()=>{e&&i.layout.open(u,e)}}}),(0,r.createKeyed)(()=>i.sessions.current()?.id,e=>_(e)),i.add(r.ComposerPill,{id:`open`,show:e=>{let t=h(e.session.id).tasks.filter(e=>!e.checked);if(t.length!==0)return{icon:`checklist`,label:i.t(`pill`,{count:t.length}),title:i.t(`pill.title`),rows:[...t.slice(0,8).map((e,t)=>({id:`task-${t}`,label:e.text})),{id:`open`,label:i.t(`pill.open`),icon:`checklist`,run:()=>i.layout.open(u,e.session)}]}}});function I(n){let[o,c]=(0,t.createSignal)(``),[l,u]=(0,t.createSignal)(!1),[f,p]=(0,t.createSignal)(``),m,g=()=>n.session.id,v=()=>h(g()),y=()=>d[g()]?.path??``;(0,t.createEffect)(()=>_(g())),(0,r.createKeyed)(g,e=>{(0,t.onCleanup)(()=>{l()&&k(e),u(!1)})});function b(e){if(!s(e))return;c(``);let t=g();O(t,n=>n.add({sessionID:t,items:[e]}))}function x(){p(v().body),u(!0)}function k(e=g()){clearTimeout(m),f()!==h(e).body&&O(e,t=>t.setBody({sessionID:e,body:f()}))}function A(){k(),u(!1)}return(()=>{var n=w(),r=n.firstChild,s=r.firstChild,u=r.nextSibling,d=u.nextSibling,h=d.firstChild;return(0,e.insert)(s,(0,e.createComponent)(a.TextInput,{get label(){return i.t(`add.label`)},get placeholder(){return i.t(`add.placeholder`)},get value(){return o()},onInput:c,onSubmit:b})),(0,e.insert)(r,(0,e.createComponent)(a.Tooltip,{get value(){return i.t(`openPreview`)},get children(){return(0,e.createComponent)(a.IconButton,{icon:`square-arrow-top-right`,get label(){return i.t(`openPreview`)},variant:`ghost`,size:`small`,get disabled(){return!y()},onClick:()=>i.system.openExternal(`md-preview://file${encodeURI(y())}`)})}}),null),(0,e.insert)(r,(0,e.createComponent)(a.Menu,{get label(){return i.t(`title`)},icon:`outline-dots`,placement:`bottom-end`,get items(){return[{label:i.t(`openFile`),icon:`open-file`,onSelect:()=>i.system.openExternal(`file://${encodeURI(y())}`)},{label:i.t(`copyPath`),icon:`copy`,onSelect:()=>void i.system.copy(y())}]}}),null),(0,e.insert)(u,(0,e.createComponent)(t.For,{get each(){return v().tasks},children:(t,n)=>(()=>{var r=T(),o=r.firstChild.firstChild,s=o.nextSibling;return o.addEventListener(`change`,e=>{let t=e.currentTarget.checked,r=g();O(r,e=>e.check({sessionID:r,index:n(),checked:t}))}),(0,e.insert)(s,()=>t.text),(0,e.insert)(r,(0,e.createComponent)(a.IconButton,{icon:`xmark-small`,get label(){return i.t(`remove`)},variant:`ghost-muted`,size:`small`,class:`notepad-remove`,onClick:()=>{let e=g();O(e,t=>t.remove({sessionID:e,index:n()}))}}),null),(0,e.effect)(()=>r.classList.toggle(`is-done`,!!t.checked)),(0,e.effect)(()=>o.checked=t.checked),r})()})),(0,e.insert)(n,(0,e.createComponent)(t.Show,{get when(){return v().tasks.some(e=>e.checked)},get children(){var t=S();return(0,e.insert)(t,(0,e.createComponent)(a.Button,{variant:`ghost-muted`,size:`small`,onClick:()=>{let e=g();O(e,t=>t.clearDone({sessionID:e}))},get children(){return i.t(`clearDone`)}})),t}}),d),(0,e.insert)(h,()=>i.t(`notes`)),(0,e.insert)(d,(0,e.createComponent)(a.Button,{variant:`ghost`,size:`small`,get icon(){return l()?`check`:`pencil-line`},onClick:()=>l()?A():x(),get children(){return(0,e.memo)(()=>!!l())()?i.t(`done`):i.t(`edit`)}}),null),(0,e.insert)(n,(0,e.createComponent)(t.Show,{get when(){return l()},get fallback(){return(0,e.createComponent)(t.Show,{get when(){return v().body},get fallback(){return(()=>{var t=D();return(0,e.insert)(t,()=>i.t(`notes.empty`)),t})()},get children(){var t=E();return t.$$dblclick=x,(0,e.insert)(t,(0,e.createComponent)(a.Markdown,{get text(){return v().body}})),t}})},get children(){var t=C();return t.$$keydown=e=>{(e.key===`Escape`||e.key===`Enter`&&(e.metaKey||e.ctrlKey))&&(e.preventDefault(),A())},t.addEventListener(`blur`,()=>k()),t.$$input=e=>{p(e.currentTarget.value),clearTimeout(m),m=setTimeout(k,600)},(0,e.setAttribute)(t,`spellcheck`,!0),(0,e.effect)(()=>t.value=f()),t}}),null),n})()}}});(0,e.delegateEvents)([`input`,`keydown`,`dblclick`]),exports.default=O;
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "opencode-notepad",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.1",
4
+ "description": "OpenCode Desktop side-panel notepad, one per chat: Markdown notes with a checklist the coding agent can add to.",
5
+ "keywords": [
6
+ "opencode",
7
+ "opencode-plugin",
8
+ "notepad",
9
+ "checklist",
10
+ "markdown"
11
+ ],
12
+ "type": "module",
13
+ "license": "MIT",
14
+ "author": "parzival1l",
15
+ "homepage": "https://github.com/parzival1l/opencode-notepad#readme",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/parzival1l/opencode-notepad.git"
19
+ },
20
+ "bugs": "https://github.com/parzival1l/opencode-notepad/issues",
21
+ "exports": {
22
+ ".": "./src/index.ts",
23
+ "./rpc": "./src/rpc.ts",
24
+ "./gui-experimental": "./dist/gui-experimental.cjs"
25
+ },
26
+ "files": [
27
+ "src",
28
+ "dist"
29
+ ],
30
+ "scripts": {
31
+ "build": "vite build",
32
+ "dev": "vite build --watch",
33
+ "test": "bun test",
34
+ "typecheck": "tsc --noEmit",
35
+ "prepublishOnly": "bun run typecheck && bun test && bun run build"
36
+ },
37
+ "dependencies": {
38
+ "@opencode/plugin": "2.2.0"
39
+ },
40
+ "devDependencies": {
41
+ "@types/bun": "^1.4.4",
42
+ "solid-js": "^1.9.17",
43
+ "typescript": "^7.0.2",
44
+ "vite": "^8.3.5",
45
+ "vite-plugin-solid": "^2.11.14"
46
+ }
47
+ }
package/src/gui.css ADDED
@@ -0,0 +1,149 @@
1
+ /* Task-list rules adapted from pluk-inc/markdown-preview (MIT): done items are muted, not struck
2
+ through, so their text stays readable beside the checkbox. */
3
+ .notepad {
4
+ display: flex;
5
+ flex-direction: column;
6
+ gap: 8px;
7
+ height: 100%;
8
+ overflow-y: auto;
9
+ padding: 10px 12px 16px;
10
+ box-sizing: border-box;
11
+ }
12
+ .notepad-toolbar {
13
+ display: flex;
14
+ align-items: center;
15
+ gap: 4px;
16
+ }
17
+ .notepad-toolbar > :not(.notepad-add) {
18
+ flex-shrink: 0;
19
+ }
20
+ .notepad-add {
21
+ flex: 1;
22
+ min-width: 0;
23
+ margin-right: 2px;
24
+ }
25
+ .notepad-add > * {
26
+ width: 100%;
27
+ }
28
+ .notepad-tasks {
29
+ list-style: none;
30
+ margin: 0;
31
+ padding: 0;
32
+ }
33
+ .notepad-task {
34
+ display: flex;
35
+ align-items: flex-start;
36
+ gap: 4px;
37
+ border-radius: 6px;
38
+ padding: 2px 2px 2px 4px;
39
+ }
40
+ .notepad-task:hover {
41
+ background: color-mix(in srgb, currentColor 6%, transparent);
42
+ }
43
+ .notepad-task label {
44
+ display: flex;
45
+ flex: 1;
46
+ min-width: 0;
47
+ align-items: flex-start;
48
+ gap: 8px;
49
+ padding: 3px 0;
50
+ cursor: pointer;
51
+ line-height: 1.4;
52
+ overflow-wrap: anywhere;
53
+ }
54
+ .notepad-task input[type="checkbox"] {
55
+ flex-shrink: 0;
56
+ margin: 2px 0 0;
57
+ width: 14px;
58
+ height: 14px;
59
+ accent-color: var(--text-interactive-base, currentColor);
60
+ cursor: pointer;
61
+ }
62
+ .notepad-task.is-done label span {
63
+ opacity: 0.5;
64
+ }
65
+ .notepad-remove {
66
+ flex-shrink: 0;
67
+ opacity: 0;
68
+ }
69
+ .notepad-task:hover .notepad-remove,
70
+ .notepad-remove:focus-visible {
71
+ opacity: 1;
72
+ }
73
+ .notepad-clear {
74
+ display: flex;
75
+ justify-content: flex-end;
76
+ }
77
+ .notepad-notes-header {
78
+ display: flex;
79
+ align-items: center;
80
+ justify-content: space-between;
81
+ margin-top: 4px;
82
+ padding-top: 8px;
83
+ border-top: 1px solid color-mix(in srgb, currentColor 12%, transparent);
84
+ font-size: 12px;
85
+ font-weight: 600;
86
+ opacity: 0.8;
87
+ }
88
+ .notepad-empty {
89
+ margin: 0;
90
+ font-size: 13px;
91
+ opacity: 0.5;
92
+ }
93
+ .notepad-body {
94
+ min-height: 2em;
95
+ }
96
+ .notepad-editor {
97
+ flex: 1;
98
+ min-height: 240px;
99
+ resize: vertical;
100
+ padding: 8px;
101
+ border-radius: 6px;
102
+ border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
103
+ background: transparent;
104
+ color: inherit;
105
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
106
+ font-size: 12.5px;
107
+ line-height: 1.5;
108
+ tab-size: 2;
109
+ }
110
+ .notepad-editor:focus {
111
+ outline: none;
112
+ border-color: color-mix(in srgb, currentColor 35%, transparent);
113
+ }
114
+ .notepad-selection {
115
+ position: fixed;
116
+ z-index: 2147483000;
117
+ transform: translateX(-50%);
118
+ display: flex;
119
+ align-items: center;
120
+ padding: 3px;
121
+ border-radius: 8px;
122
+ background: #1f1f1f;
123
+ color: #f4f4f4;
124
+ box-shadow: 0 6px 20px rgb(0 0 0 / 0.28), 0 0 0 1px rgb(255 255 255 / 0.08);
125
+ font-size: 12px;
126
+ animation: notepad-pop 120ms ease-out;
127
+ }
128
+ .notepad-selection button {
129
+ all: unset;
130
+ padding: 4px 9px;
131
+ border-radius: 5px;
132
+ cursor: pointer;
133
+ white-space: nowrap;
134
+ }
135
+ .notepad-selection button:hover,
136
+ .notepad-selection button:focus-visible {
137
+ background: rgb(255 255 255 / 0.14);
138
+ }
139
+ .notepad-selection-divider {
140
+ width: 1px;
141
+ height: 14px;
142
+ background: rgb(255 255 255 / 0.18);
143
+ }
144
+ @keyframes notepad-pop {
145
+ from { opacity: 0; transform: translateX(-50%) translateY(4px); }
146
+ }
147
+ @media (prefers-reduced-motion: reduce) {
148
+ .notepad-selection { animation: none; }
149
+ }
package/src/gui.tsx ADDED
@@ -0,0 +1,459 @@
1
+ import { createEffect, createSignal, For, onCleanup, Show } from "solid-js"
2
+ import { createStore } from "solid-js/store"
3
+ import {
4
+ Command,
5
+ ComposerPill,
6
+ createKeyed,
7
+ MenuItem,
8
+ Panel,
9
+ Plugin,
10
+ Slot,
11
+ Style,
12
+ type MountedSession,
13
+ type PanelTab,
14
+ } from "@opencode/plugin/gui"
15
+ import * as Note from "./note"
16
+ import { Notepad, type State } from "./rpc"
17
+ import { readSelection, SelectionBar, type SelectionAction } from "./selection"
18
+ import css from "./gui.css?inline"
19
+
20
+ export default Plugin.define({
21
+ i18n: {
22
+ en: {
23
+ title: "Notepad",
24
+ "command.toggle": "Toggle Notepad",
25
+ "add.label": "Add a checklist item",
26
+ "add.placeholder": "Add an item and press Enter",
27
+ remove: "Remove item",
28
+ clearDone: "Clear done",
29
+ notes: "Notes",
30
+ "notes.empty": "No notes yet. Select Edit to write Markdown.",
31
+ edit: "Edit",
32
+ done: "Done",
33
+ openPreview: "Open in Markdown Preview",
34
+ openFile: "Open file in default app",
35
+ copyPath: "Copy file path",
36
+ pill: "{{count}} open",
37
+ "pill.title": "Notepad checklist",
38
+ "pill.open": "Open Notepad",
39
+ error: "Notepad: {{message}}",
40
+ "selection.quote": "Quote",
41
+ "selection.list": "Add to list",
42
+ "selection.notes": "Add to notes",
43
+ "command.selectionList": "Add selection to Notepad list",
44
+ "command.selectionNotes": "Add selection to Notepad notes",
45
+ "added.list": "Added {{count}} item(s) to the Notepad list",
46
+ "added.notes": "Added the selection to Notepad notes",
47
+ "quote.source": "Conversation",
48
+ "quote.unavailable": "This chat can't take a quote right now",
49
+ "noChat": "Open a chat first",
50
+ },
51
+ },
52
+ setup(ctx) {
53
+ const ui = ctx.ui
54
+ const key = `${ctx.id}:main`
55
+
56
+ // Notes by the session ID the window asked for. A subagent's entry holds its parent chat's note.
57
+ const [docs, setDocs] = createStore<Record<string, State>>({})
58
+ const loading = new Set<string>()
59
+
60
+ const noteOf = (sessionID: string | undefined) => Note.parse((sessionID && docs[sessionID]?.doc) || "")
61
+
62
+ function put(requested: string, state: State) {
63
+ setDocs(requested, state)
64
+ }
65
+
66
+ function load(sessionID: string) {
67
+ if (docs[sessionID] || loading.has(sessionID)) return
68
+ loading.add(sessionID)
69
+ ctx.client
70
+ .rpc(Notepad)
71
+ .read({ sessionID })
72
+ .then((value) => put(sessionID, value as State), fail)
73
+ .finally(() => loading.delete(sessionID))
74
+ }
75
+
76
+ // One run per server client: reload what this window shows, then follow every change the server reports.
77
+ createKeyed(
78
+ () => ctx.servers.get("sidecar")?.client,
79
+ (client) => {
80
+ const pad = client.rpc(Notepad)
81
+ for (const sessionID of Object.keys(docs)) {
82
+ void pad.read({ sessionID }).then((value) => put(sessionID, value as State), fail)
83
+ }
84
+ onCleanup(
85
+ pad.events.on("updated", (event) => {
86
+ const state = event.data as unknown as State
87
+ for (const [requested, current] of Object.entries(docs)) {
88
+ if (current.sessionID === state.sessionID) put(requested, state)
89
+ }
90
+ if (!docs[state.sessionID]) put(state.sessionID, state)
91
+ }),
92
+ )
93
+ },
94
+ )
95
+
96
+ function fail(error: unknown) {
97
+ ctx.toast.show({
98
+ title: ctx.t("error", { message: error instanceof Error ? error.message : String(error) }),
99
+ variant: "error",
100
+ })
101
+ }
102
+
103
+ type Pad = ReturnType<typeof ctx.client.rpc<typeof Notepad>>
104
+
105
+ function call(sessionID: string, run: (pad: Pad) => Promise<unknown>, done?: () => void) {
106
+ run(ctx.client.rpc(Notepad)).then((value) => {
107
+ put(sessionID, value as State)
108
+ done?.()
109
+ }, fail)
110
+ }
111
+
112
+ function currentChat(): MountedSession | undefined {
113
+ const session = ctx.sessions.current()
114
+ if (!session) ctx.toast.show({ title: ctx.t("noChat") })
115
+ return session
116
+ }
117
+
118
+ function selectionToList(text: string) {
119
+ const session = currentChat()
120
+ const items = Note.toItems(text)
121
+ if (!session || items.length === 0) return
122
+ call(
123
+ session.id,
124
+ (pad) => pad.add({ sessionID: session.id, items }),
125
+ () => ctx.toast.show({ title: ctx.t("added.list", { count: items.length }), variant: "success", duration: 2000 }),
126
+ )
127
+ }
128
+
129
+ function selectionToNotes(text: string) {
130
+ const session = currentChat()
131
+ if (!session) return
132
+ call(
133
+ session.id,
134
+ (pad) => pad.appendNote({ sessionID: session.id, markdown: Note.quote(text) }),
135
+ () => ctx.toast.show({ title: ctx.t("added.notes"), variant: "success", duration: 2000 }),
136
+ )
137
+ }
138
+
139
+ // Attaches the selection to the next prompt as a quote and opens its comment box: one step.
140
+ function selectionToQuote(text: string) {
141
+ const composer = ctx.screen.current()?.composer
142
+ if (!composer?.quotable()) {
143
+ ctx.toast.show({ title: ctx.t("quote.unavailable") })
144
+ return
145
+ }
146
+ const commentID = crypto.randomUUID()
147
+ composer.attach({
148
+ type: "quote",
149
+ commentID,
150
+ text: text.trim(),
151
+ source: { label: ctx.t("quote.source"), icon: "speech-bubble", subject: "this conversation" },
152
+ })
153
+ composer.focus(commentID)
154
+ }
155
+
156
+ function toggle() {
157
+ const session = ctx.sessions.current()
158
+ if (session) ctx.layout.toggle(key, session)
159
+ }
160
+
161
+ const tab: PanelTab = {
162
+ id: "main",
163
+ get title() {
164
+ return ctx.t("title")
165
+ },
166
+ closable: "compact",
167
+ fallback: true,
168
+ }
169
+
170
+ ctx.add(Style, css)
171
+
172
+ ctx.add(Panel, {
173
+ id: "main",
174
+ region: "side",
175
+ mobile: {
176
+ get title() {
177
+ return ctx.t("title")
178
+ },
179
+ order: 60,
180
+ kind: "menu",
181
+ icon: "checklist",
182
+ },
183
+ list: (input) => (input.open.includes("main") ? [tab] : []),
184
+ enter: () => document.querySelector<HTMLInputElement>(".notepad-add input")?.focus(),
185
+ render: (props) => <NotepadView session={props.session} />,
186
+ })
187
+
188
+ ctx.add(
189
+ Command,
190
+ (): Command => ({
191
+ id: "toggle",
192
+ title: ctx.t("command.toggle"),
193
+ keywords: "notepad todo checklist notes markdown",
194
+ bind: "mod+alt+n",
195
+ enabled: !!ctx.sessions.current(),
196
+ run: toggle,
197
+ }),
198
+ )
199
+
200
+ // Highlight text anywhere in the window (outside inputs) to get a floating bar: Quote, Add to list, Add to notes.
201
+ const actions: SelectionAction[] = [
202
+ {
203
+ id: "quote",
204
+ get label() {
205
+ return ctx.t("selection.quote")
206
+ },
207
+ run: selectionToQuote,
208
+ },
209
+ {
210
+ id: "list",
211
+ get label() {
212
+ return ctx.t("selection.list")
213
+ },
214
+ run: selectionToList,
215
+ },
216
+ {
217
+ id: "notes",
218
+ get label() {
219
+ return ctx.t("selection.notes")
220
+ },
221
+ run: selectionToNotes,
222
+ },
223
+ ]
224
+ ctx.add(Slot, { at: "window.bottom", render: () => <SelectionBar actions={actions} /> })
225
+
226
+ for (const [id, bind, send] of [
227
+ ["selection-list", "mod+alt+l", selectionToList],
228
+ ["selection-notes", "mod+alt+k", selectionToNotes],
229
+ ] as const) {
230
+ ctx.add(
231
+ Command,
232
+ (): Command => ({
233
+ id,
234
+ title: ctx.t(id === "selection-list" ? "command.selectionList" : "command.selectionNotes"),
235
+ keywords: "notepad selection highlight quote",
236
+ bind,
237
+ run: () => {
238
+ const picked = readSelection()
239
+ if (!picked) return false
240
+ window.getSelection()?.removeAllRanges()
241
+ send(picked.text)
242
+ },
243
+ }),
244
+ )
245
+ }
246
+
247
+ ctx.add(MenuItem, (): MenuItem => {
248
+ const session = ctx.sessions.current()
249
+ return {
250
+ menu: "session.panel",
251
+ id: "open",
252
+ title: ctx.t("title"),
253
+ icon: "checklist",
254
+ keybind: `${ctx.id}.toggle`,
255
+ opened: !!session && ctx.layout.state(key, session) !== "closed",
256
+ run: () => {
257
+ if (session) ctx.layout.open(key, session)
258
+ },
259
+ }
260
+ })
261
+
262
+ // Load the routed chat's note so its pill shows without opening the panel.
263
+ createKeyed(
264
+ () => ctx.sessions.current()?.id,
265
+ (sessionID) => load(sessionID),
266
+ )
267
+
268
+ ctx.add(ComposerPill, {
269
+ id: "open",
270
+ show: (input) => {
271
+ const open = noteOf(input.session.id).tasks.filter((task) => !task.checked)
272
+ if (open.length === 0) return undefined
273
+ return {
274
+ icon: "checklist",
275
+ label: ctx.t("pill", { count: open.length }),
276
+ title: ctx.t("pill.title"),
277
+ rows: [
278
+ ...open.slice(0, 8).map((task, i) => ({ id: `task-${i}`, label: task.text })),
279
+ {
280
+ id: "open",
281
+ label: ctx.t("pill.open"),
282
+ icon: "checklist" as const,
283
+ run: () => ctx.layout.open(key, input.session),
284
+ },
285
+ ],
286
+ }
287
+ },
288
+ })
289
+
290
+ function NotepadView(props: { readonly session: MountedSession }) {
291
+ const [draft, setDraft] = createSignal("")
292
+ const [editing, setEditing] = createSignal(false)
293
+ const [body, setBody] = createSignal("")
294
+ let timer: ReturnType<typeof setTimeout> | undefined
295
+
296
+ const id = () => props.session.id
297
+ const note = () => noteOf(id())
298
+ const path = () => docs[id()]?.path ?? ""
299
+
300
+ createEffect(() => load(id()))
301
+
302
+ // Switching chats while editing saves the old chat's draft first.
303
+ createKeyed(id, (sessionID) => {
304
+ onCleanup(() => {
305
+ if (editing()) save(sessionID)
306
+ setEditing(false)
307
+ })
308
+ })
309
+
310
+ function add(value: string) {
311
+ if (!Note.clean(value)) return
312
+ setDraft("")
313
+ const sessionID = id()
314
+ call(sessionID, (pad) => pad.add({ sessionID, items: [value] }))
315
+ }
316
+
317
+ function startEdit() {
318
+ setBody(note().body)
319
+ setEditing(true)
320
+ }
321
+
322
+ function save(sessionID = id()) {
323
+ clearTimeout(timer)
324
+ if (body() !== noteOf(sessionID).body) call(sessionID, (pad) => pad.setBody({ sessionID, body: body() }))
325
+ }
326
+
327
+ function finishEdit() {
328
+ save()
329
+ setEditing(false)
330
+ }
331
+
332
+ return (
333
+ <div class="notepad">
334
+ <div class="notepad-toolbar">
335
+ <div class="notepad-add">
336
+ <ui.TextInput
337
+ label={ctx.t("add.label")}
338
+ placeholder={ctx.t("add.placeholder")}
339
+ value={draft()}
340
+ onInput={setDraft}
341
+ onSubmit={add}
342
+ />
343
+ </div>
344
+ <ui.Tooltip value={ctx.t("openPreview")}>
345
+ <ui.IconButton
346
+ icon="square-arrow-top-right"
347
+ label={ctx.t("openPreview")}
348
+ variant="ghost"
349
+ size="small"
350
+ disabled={!path()}
351
+ onClick={() => ctx.system.openExternal(`md-preview://file${encodeURI(path())}`)}
352
+ />
353
+ </ui.Tooltip>
354
+ <ui.Menu
355
+ label={ctx.t("title")}
356
+ icon="outline-dots"
357
+ placement="bottom-end"
358
+ items={[
359
+ {
360
+ label: ctx.t("openFile"),
361
+ icon: "open-file",
362
+ onSelect: () => ctx.system.openExternal(`file://${encodeURI(path())}`),
363
+ },
364
+ { label: ctx.t("copyPath"), icon: "copy", onSelect: () => void ctx.system.copy(path()) },
365
+ ]}
366
+ />
367
+ </div>
368
+
369
+ <ul class="notepad-tasks">
370
+ <For each={note().tasks}>
371
+ {(task, i) => (
372
+ <li class="notepad-task" classList={{ "is-done": task.checked }}>
373
+ <label>
374
+ <input
375
+ type="checkbox"
376
+ checked={task.checked}
377
+ onChange={(event) => {
378
+ const checked = event.currentTarget.checked
379
+ const sessionID = id()
380
+ call(sessionID, (pad) => pad.check({ sessionID, index: i(), checked }))
381
+ }}
382
+ />
383
+ <span>{task.text}</span>
384
+ </label>
385
+ <ui.IconButton
386
+ icon="xmark-small"
387
+ label={ctx.t("remove")}
388
+ variant="ghost-muted"
389
+ size="small"
390
+ class="notepad-remove"
391
+ onClick={() => {
392
+ const sessionID = id()
393
+ call(sessionID, (pad) => pad.remove({ sessionID, index: i() }))
394
+ }}
395
+ />
396
+ </li>
397
+ )}
398
+ </For>
399
+ </ul>
400
+ <Show when={note().tasks.some((task) => task.checked)}>
401
+ <div class="notepad-clear">
402
+ <ui.Button
403
+ variant="ghost-muted"
404
+ size="small"
405
+ onClick={() => {
406
+ const sessionID = id()
407
+ call(sessionID, (pad) => pad.clearDone({ sessionID }))
408
+ }}
409
+ >
410
+ {ctx.t("clearDone")}
411
+ </ui.Button>
412
+ </div>
413
+ </Show>
414
+
415
+ <div class="notepad-notes-header">
416
+ <span>{ctx.t("notes")}</span>
417
+ <ui.Button
418
+ variant="ghost"
419
+ size="small"
420
+ icon={editing() ? "check" : "pencil-line"}
421
+ onClick={() => (editing() ? finishEdit() : startEdit())}
422
+ >
423
+ {editing() ? ctx.t("done") : ctx.t("edit")}
424
+ </ui.Button>
425
+ </div>
426
+ <Show
427
+ when={editing()}
428
+ fallback={
429
+ <Show when={note().body} fallback={<p class="notepad-empty">{ctx.t("notes.empty")}</p>}>
430
+ <div class="notepad-body" onDblClick={startEdit}>
431
+ <ui.Markdown text={note().body} />
432
+ </div>
433
+ </Show>
434
+ }
435
+ >
436
+ <textarea
437
+ class="notepad-editor"
438
+ value={body()}
439
+ spellcheck={true}
440
+ autofocus
441
+ onInput={(event) => {
442
+ setBody(event.currentTarget.value)
443
+ clearTimeout(timer)
444
+ timer = setTimeout(save, 600)
445
+ }}
446
+ onBlur={() => save()}
447
+ onKeyDown={(event) => {
448
+ if (event.key === "Escape" || (event.key === "Enter" && (event.metaKey || event.ctrlKey))) {
449
+ event.preventDefault()
450
+ finishEdit()
451
+ }
452
+ }}
453
+ />
454
+ </Show>
455
+ </div>
456
+ )
457
+ }
458
+ },
459
+ })
package/src/index.ts ADDED
@@ -0,0 +1,164 @@
1
+ import { Plugin } from "@opencode/plugin"
2
+ import * as Note from "./note"
3
+ import { Notepad, type State } from "./rpc"
4
+ import { createNotebook, resolveDir } from "./store"
5
+
6
+ function listing(doc: string, file: string): string {
7
+ const note = Note.parse(doc)
8
+ const tasks = note.tasks.length
9
+ ? note.tasks.map((task, i) => `${i}. [${task.checked ? "x" : " "}] ${task.text}`).join("\n")
10
+ : "(no checklist items)"
11
+ return `Notepad for this chat: ${file}\n\nChecklist (index. [state] text):\n${tasks}\n\nNotes:\n${note.body || "(empty)"}`
12
+ }
13
+
14
+ export default Plugin.define({
15
+ id: "notepad",
16
+ async setup(ctx) {
17
+ let emit: (state: State) => void = () => {}
18
+ const book = createNotebook(resolveDir(ctx.options.dir), (sessionID, doc) =>
19
+ emit({ sessionID, doc, path: book.path(sessionID) }),
20
+ )
21
+
22
+ // A chat and its subagents share one notepad: the top-level session's.
23
+ const roots = new Map<string, string>()
24
+ async function root(sessionID: string): Promise<string> {
25
+ const known = roots.get(sessionID)
26
+ if (known) return known
27
+ let current = sessionID
28
+ for (let depth = 0; depth < 16; depth++) {
29
+ const info = await ctx.session.get({ sessionID: current }).catch(() => undefined)
30
+ if (!info?.parentID) break
31
+ current = info.parentID
32
+ }
33
+ roots.set(sessionID, current)
34
+ return current
35
+ }
36
+
37
+ async function edit(sessionID: string, change: (doc: string) => string): Promise<State> {
38
+ const id = await root(sessionID)
39
+ return { sessionID: id, doc: await book.update(id, change), path: book.path(id) }
40
+ }
41
+
42
+ const rpc = await ctx.rpc.register(Notepad, {
43
+ read: async (input) => {
44
+ const id = await root((input as { sessionID: string }).sessionID)
45
+ return { sessionID: id, doc: await book.read(id), path: book.path(id) }
46
+ },
47
+ add: async (input) => {
48
+ const { sessionID, items } = input as { sessionID: string; items: string[] }
49
+ return edit(sessionID, (doc) => Note.addTasks(doc, items))
50
+ },
51
+ check: async (input) => {
52
+ const { sessionID, index, checked } = input as { sessionID: string; index: number; checked: boolean }
53
+ return edit(sessionID, (doc) => Note.setChecked(doc, index, checked))
54
+ },
55
+ remove: async (input) => {
56
+ const { sessionID, index } = input as { sessionID: string; index: number }
57
+ return edit(sessionID, (doc) => Note.removeTask(doc, index))
58
+ },
59
+ clearDone: async (input) => edit((input as { sessionID: string }).sessionID, Note.clearDone),
60
+ appendNote: async (input) => {
61
+ const { sessionID, markdown } = input as { sessionID: string; markdown: string }
62
+ return edit(sessionID, (doc) => Note.appendBody(doc, markdown))
63
+ },
64
+ setBody: async (input) => {
65
+ const { sessionID, body } = input as { sessionID: string; body: string }
66
+ return edit(sessionID, (doc) => Note.setBody(doc, body))
67
+ },
68
+ })
69
+ emit = (state) => void rpc.events.emit("updated", { ...state }).catch(() => undefined)
70
+ const unwatch = await book.watch()
71
+
72
+ await ctx.tool.transform((editor) => {
73
+ editor.namespace({
74
+ name: "notepad",
75
+ description:
76
+ "This chat's Notepad: a Markdown note in the OpenCode Desktop side panel, one per chat. Its top section is a " +
77
+ 'checklist. When the user says "add this to the list", "put that on my list", or "note that down", call notepad.add.',
78
+ })
79
+ editor.add({
80
+ name: "add",
81
+ description:
82
+ "Add one or more items to the top checklist of this chat's Notepad. Use when the user says \"add this to the " +
83
+ 'list", "add to my notepad", "remind me to", or similar. Write each item as a short, self-contained line; ' +
84
+ 'resolve "this" or "that" from the conversation.',
85
+ input: {
86
+ type: "object",
87
+ properties: {
88
+ items: { type: "array", items: { type: "string" }, minItems: 1, description: "One checklist line each." },
89
+ },
90
+ required: ["items"],
91
+ additionalProperties: false,
92
+ },
93
+ options: { namespace: "notepad", codemode: true, pinned: true },
94
+ async execute(input, context) {
95
+ const { items } = input as { items: string[] }
96
+ const state = await edit(context.sessionID, (doc) => Note.addTasks(doc, items))
97
+ const total = Note.parse(state.doc).tasks.length
98
+ return { content: `Added ${items.length} item(s) to this chat's Notepad checklist (${total} total).` }
99
+ },
100
+ })
101
+ editor.add({
102
+ name: "check",
103
+ description:
104
+ "Check off (or uncheck) items in this chat's Notepad checklist. Match by `query` (case-insensitive text) or " +
105
+ "by `index` from notepad.read. A query that matches several items checks all of them.",
106
+ input: {
107
+ type: "object",
108
+ properties: {
109
+ query: { type: "string" },
110
+ index: { type: "integer", minimum: 0 },
111
+ checked: { type: "boolean", default: true },
112
+ },
113
+ additionalProperties: false,
114
+ },
115
+ options: { namespace: "notepad", codemode: true },
116
+ async execute(input, context) {
117
+ const { query, index, checked = true } = input as { query?: string; index?: number; checked?: boolean }
118
+ let matched: number[] = []
119
+ const state = await edit(context.sessionID, (current) => {
120
+ matched = index !== undefined ? [index] : Note.findTasks(current, query ?? "")
121
+ return matched.reduce((next, i) => Note.setChecked(next, i, checked), current)
122
+ })
123
+ if (matched.length === 0) return { content: `No checklist item matches.\n\n${listing(state.doc, state.path)}` }
124
+ const tasks = Note.parse(state.doc).tasks
125
+ const names = matched.map((i) => `- ${tasks[i]!.text}`).join("\n")
126
+ return { content: `${checked ? "Checked" : "Unchecked"} ${matched.length} item(s):\n${names}` }
127
+ },
128
+ })
129
+ editor.add({
130
+ name: "read",
131
+ description: "Read this chat's Notepad: the numbered checklist and the free Markdown notes below it.",
132
+ input: { type: "object", properties: {}, additionalProperties: false },
133
+ options: { namespace: "notepad", codemode: true },
134
+ async execute(_input, context) {
135
+ const id = await root(context.sessionID)
136
+ return { content: listing(await book.read(id), book.path(id)) }
137
+ },
138
+ })
139
+ editor.add({
140
+ name: "append_note",
141
+ description:
142
+ "Append Markdown to the free-form notes section of this chat's Notepad, below the checklist. Use for longer " +
143
+ "notes; use notepad.add for checklist items.",
144
+ input: {
145
+ type: "object",
146
+ properties: { markdown: { type: "string" } },
147
+ required: ["markdown"],
148
+ additionalProperties: false,
149
+ },
150
+ options: { namespace: "notepad", codemode: true },
151
+ async execute(input, context) {
152
+ const { markdown } = input as { markdown: string }
153
+ await edit(context.sessionID, (doc) => Note.appendBody(doc, markdown))
154
+ return { content: "Appended to this chat's Notepad notes." }
155
+ },
156
+ })
157
+ })
158
+
159
+ return async () => {
160
+ unwatch()
161
+ await rpc.dispose()
162
+ }
163
+ },
164
+ })
package/src/note.ts ADDED
@@ -0,0 +1,97 @@
1
+ // A note is one Markdown document. Its top section is the run of task lines at the very start
2
+ // ("- [ ] item"); everything after the first non-task line is free Markdown, the body.
3
+
4
+ export interface Task {
5
+ readonly text: string
6
+ readonly checked: boolean
7
+ }
8
+
9
+ export interface Note {
10
+ readonly tasks: readonly Task[]
11
+ readonly body: string
12
+ }
13
+
14
+ const TASK = /^\s*[-*+]\s+\[( |x|X)\]\s?(.*)$/
15
+
16
+ export function parse(doc: string): Note {
17
+ const lines = doc.replace(/\r\n?/g, "\n").split("\n")
18
+ const tasks: Task[] = []
19
+ let i = 0
20
+ for (; i < lines.length; i++) {
21
+ const match = TASK.exec(lines[i]!)
22
+ if (!match) break
23
+ tasks.push({ checked: match[1] !== " ", text: match[2]!.trim() })
24
+ }
25
+ const rest = lines.slice(i)
26
+ while (rest.length > 0 && rest[0]!.trim() === "") rest.shift()
27
+ return { tasks, body: rest.join("\n").trimEnd() }
28
+ }
29
+
30
+ export function compose(note: Note): string {
31
+ const tasks = note.tasks.map((task) => `- [${task.checked ? "x" : " "}] ${task.text}`).join("\n")
32
+ const body = note.body.trimEnd()
33
+ const doc = tasks && body ? `${tasks}\n\n${body}` : tasks || body
34
+ return doc ? `${doc}\n` : ""
35
+ }
36
+
37
+ // Turns free text into one task line: drops a leading list or task marker, joins lines.
38
+ export function clean(item: string): string {
39
+ return item
40
+ .replace(/^\s*(?:[-*+•]|\d+[.)])\s+(\[( |x|X)\]\s*)?/, "")
41
+ .replace(/\s*\n\s*/g, " ")
42
+ .trim()
43
+ }
44
+
45
+ export function addTasks(doc: string, items: readonly string[]): string {
46
+ const note = parse(doc)
47
+ const added = items.map(clean).filter(Boolean).map((text) => ({ text, checked: false }))
48
+ return compose({ ...note, tasks: [...note.tasks, ...added] })
49
+ }
50
+
51
+ export function setChecked(doc: string, index: number, checked: boolean): string {
52
+ const note = parse(doc)
53
+ if (!note.tasks[index]) throw new RangeError(`No task at index ${index}`)
54
+ return compose({ ...note, tasks: note.tasks.map((task, i) => (i === index ? { ...task, checked } : task)) })
55
+ }
56
+
57
+ export function removeTask(doc: string, index: number): string {
58
+ const note = parse(doc)
59
+ if (!note.tasks[index]) throw new RangeError(`No task at index ${index}`)
60
+ return compose({ ...note, tasks: note.tasks.filter((_, i) => i !== index) })
61
+ }
62
+
63
+ export function clearDone(doc: string): string {
64
+ const note = parse(doc)
65
+ return compose({ ...note, tasks: note.tasks.filter((task) => !task.checked) })
66
+ }
67
+
68
+ export function setBody(doc: string, body: string): string {
69
+ return compose({ ...parse(doc), body })
70
+ }
71
+
72
+ export function appendBody(doc: string, markdown: string): string {
73
+ const note = parse(doc)
74
+ const added = markdown.trim()
75
+ if (!added) return compose(note)
76
+ return compose({ ...note, body: note.body ? `${note.body}\n\n${added}` : added })
77
+ }
78
+
79
+ // Selected text becomes one checklist item per non-blank line, so a selected list adds each entry.
80
+ export function toItems(selection: string): string[] {
81
+ return selection.split(/\r?\n/).map(clean).filter(Boolean)
82
+ }
83
+
84
+ export function quote(text: string): string {
85
+ return text
86
+ .trim()
87
+ .split(/\r?\n/)
88
+ .map((line) => (line.trim() ? `> ${line.trimEnd()}` : ">"))
89
+ .join("\n")
90
+ }
91
+
92
+ // Indexes of tasks whose text contains the query, ignoring case.
93
+ export function findTasks(doc: string, query: string): number[] {
94
+ const needle = query.trim().toLowerCase()
95
+ if (!needle) return []
96
+ return parse(doc).tasks.flatMap((task, i) => (task.text.toLowerCase().includes(needle) ? [i] : []))
97
+ }
package/src/rpc.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { Rpc } from "@opencode/plugin/rpc"
2
+
3
+ // Every call names the chat it works on. The server stores a subagent's notes in its parent chat.
4
+ const sessionID = { type: "string", minLength: 1 } as const
5
+
6
+ const state = {
7
+ type: "object",
8
+ properties: { sessionID, doc: { type: "string" }, path: { type: "string" } },
9
+ required: ["sessionID", "doc", "path"],
10
+ additionalProperties: false,
11
+ } as const
12
+
13
+ const index = { type: "integer", minimum: 0 } as const
14
+
15
+ function input<P extends Record<string, unknown>>(properties: P) {
16
+ return {
17
+ type: "object",
18
+ properties: { sessionID, ...properties },
19
+ required: ["sessionID", ...Object.keys(properties)],
20
+ additionalProperties: false,
21
+ } as const
22
+ }
23
+
24
+ // `sessionID` in a reply or event is the chat that owns the note, which may be the caller's parent.
25
+ export interface State {
26
+ readonly sessionID: string
27
+ readonly doc: string
28
+ readonly path: string
29
+ }
30
+
31
+ export const Notepad = Rpc.define({
32
+ id: "notepad",
33
+ methods: {
34
+ read: { input: input({}), output: state },
35
+ add: { input: input({ items: { type: "array", items: { type: "string" } } }), output: state },
36
+ check: { input: input({ index, checked: { type: "boolean" } }), output: state },
37
+ remove: { input: input({ index }), output: state },
38
+ clearDone: { input: input({}), output: state },
39
+ appendNote: { input: input({ markdown: { type: "string" } }), output: state },
40
+ setBody: { input: input({ body: { type: "string" } }), output: state },
41
+ },
42
+ events: {
43
+ updated: { schema: state },
44
+ },
45
+ })
@@ -0,0 +1,93 @@
1
+ import { createSignal, For, onCleanup, onMount, Show } from "solid-js"
2
+ import { Portal } from "solid-js/web"
3
+
4
+ // Places a selection is editable or belongs to the notepad itself; the bar stays hidden there.
5
+ const IGNORE = "input, textarea, select, [contenteditable=''], [contenteditable='true'], .notepad, .notepad-selection"
6
+
7
+ export interface Picked {
8
+ readonly text: string
9
+ readonly top: number
10
+ readonly left: number
11
+ }
12
+
13
+ export interface SelectionAction {
14
+ readonly id: string
15
+ readonly label: string
16
+ run(text: string): void
17
+ }
18
+
19
+ // The window's current text selection, when it is worth acting on.
20
+ export function readSelection(): Picked | undefined {
21
+ const selection = window.getSelection()
22
+ if (!selection || selection.isCollapsed || selection.rangeCount === 0) return undefined
23
+ const text = selection.toString()
24
+ if (!text.trim()) return undefined
25
+ const node = selection.anchorNode
26
+ const element = node instanceof Element ? node : node?.parentElement
27
+ if (element?.closest(IGNORE)) return undefined
28
+ const rect = selection.getRangeAt(0).getBoundingClientRect()
29
+ if (rect.width === 0 && rect.height === 0) return undefined
30
+ return { text, top: rect.top, left: rect.left + rect.width / 2 }
31
+ }
32
+
33
+ export function SelectionBar(props: { readonly actions: readonly SelectionAction[] }) {
34
+ const [picked, setPicked] = createSignal<Picked>()
35
+ let timer: ReturnType<typeof setTimeout> | undefined
36
+
37
+ const refresh = () => {
38
+ clearTimeout(timer)
39
+ timer = setTimeout(() => setPicked(readSelection()), 120)
40
+ }
41
+ const hide = () => setPicked(undefined)
42
+
43
+ onMount(() => {
44
+ document.addEventListener("selectionchange", refresh)
45
+ window.addEventListener("scroll", hide, true)
46
+ window.addEventListener("resize", hide)
47
+ })
48
+ onCleanup(() => {
49
+ clearTimeout(timer)
50
+ document.removeEventListener("selectionchange", refresh)
51
+ window.removeEventListener("scroll", hide, true)
52
+ window.removeEventListener("resize", hide)
53
+ })
54
+
55
+ function send(action: SelectionAction) {
56
+ const current = picked()
57
+ if (!current) return
58
+ window.getSelection()?.removeAllRanges()
59
+ hide()
60
+ action.run(current.text)
61
+ }
62
+
63
+ return (
64
+ <Show when={picked()}>
65
+ {(at) => (
66
+ <Portal>
67
+ <div
68
+ class="notepad-selection"
69
+ style={{
70
+ top: `${at().top < 48 ? at().top + 28 : at().top - 40}px`,
71
+ left: `${Math.min(Math.max(at().left, 160), window.innerWidth - 160)}px`,
72
+ }}
73
+ // Keep the selection alive while the user clicks a button.
74
+ onMouseDown={(event) => event.preventDefault()}
75
+ >
76
+ <For each={props.actions}>
77
+ {(action, i) => (
78
+ <>
79
+ <Show when={i() > 0}>
80
+ <span class="notepad-selection-divider" />
81
+ </Show>
82
+ <button type="button" onClick={() => send(action)}>
83
+ {action.label}
84
+ </button>
85
+ </>
86
+ )}
87
+ </For>
88
+ </div>
89
+ </Portal>
90
+ )}
91
+ </Show>
92
+ )
93
+ }
package/src/store.ts ADDED
@@ -0,0 +1,104 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import { watch } from "node:fs"
3
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises"
4
+ import os from "node:os"
5
+ import path from "node:path"
6
+
7
+ const home = os.homedir()
8
+
9
+ // One Markdown file per chat lives here, beside OpenCode's own data, not in a user folder.
10
+ export const DEFAULT_DIR = path.join(process.env.XDG_DATA_HOME || path.join(home, ".local", "share"), "opencode-notepad")
11
+
12
+ export function resolveDir(option: unknown): string {
13
+ if (typeof option !== "string" || !option.trim()) return DEFAULT_DIR
14
+ const value = option.trim()
15
+ return path.resolve(value === "~" ? home : value.replace(/^~(?=\/)/, home))
16
+ }
17
+
18
+ const SAFE_ID = /^[A-Za-z0-9_-]+$/
19
+
20
+ // `typeof` first: a missing ID would otherwise pass the pattern as the text "undefined".
21
+ export function fileFor(dir: string, sessionID: string): string {
22
+ if (typeof sessionID !== "string" || !SAFE_ID.test(sessionID)) throw new Error(`Invalid session ID: ${sessionID}`)
23
+ return path.join(dir, `${sessionID}.md`)
24
+ }
25
+
26
+ // Owns the notepad files of every chat. Edits to one file run one at a time, so a GUI click and an
27
+ // agent tool call never overwrite each other. `changed` fires after every write, and after edits
28
+ // made by other apps.
29
+ export function createNotebook(dir: string, changed: (sessionID: string, doc: string) => void) {
30
+ const last = new Map<string, string>()
31
+ const queues = new Map<string, Promise<unknown>>()
32
+
33
+ async function load(sessionID: string): Promise<string> {
34
+ try {
35
+ return await readFile(fileFor(dir, sessionID), "utf8")
36
+ } catch (error) {
37
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return ""
38
+ throw error
39
+ }
40
+ }
41
+
42
+ async function save(sessionID: string, doc: string) {
43
+ const file = fileFor(dir, sessionID)
44
+ await mkdir(dir, { recursive: true })
45
+ const temp = `${file}.${randomUUID()}.tmp`
46
+ await writeFile(temp, doc, "utf8")
47
+ await rename(temp, file)
48
+ last.set(sessionID, doc)
49
+ }
50
+
51
+ function run<T>(sessionID: string, task: () => Promise<T>): Promise<T> {
52
+ const next = (queues.get(sessionID) ?? Promise.resolve()).then(task, task)
53
+ queues.set(
54
+ sessionID,
55
+ next.catch(() => undefined),
56
+ )
57
+ return next
58
+ }
59
+
60
+ return {
61
+ dir,
62
+ path: (sessionID: string) => fileFor(dir, sessionID),
63
+ read: (sessionID: string) =>
64
+ run(sessionID, async () => {
65
+ const doc = await load(sessionID)
66
+ last.set(sessionID, doc)
67
+ return doc
68
+ }),
69
+ update: (sessionID: string, edit: (doc: string) => string) =>
70
+ run(sessionID, async () => {
71
+ const before = await load(sessionID)
72
+ const after = edit(before)
73
+ if (after !== before) await save(sessionID, after)
74
+ changed(sessionID, after)
75
+ return after
76
+ }),
77
+ // Watches the folder, not each file: editors that save by rename replace the file's inode.
78
+ async watch(): Promise<() => void> {
79
+ await mkdir(dir, { recursive: true })
80
+ const timers = new Map<string, ReturnType<typeof setTimeout>>()
81
+ const watcher = watch(dir, (_event, name) => {
82
+ const match = name ? /^([A-Za-z0-9_-]+)\.md$/.exec(name.toString()) : null
83
+ if (!match) return
84
+ const sessionID = match[1]!
85
+ clearTimeout(timers.get(sessionID))
86
+ timers.set(
87
+ sessionID,
88
+ setTimeout(() => {
89
+ void run(sessionID, async () => {
90
+ const doc = await load(sessionID)
91
+ if (doc === last.get(sessionID)) return
92
+ last.set(sessionID, doc)
93
+ changed(sessionID, doc)
94
+ }).catch(() => undefined)
95
+ }, 150),
96
+ )
97
+ })
98
+ return () => {
99
+ for (const timer of timers.values()) clearTimeout(timer)
100
+ watcher.close()
101
+ }
102
+ },
103
+ }
104
+ }