diff --git a/README.md b/README.md index 8fd5cbe..cb1e2a3 100644 --- a/README.md +++ b/README.md @@ -29,19 +29,13 @@ A drop-in `/loop` command for [opencode](https://opencode.ai), modeled after Cla - **Auto-expire** — tasks idle for more than 7 days are removed on load (active tasks never expire) - **Max 50 concurrent tasks** - **LLM-callable tools** — `loop_schedule`, `loop_status` (session-bound by default) -- **Interactive task list** — `/loop list` and `/loop status` open a dedicated native dialog with selectable task rows instead of writing over the prompt; start/cancel/pause/resume stay silent, failures surface as error toasts -- **Clipboard actions** — copy the complete result or copy any displayed task ID with one action -- **Keyboard and mouse navigation** — move with `Up`/`Down` or `Tab`/`Shift+Tab`, hover with the pointer, and activate with `Enter`, `Space`, or a click -- **Responsive layout** — short or narrow terminals keep the dialog inside the viewport with a scrollable task list -- **Easy dismissal** — choose **Close**, press `q`, or use the native dialog's `Esc` key +- **Inline results, Claude Code style** — every `/loop` result (create, list, cancel, pause, resume, stop-all, failures) is presented by the model directly in the conversation, in the user's own language — task lists render as a markdown table. No dialogs, no toasts ## Requirements -- OpenCode **1.17.18 or newer** for the interactive TUI companion +- OpenCode **1.17.18 or newer** - Node.js **18 or newer** -The scheduling server plugin still has a native toast fallback, while supported OpenCode versions install both package entrypoints automatically. - ## Install ### Option 1: From npm (recommended) @@ -187,15 +181,16 @@ If you try `cancel ` for a task owned by another session, you'll get a refus Two behavioral differences worth knowing: tasks only fire for the **currently active session** (switch sessions and the others wait; switch back and they catch up once), and fixed tasks fire on a 5-second ticker rather than exact wall-clock cron times (up to one ticker period late). -### Interactive task list dialog +### Inline results -`/loop list` and `/loop status` open a native OpenCode dialog rendering your tasks as a selectable, color-coded list (▶ active, ⏸ paused). Starting, cancelling, pausing, or resuming a task stays silent; failures surface as error toasts. The dialog provides: +Every `/loop` command result is presented by the model directly in the conversation — Claude Code style, in the same language you used: -- A highlighted **task row** per task — press `Enter` to copy its task ID -- **Copy all** for the exact complete result text -- **Close** to dismiss the dialog +- **Create** — a short confirmation with the task, schedule, and job ID (plus how to cancel) +- **List / status** — a markdown table of your tasks (Job ID, frequency, content, type) with the management commands below it +- **Cancel / pause / resume / stop-all** — a concise confirmation of what changed and whether the task will trigger again +- **Failures** — a brief explanation of what went wrong -Use `Up`/`Down` or `Tab`/`Shift+Tab` to change the selected row, then press `Enter` or `Space` to activate it. Moving the mouse over a row selects it, and clicking activates that exact row. A successful copy shows a confirmation and closes the dialog immediately; if clipboard access fails, the dialog stays open and shows an error. Press `Page Up` or `Page Down` to scroll long lists, or press `q` or `Esc` to close. In short or narrow terminals, the dialog scales to the available viewport and keeps the list scrollable. A newer Loop result replaces the previous Loop dialog rather than stacking another one. +No dialogs, no toasts — the conversation is the only output surface. ### Programmatic (LLM tools) @@ -292,7 +287,7 @@ Tasks persist to `.opencode/cache/loop/tasks.json` (per project). Fire history i ### Package entrypoints -Current releases expose separate `opencode-plugin-loop/server` and `opencode-plugin-loop/tui` entrypoints so OpenCode can load the scheduler and responsive interactive dialog independently. The root export remains the v1-compatible server module for backward compatibility. Programmatic consumers should use the named factory: +Current releases expose separate `opencode-plugin-loop/server` and `opencode-plugin-loop/tui` entrypoints so OpenCode installs that auto-load both keep working (the TUI entrypoint is a no-op since results are presented inline). The root export remains the v1-compatible server module for backward compatibility. Programmatic consumers should use the named factory: ```typescript import { LoopPlugin } from "opencode-plugin-loop" @@ -300,7 +295,7 @@ import { LoopPlugin } from "opencode-plugin-loop" ### Task lines overlap the input area -Older releases wrote `/loop` results directly to the terminal. OpenCode owns and redraws the terminal UI, so those writes could leave task IDs and prompts over the input area. Upgrade to the current release for the responsive interactive dialog; runtime diagnostics go to OpenCode's structured application log. +Older releases wrote `/loop` results directly to the terminal or into native dialogs/toasts. Upgrade to the current release: every result is presented inline by the model, and runtime diagnostics go to OpenCode's structured application log. Also make sure the plugin is installed from only one source. OpenCode loads npm plugins from `opencode.json` and copied plugins under `~/.config/opencode/plugins/` independently, even when they have the same package name. diff --git a/package-lock.json b/package-lock.json index 2b9fb5d..f02792e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,25 +1,18 @@ { "name": "opencode-plugin-loop", - "version": "0.6.1", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-plugin-loop", - "version": "0.6.1", + "version": "0.7.0", "license": "MIT", - "dependencies": { - "clipboardy": "4.0.0" - }, "devDependencies": { "@babel/core": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@opencode-ai/plugin": "^1.17.18", - "@opentui/core": "0.4.3", - "@opentui/solid": "0.4.3", "@types/node": "^22.0.0", - "babel-preset-solid": "^1.9.12", - "solid-js": "1.9.12", "typescript": "^5.4.0" }, "engines": { @@ -27,10 +20,7 @@ "opencode": ">=1.17.18" }, "peerDependencies": { - "@opencode-ai/plugin": ">=1.17.18", - "@opentui/core": ">=0.4.3", - "@opentui/solid": ">=0.4.3", - "solid-js": "1.9.12" + "@opencode-ai/plugin": ">=1.17.18" } }, "node_modules/@ai-sdk/provider": { @@ -661,6 +651,8 @@ "integrity": "sha512-rrJfAk13tALDqldYjhc78eWQ+aKq1iknJgffIOg3OwyZoqQo+p6gtuqyhmWvXIfQzlNUbpgpCPcxbXlhMnlaHQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", @@ -694,7 +686,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@opentui/core-darwin-x64": { "version": "0.4.3", @@ -708,7 +701,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@opentui/core-linux-arm64": { "version": "0.4.3", @@ -722,7 +716,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@opentui/core-linux-arm64-musl": { "version": "0.4.3", @@ -736,7 +731,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@opentui/core-linux-x64": { "version": "0.4.3", @@ -750,7 +746,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@opentui/core-linux-x64-musl": { "version": "0.4.3", @@ -764,7 +761,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@opentui/core-win32-arm64": { "version": "0.4.3", @@ -778,7 +776,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@opentui/core-win32-x64": { "version": "0.4.3", @@ -792,7 +791,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@opentui/solid": { "version": "0.4.3", @@ -800,6 +800,8 @@ "integrity": "sha512-RcV0+S8HMdXOASyr7HmJUBuTUIaFPzAxMDa44VftS5C2JUgrmAuWo0Njv1q3TWRB1owjHnyKhEfWGKq7A82wxw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", @@ -836,6 +838,8 @@ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=12" }, @@ -849,6 +853,8 @@ "integrity": "sha512-/O6JWUmjv03OI9lL2ry9bUjpD5S3PclM55RRJEyCdcFZ5W2SEA/59d+l2hNsk3gI6kiWRdRPdOtqZmsQzFN1pQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", @@ -866,6 +872,8 @@ "integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@babel/types": "^7.18.6" }, @@ -879,6 +887,8 @@ "integrity": "sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "find-babel-config": "^2.1.1", "glob": "^9.3.3", @@ -893,6 +903,8 @@ "integrity": "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, @@ -911,7 +923,9 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/baseline-browser-mapping": { "version": "2.10.43", @@ -932,6 +946,8 @@ "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "balanced-match": "^1.0.0" } @@ -976,6 +992,8 @@ "integrity": "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "peerDependencies": { "typescript": "^5" } @@ -1001,23 +1019,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/clipboardy": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-4.0.0.tgz", - "integrity": "sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w==", - "license": "MIT", - "dependencies": { - "execa": "^8.0.1", - "is-wsl": "^3.1.0", - "is64bit": "^2.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1029,6 +1030,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -1044,7 +1046,9 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/debug": { "version": "4.4.3", @@ -1081,6 +1085,8 @@ "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", "dev": true, "license": "BSD-3-Clause", + "optional": true, + "peer": true, "engines": { "node": ">=0.3.1" } @@ -1116,7 +1122,9 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/entities": { "version": "7.0.1", @@ -1124,6 +1132,8 @@ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", "dev": true, "license": "BSD-2-Clause", + "optional": true, + "peer": true, "engines": { "node": ">=0.12" }, @@ -1137,6 +1147,8 @@ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">= 0.4" } @@ -1151,29 +1163,6 @@ "node": ">=6" } }, - "node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, "node_modules/fast-check": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", @@ -1203,6 +1192,8 @@ "integrity": "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "json5": "^2.2.3" } @@ -1220,6 +1211,8 @@ "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "locate-path": "^3.0.0" }, @@ -1232,7 +1225,9 @@ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true, + "peer": true }, "node_modules/function-bind": { "version": "1.1.2", @@ -1240,6 +1235,8 @@ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -1260,6 +1257,8 @@ "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=18" }, @@ -1267,18 +1266,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/glob": { "version": "9.3.5", "resolved": "https://registry.npmjs.org/glob/-/glob-9.3.5.tgz", @@ -1286,6 +1273,8 @@ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", + "optional": true, + "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", @@ -1305,6 +1294,8 @@ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -1317,16 +1308,9 @@ "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==", "dev": true, - "license": "MIT" - }, - "node_modules/human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=16.17.0" - } + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/ini": { "version": "7.0.0", @@ -1344,6 +1328,8 @@ "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "hasown": "^2.0.3" }, @@ -1354,85 +1340,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "license": "MIT", - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-wsl": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", - "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", - "license": "MIT", - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is64bit": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is64bit/-/is64bit-2.0.0.tgz", - "integrity": "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw==", - "license": "MIT", - "dependencies": { - "system-architecture": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/js-tokens": { @@ -1488,6 +1400,8 @@ "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" @@ -1512,6 +1426,8 @@ "integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -1519,30 +1435,14 @@ "node": ">= 20" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "license": "MIT" - }, - "node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/minimatch": { "version": "8.0.7", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.7.tgz", "integrity": "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==", "dev": true, "license": "ISC", + "optional": true, + "peer": true, "dependencies": { "brace-expansion": "^2.0.1" }, @@ -1559,6 +1459,8 @@ "integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==", "dev": true, "license": "ISC", + "optional": true, + "peer": true, "engines": { "node": ">=8" } @@ -1636,54 +1538,14 @@ "node": ">=18" } }, - "node_modules/npm-run-path": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", - "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", - "license": "MIT", - "dependencies": { - "path-key": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "license": "MIT", - "dependencies": { - "mimic-fn": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "p-try": "^2.0.0" }, @@ -1700,6 +1562,8 @@ "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "p-limit": "^2.0.0" }, @@ -1713,6 +1577,8 @@ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=6" } @@ -1723,6 +1589,8 @@ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "entities": "^6.0.0" }, @@ -1736,6 +1604,8 @@ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true, "license": "BSD-2-Clause", + "optional": true, + "peer": true, "engines": { "node": ">=0.12" }, @@ -1749,6 +1619,8 @@ "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=4" } @@ -1757,6 +1629,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -1767,7 +1640,9 @@ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/path-scurry": { "version": "1.11.1", @@ -1775,6 +1650,8 @@ "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "dev": true, "license": "BlueOak-1.0.0", + "optional": true, + "peer": true, "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" @@ -1791,7 +1668,9 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, - "license": "ISC" + "license": "ISC", + "optional": true, + "peer": true }, "node_modules/path-scurry/node_modules/minipass": { "version": "7.1.3", @@ -1799,6 +1678,8 @@ "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, "license": "BlueOak-1.0.0", + "optional": true, + "peer": true, "engines": { "node": ">=16 || 14 >=14.17" } @@ -1816,6 +1697,8 @@ "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "find-up": "^3.0.0" }, @@ -1845,7 +1728,9 @@ "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz", "integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/resolve": { "version": "1.22.12", @@ -1853,6 +1738,8 @@ "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", @@ -1874,7 +1761,9 @@ "resolved": "https://registry.npmjs.org/s-js/-/s-js-0.4.9.tgz", "integrity": "sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/semver": { "version": "6.3.1", @@ -1892,6 +1781,8 @@ "integrity": "sha512-bSjOuPcwPKLSJNhr9+bZxA20nQxVle5J5MNsYRVE6cIg7KpRLXGupymePavu0jrxlPiPsr4xGZSB8yUY2sH2sw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=10" } @@ -1902,6 +1793,8 @@ "integrity": "sha512-+BDhqYM6CEn3x09v44dpa9p6974FuUB2dxk+Ctn04k0cO1Zt6QODTXfmEZK0eBaTe/fJBvP4NMGuNJ+R8T+QMg==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=10" }, @@ -1913,6 +1806,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -1925,29 +1819,20 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/solid-js": { "version": "1.9.12", "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.12.tgz", "integrity": "sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", @@ -1960,6 +1845,8 @@ "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", @@ -1978,6 +1865,8 @@ "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "ansi-regex": "^6.0.1" }, @@ -1988,24 +1877,14 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">= 0.4" }, @@ -2013,18 +1892,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/system-architecture": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/system-architecture/-/system-architecture-0.1.0.tgz", - "integrity": "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/toml": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.2.tgz", @@ -2107,6 +1974,7 @@ "integrity": "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA==", "dev": true, "license": "MIT", + "optional": true, "peer": true, "peerDependencies": { "@types/emscripten": "^1.40.0" @@ -2121,6 +1989,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" diff --git a/package.json b/package.json index 3b47720..c9ea028 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-plugin-loop", - "version": "0.6.1", + "version": "0.7.0", "description": "/loop command for opencode — run prompts on a schedule (fixed, adaptive, or maintenance), modeled after Claude Code's /loop", "type": "module", "main": "./dist/index.js", @@ -61,20 +61,13 @@ "prepublishOnly": "npm run build" }, "peerDependencies": { - "@opencode-ai/plugin": ">=1.17.18", - "@opentui/core": ">=0.4.3", - "@opentui/solid": ">=0.4.3", - "solid-js": "1.9.12" + "@opencode-ai/plugin": ">=1.17.18" }, "devDependencies": { "@babel/core": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@opencode-ai/plugin": "^1.17.18", - "@opentui/core": "0.4.3", - "@opentui/solid": "0.4.3", "@types/node": "^22.0.0", - "babel-preset-solid": "^1.9.12", - "solid-js": "1.9.12", "typescript": "^5.4.0" }, "opencode": { @@ -82,8 +75,5 @@ "commands": [ "commands/loop.md" ] - }, - "dependencies": { - "clipboardy": "4.0.0" } } diff --git a/scripts/build.mjs b/scripts/build.mjs index 16b28fc..0f87cb4 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -1,6 +1,5 @@ import { transformAsync } from "@babel/core" import presetTypeScript from "@babel/preset-typescript" -import presetSolid from "babel-preset-solid" import { execFileSync } from "node:child_process" import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises" import { createRequire } from "node:module" @@ -27,17 +26,7 @@ async function sourceFiles(directory) { async function transformSource(path) { const isTsx = extname(path) === ".tsx" - const presets = [ - ...(isTsx - ? [ - [ - presetSolid, - { moduleName: "@opentui/solid", generate: "universal" }, - ], - ] - : []), - [presetTypeScript, { allExtensions: true, isTSX: isTsx }], - ] + const presets = [[presetTypeScript, { allExtensions: true, isTSX: isTsx }]] const result = await transformAsync(await readFile(path, "utf8"), { filename: path, babelrc: false, diff --git a/src/feedback-channel.ts b/src/feedback-channel.ts deleted file mode 100644 index 80d9b2a..0000000 --- a/src/feedback-channel.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs" -import { join } from "node:path" -import { randomUUID } from "node:crypto" - -export interface LoopFeedbackPayload { - directory: string - message: string - ts: number -} - -export const LOOP_FEEDBACK_FILE = "tui-feedback.json" - -export function loopFeedbackPath(storageDir: string): string { - return join(storageDir, LOOP_FEEDBACK_FILE) -} - -export function writeLoopFeedback( - storageDir: string, - payload: LoopFeedbackPayload -): void { - mkdirSync(storageDir, { recursive: true }) - const target = loopFeedbackPath(storageDir) - const tmp = join(storageDir, `.${LOOP_FEEDBACK_FILE}.${randomUUID()}.tmp`) - writeFileSync(tmp, JSON.stringify(payload), "utf8") - renameSync(tmp, target) -} - -export function readLoopFeedback(path: string): LoopFeedbackPayload | undefined { - let raw: string - try { - raw = readFileSync(path, "utf8") - } catch { - return undefined - } - try { - const value: unknown = JSON.parse(raw) - if (typeof value !== "object" || value === null) return undefined - const record = value as Record - if (typeof record.directory !== "string") return undefined - if (typeof record.message !== "string") return undefined - if (typeof record.ts !== "number") return undefined - return { directory: record.directory, message: record.message, ts: record.ts } - } catch { - return undefined - } -} diff --git a/src/index.ts b/src/index.ts index 1e6a0c4..db5bf22 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,10 +33,10 @@ import { buildLoopTools } from "./tools/loop-tools.js" import type { LoopConfig } from "./types.js" import { buildLoopFailedPrompt, + buildLoopResultPrompt, consumeLoopCommand, createLoopLogger, errorMessage, - showLoopResult, } from "./runtime-feedback.js" const DEFAULT_CONFIG: Required = { @@ -185,6 +185,8 @@ export const LoopPlugin: Plugin = async (ctx) => { } if (result.message.startsWith("❌") && !result.modelPrompt) { result.modelPrompt = buildLoopFailedPrompt(result.message) + } else if (!result.modelPrompt) { + result.modelPrompt = buildLoopResultPrompt(result.message) } consumeLoopCommand(output.parts, result.modelPrompt) await logger(result.message.startsWith("❌") ? "error" : "info", result.message, { @@ -192,7 +194,6 @@ export const LoopPlugin: Plugin = async (ctx) => { action: commandAction(args), argumentLength: args.length, }) - await showLoopResult(ctx.client, result, logger, { storageDir, directory: ctx.directory }) }, } diff --git a/src/runtime-feedback.ts b/src/runtime-feedback.ts index 4faf57b..c8f7220 100644 --- a/src/runtime-feedback.ts +++ b/src/runtime-feedback.ts @@ -1,8 +1,5 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { Part } from "@opencode-ai/sdk" -import type { CommandParseResult } from "./scheduler.js" -import { writeLoopFeedback } from "./feedback-channel.js" -import { LOOP_FEEDBACK_TITLE } from "./tui-feedback-model.js" const SERVICE = "opencode-plugin-loop" const HANDLED_COMMAND_PROMPT = @@ -32,6 +29,21 @@ export function buildLoopFailedPrompt(message: string): string { ].join("\n") } +export function buildLoopResultPrompt(message: string): string { + const body = message.replace(/^📋\s*/, "").replace(/^📭\s*/, "") + return [ + "The opencode-plugin-loop plugin has fully handled the user's /loop command. The command result is:", + "", + body, + "", + "Present this result to the user, written in the same language the user used in their request:", + "- If it is a task list, render it as a markdown table with columns: Job ID, frequency, content, and type (every task is a session-scoped loop that auto-expires after 7 days idle). Keep the management commands (`/loop cancel|pause|resume `, `/loop stop-all`) mentioned below the table.", + "- If it confirms an action (cancel, pause, resume, stop-all), confirm concisely which task was affected and whether it will trigger again.", + "- If it is help text or an empty state, present it naturally.", + "Do not call tools, and do not execute any task prompt yourself.", + ].join("\n") +} + export type LoopLogLevel = "debug" | "info" | "warn" | "error" export type LoopLogger = ( level: LoopLogLevel, @@ -79,62 +91,3 @@ export function consumeLoopCommand( part.ignored = true } } - -function toastVariant(message: string): "info" | "success" | "error" { - if (message.startsWith("❌")) return "error" - if (message.startsWith("📋") || message.startsWith("📭")) return "info" - return "success" -} - -function toastDuration(message: string): number { - const extraLines = Math.max(0, message.split("\n").length - 1) - return Math.min(12_000, 5_000 + extraLines * 1_500) -} - -export interface ShowLoopResultOptions { - storageDir: string - directory: string -} - -export async function showLoopResult( - client: PluginInput["client"], - result: CommandParseResult, - logger: LoopLogger, - options?: ShowLoopResultOptions -): Promise { - const variant = toastVariant(result.message) - // Non-view results (start/cancel/pause/resume/stop-all) stay silent by design; - // task lists go through the feedback file so no toast lingers after the - // dialog closes; only failures surface an error toast. - if (variant === "success") return - if (variant === "info") { - if (!options) return - try { - writeLoopFeedback(options.storageDir, { - directory: options.directory, - message: result.message, - ts: Date.now(), - }) - } catch (error) { - await logger("warn", "failed to write loop feedback", { - error: errorMessage(error), - }) - } - return - } - try { - await client.tui.showToast({ - throwOnError: true, - body: { - title: LOOP_FEEDBACK_TITLE, - message: result.message, - variant, - duration: toastDuration(result.message), - }, - }) - } catch (error) { - await logger("warn", "failed to show loop command result", { - error: errorMessage(error), - }) - } -} diff --git a/src/tui-dialog-actions.ts b/src/tui-dialog-actions.ts deleted file mode 100644 index 0a5355c..0000000 --- a/src/tui-dialog-actions.ts +++ /dev/null @@ -1,54 +0,0 @@ -export type LoopDialogAction = - | { type: "copy-id"; taskId: string } - | { type: "copy-all" } - | { type: "close" } - -export function createLoopDialogActions( - taskIds: readonly string[] -): readonly LoopDialogAction[] { - return [ - ...taskIds.map((taskId) => ({ type: "copy-id" as const, taskId })), - { type: "copy-all" as const }, - { type: "close" as const }, - ] -} - -export interface LoopActionRunnerDependencies { - writeClipboard(text: string): Promise - notifySuccess(description: string): void - notifyFailure(): void - closeIfCurrent(): void -} - -export function createLoopActionRunner(input: LoopActionRunnerDependencies) { - let busy = false - - return { - get busy() { - return busy - }, - - async run(action: LoopDialogAction, message: string): Promise { - if (action.type === "close") { - input.closeIfCurrent() - return - } - if (busy) return - - busy = true - const text = action.type === "copy-all" ? message : action.taskId - const description = - action.type === "copy-all" ? "Loop result" : `Task ID ${action.taskId}` - - try { - await input.writeClipboard(text) - input.notifySuccess(description) - input.closeIfCurrent() - } catch { - input.notifyFailure() - } finally { - busy = false - } - }, - } -} diff --git a/src/tui-dialog-interaction.ts b/src/tui-dialog-interaction.ts deleted file mode 100644 index 14ec193..0000000 --- a/src/tui-dialog-interaction.ts +++ /dev/null @@ -1,69 +0,0 @@ -export interface LoopDialogInteractionController { - move(delta: number): void - select(index: number): void - activate(): void - activateAt(index: number): void - pageMessage(delta: number): void - close(): void -} - -export interface LoopDialogKeyEvent { - name: string - shift?: boolean - preventDefault(): void - stopPropagation(): void -} - -export function handleLoopDialogKey( - event: LoopDialogKeyEvent, - controller: LoopDialogInteractionController, - enabled = true -): boolean { - if (!enabled) return false - - let run: (() => void) | undefined - - switch (event.name) { - case "up": - run = () => controller.move(-1) - break - case "down": - run = () => controller.move(1) - break - case "tab": - run = () => controller.move(event.shift ? -1 : 1) - break - case "enter": - case "return": - case "space": - run = () => controller.activate() - break - case "pageup": - run = () => controller.pageMessage(-1) - break - case "pagedown": - run = () => controller.pageMessage(1) - break - case "q": - run = () => controller.close() - break - default: - return false - } - - event.preventDefault() - event.stopPropagation() - run() - return true -} - -export function createLoopDialogPointerHandlers( - index: number, - controller: LoopDialogInteractionController -) { - return { - onMouseOver: () => controller.select(index), - onMouseDown: () => controller.select(index), - onMouseUp: () => controller.activateAt(index), - } -} diff --git a/src/tui-dialog-layout.ts b/src/tui-dialog-layout.ts deleted file mode 100644 index bd78e7c..0000000 --- a/src/tui-dialog-layout.ts +++ /dev/null @@ -1,47 +0,0 @@ -export interface LoopDialogRows { - maxHeight: number - messageRows: number - listRows: number -} - -export function allocateLoopDialogRows( - terminalRows: number, - itemCount: number, - taskList = false -): LoopDialogRows { - const rows = Math.max(1, Math.floor(terminalRows)) - const available = Math.max(1, rows - 4) - const maxHeight = Math.min(28, available, Math.max(6, Math.floor(rows * 0.7))) - const items = Math.max(0, Math.floor(itemCount)) - - if (taskList) { - // header (1) + gaps (2) + hint bar (1) + gap (1) - const contentRows = Math.max(1, maxHeight - 5) - return { maxHeight, messageRows: 0, listRows: Math.min(items, contentRows) } - } - - const contentRows = Math.max(0, maxHeight - 3) - if (contentRows < 2) { - return { maxHeight, messageRows: 0, listRows: contentRows } - } - - const listRows = Math.min( - items, - Math.max(1, Math.min(6, Math.floor(contentRows * 0.4))) - ) - - return { - maxHeight, - messageRows: contentRows - listRows, - listRows, - } -} - -export function moveLoopActionIndex( - current: number, - delta: number, - count: number -): number { - if (count <= 0) return 0 - return ((current + delta) % count + count) % count -} diff --git a/src/tui-dialog-view.tsx b/src/tui-dialog-view.tsx deleted file mode 100644 index 0fa2085..0000000 --- a/src/tui-dialog-view.tsx +++ /dev/null @@ -1,326 +0,0 @@ -/** @jsxImportSource @opentui/solid */ -import { RGBA, TextAttributes, type ScrollBoxRenderable } from "@opentui/core" -import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid" -import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js" -import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui" - -import type { LoopDialogAction } from "./tui-dialog-actions.js" -import type { LoopTaskInfo } from "./tui-feedback-model.js" -import { - createLoopDialogPointerHandlers, - handleLoopDialogKey, - type LoopDialogInteractionController, -} from "./tui-dialog-interaction.js" -import { - allocateLoopDialogRows, - moveLoopActionIndex, -} from "./tui-dialog-layout.js" - -export interface LoopFeedbackDialogProps { - message: string - variant: "info" | "success" | "warning" | "error" - tasks: readonly LoopTaskInfo[] - actions: readonly LoopDialogAction[] - theme: TuiThemeCurrent - onActivate(action: LoopDialogAction): void | Promise - onClose(): void -} - -const transparent = RGBA.fromInts(0, 0, 0, 0) - -function label(action: LoopDialogAction): string { - if (action.type === "copy-id") return `Copy ID: ${action.taskId}` - if (action.type === "copy-all") return "Copy all" - return "Close" -} - -function description(action: LoopDialogAction): string { - if (action.type === "copy-id") return "Copy this task ID" - if (action.type === "copy-all") return "Copy the complete Loop result" - return "Close this Loop result" -} - -function idLabel(task: LoopTaskInfo): string { - return task.session ? `${task.id} [s:${task.session}]` : task.id -} - -function intervalLabel(task: LoopTaskInfo): string { - return task.once ? `${task.interval} · once` : task.interval -} - -function padEnd(value: string, width: number): string { - return value.length >= width ? value : value + " ".repeat(width - value.length) -} - -export function LoopFeedbackDialog(props: LoopFeedbackDialogProps): JSX.Element { - const dimensions = useTerminalDimensions() - const [selected, setSelected] = createSignal(0) - const taskMode = createMemo(() => props.tasks.length > 0) - const rows = createMemo(() => - allocateLoopDialogRows( - dimensions().height, - props.actions.length, - taskMode() - ) - ) - const idWidth = createMemo(() => - props.tasks.reduce((width, task) => Math.max(width, idLabel(task).length), 0) - ) - const intervalWidth = createMemo(() => - props.tasks.reduce( - (width, task) => Math.max(width, intervalLabel(task).length), - 0 - ) - ) - const taskById = createMemo(() => { - const map = new Map() - for (const task of props.tasks) map.set(task.id, task) - return map - }) - let messageScroll: ScrollBoxRenderable | undefined - let actionScroll: ScrollBoxRenderable | undefined - let keyboardReady = false - let mounted = true - - const keepSelectedVisible = () => { - const scroll = actionScroll - const target = scroll?.getChildren()[selected()] - if (!scroll || !target) return - - const offset = target.y - scroll.y - if (offset < 0) scroll.scrollBy(offset) - if (offset >= scroll.height) scroll.scrollBy(offset - scroll.height + 1) - } - - const controller: LoopDialogInteractionController = { - move(delta) { - setSelected((index) => - moveLoopActionIndex(index, delta, props.actions.length) - ) - }, - select(index) { - if (index < 0 || index >= props.actions.length) return - setSelected(index) - }, - activate() { - const action = props.actions[selected()] - if (action) void props.onActivate(action) - }, - activateAt(index) { - const action = props.actions[index] - if (!action) return - setSelected(index) - void props.onActivate(action) - }, - pageMessage(delta) { - const target = taskMode() ? actionScroll : messageScroll - if (!target) return - target.scrollBy(delta * Math.max(1, target.height - 1)) - }, - close() { - props.onClose() - }, - } - - useKeyboard((event) => { - handleLoopDialogKey(event, controller, keyboardReady) - }) - queueMicrotask(() => { - if (mounted) keyboardReady = true - }) - createEffect(() => { - selected() - queueMicrotask(keepSelectedVisible) - }) - onCleanup(() => { - mounted = false - keyboardReady = false - messageScroll = undefined - actionScroll = undefined - }) - - const icon = () => - ({ info: "ℹ", success: "✓", warning: "⚠", error: "✕" })[ - props.variant - ] - - return ( - - - - - {taskMode() ? "🔁" : icon()} Loop tasks - - - ({props.tasks.length}) - - - esc - - - 0}> - (messageScroll = value)} - height={rows().messageRows} - minHeight={rows().messageRows} - maxHeight={rows().messageRows} - flexShrink={0} - paddingLeft={3} - paddingRight={3} - scrollbarOptions={{ visible: true }} - > - {props.message} - - - - 0}> - (actionScroll = value)} - height={rows().listRows} - minHeight={rows().listRows} - maxHeight={rows().listRows} - flexShrink={0} - paddingLeft={1} - paddingRight={1} - scrollbarOptions={{ visible: false }} - > - - {(action, index) => { - const active = () => selected() === index() - const pointer = createLoopDialogPointerHandlers( - index(), - controller - ) - const task = () => - action.type === "copy-id" - ? taskById().get(action.taskId) - : undefined - const fg = () => - active() - ? props.theme.selectedListItemText - : props.theme.text - const fgMuted = () => - active() - ? props.theme.selectedListItemText - : props.theme.textMuted - return ( - - - {label(action)} - - - {description(action)} - - - } - > - {(current: () => LoopTaskInfo) => ( - - - {current().status === "paused" ? "⏸" : "▶"} - - - {padEnd(idLabel(current()), idWidth())} - - - {padEnd(intervalLabel(current()), intervalWidth())} - - - {current().prompt} - - - )} - - ) - }} - - - - - - - - ↑↓ move · enter copy ID · q close - - - - - ) -} diff --git a/src/tui-feedback-model.ts b/src/tui-feedback-model.ts deleted file mode 100644 index e06252f..0000000 --- a/src/tui-feedback-model.ts +++ /dev/null @@ -1,125 +0,0 @@ -export const LOOP_FEEDBACK_TITLE = "Loop · opencode-plugin-loop" -export const LOOP_COPY_TITLE = "Loop copy" - -export type LoopFeedbackVariant = "info" | "success" | "warning" | "error" - -export interface LoopFeedbackInput { - message: string - variant: LoopFeedbackVariant -} - -export interface LoopTaskInfo { - readonly id: string - readonly status: "active" | "paused" - readonly interval: string - readonly prompt: string - readonly once: boolean - readonly session?: string -} - -export interface LoopFeedbackModel { - readonly message: string - readonly variant: LoopFeedbackVariant - readonly taskIds: readonly string[] - readonly tasks: readonly LoopTaskInfo[] -} - -interface IndexedTaskId { - index: number - id: string -} - -const TASK_ID = "[A-Za-z0-9_-]+" -const TASK_ID_PATTERNS = [ - new RegExp(`\\[id=(${TASK_ID})\\]`, "g"), - new RegExp(`^\\s*\\[(${TASK_ID})\\]`, "gm"), -] - -const TASK_LINE = /^\s*\[([A-Za-z0-9_-]+)\](?:\s+\[s:([^\]]+)\])?\s+(▶ active|⏸ paused)\s+•\s+(.+?)\s+•\s+(.*)$/ -const ONCE_PREFIX = "once • " - -export function parseLoopTaskList(message: string): LoopTaskInfo[] { - const tasks: LoopTaskInfo[] = [] - for (const line of message.split("\n")) { - const match = TASK_LINE.exec(line) - if (!match) continue - const [, id, session, status, interval, rest] = match - if (!id || !status || !interval) continue - const once = rest.startsWith(ONCE_PREFIX) - tasks.push({ - id, - status: status.startsWith("⏸") ? "paused" : "active", - interval, - prompt: once ? rest.slice(ONCE_PREFIX.length) : rest, - once, - ...(session ? { session } : {}), - }) - } - return tasks -} - -export function extractTaskIds(message: string): string[] { - const matches: IndexedTaskId[] = [] - - for (const pattern of TASK_ID_PATTERNS) { - pattern.lastIndex = 0 - let match: RegExpExecArray | null - while ((match = pattern.exec(message)) !== null) { - if (!match[1]) continue - matches.push({ index: match.index, id: match[1] }) - } - } - - matches.sort((left, right) => left.index - right.index) - - const seen = new Set() - return matches.flatMap(({ id }) => { - if (seen.has(id)) return [] - seen.add(id) - return [id] - }) -} - -export function createLoopFeedbackModel(input: LoopFeedbackInput): LoopFeedbackModel { - const taskIds = Object.freeze(extractTaskIds(input.message)) - const tasks = Object.freeze( - input.variant === "info" ? parseLoopTaskList(input.message) : [] - ) - return Object.freeze({ - message: input.message, - variant: input.variant, - taskIds, - tasks, - }) -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null -} - -function isVariant(value: unknown): value is LoopFeedbackVariant { - return value === "info" || value === "success" || value === "warning" || value === "error" -} - -export function isLoopFeedbackToast(event: unknown): event is { - type: "tui.toast.show" - properties: LoopFeedbackInput & Record -} { - if (!isRecord(event) || event.type !== "tui.toast.show") return false - if (!isRecord(event.properties)) return false - - return ( - event.properties.title === LOOP_FEEDBACK_TITLE && - typeof event.properties.message === "string" && - isVariant(event.properties.variant) - ) -} - -// Legacy fallback: servers older than 0.6.0 deliver task lists via toast -// instead of the feedback file, so the TUI still honors these events. -export function isLoopTaskListToast(event: unknown): event is { - type: "tui.toast.show" - properties: LoopFeedbackInput & Record -} { - return isLoopFeedbackToast(event) && event.properties.variant === "info" -} diff --git a/src/tui.ts b/src/tui.ts index 579dfad..7eedf38 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -1,227 +1,11 @@ -import type { - TuiPlugin, - TuiPluginApi, - TuiPluginModule, -} from "@opencode-ai/plugin/tui" -import type { JSX } from "@opentui/solid" -import clipboardy from "clipboardy" -import { watch } from "node:fs" -import { mkdirSync } from "node:fs" -import { join } from "node:path" - -import { - createLoopActionRunner, - createLoopDialogActions, - type LoopDialogAction, -} from "./tui-dialog-actions.js" -import { - LoopFeedbackDialog, - type LoopFeedbackDialogProps, -} from "./tui-dialog-view.js" -import { - LOOP_FEEDBACK_FILE, - loopFeedbackPath, - readLoopFeedback, - type LoopFeedbackPayload, -} from "./feedback-channel.js" -import { - LOOP_COPY_TITLE, - createLoopFeedbackModel, - isLoopTaskListToast, - type LoopFeedbackInput, -} from "./tui-feedback-model.js" - -export interface LoopDialogRenderInput extends LoopFeedbackDialogProps { - api: TuiPluginApi -} - -export interface LoopTuiDependencies { - writeClipboard(text: string): Promise - renderDialog?(input: LoopDialogRenderInput): JSX.Element - getDirectory?(api: TuiPluginApi): Promise - watchFeedback?( - storageDir: string, - onFeedback: (payload: LoopFeedbackPayload) => void - ): () => void -} - -const defaultDependencies: Required = { - writeClipboard: (text) => clipboardy.write(text), - renderDialog(input) { - return LoopFeedbackDialog({ - message: input.message, - variant: input.variant, - tasks: input.tasks, - actions: input.actions, - theme: input.theme, - onActivate: input.onActivate, - onClose: input.onClose, - }) - }, - async getDirectory(api) { - try { - const res = await api.client.path.get() - const directory = (res as { data?: { directory?: unknown } })?.data - ?.directory - if (typeof directory === "string" && directory) return directory - } catch { - // Fall back to the TUI process working directory below. - } - return process.cwd() - }, - watchFeedback(storageDir, onFeedback) { - mkdirSync(storageDir, { recursive: true }) - let timer: ReturnType | undefined - const watcher = watch(storageDir, (_event, filename) => { - if (filename !== LOOP_FEEDBACK_FILE) return - if (timer) clearTimeout(timer) - timer = setTimeout(() => { - const payload = readLoopFeedback(loopFeedbackPath(storageDir)) - if (payload) onFeedback(payload) - }, 50) - }) - return () => { - if (timer) clearTimeout(timer) - watcher.close() - } - }, -} - -export function createLoopTuiPlugin( - input: LoopTuiDependencies = defaultDependencies -): TuiPlugin { - const dependencies: Required = { - ...defaultDependencies, - ...input, - } - - return async (api) => { - const startedAt = Date.now() - let latestGeneration = 0 - let ownedGeneration: number | undefined - - const finishGeneration = (generation: number) => { - if (ownedGeneration !== generation) return - ownedGeneration = undefined - } - - const closeGeneration = (generation: number) => { - if (ownedGeneration !== generation) return - try { - api.ui.dialog.clear() - } finally { - finishGeneration(generation) - } - } - - const close = () => { - const generation = ownedGeneration - if (generation !== undefined) closeGeneration(generation) - } - - const logDialogFailure = (error: unknown) => { - const message = error instanceof Error ? error.message : String(error) - try { - void api.client.app - .log({ - service: "opencode-plugin-loop", - level: "warn", - message: "failed to open interactive Loop dialog", - extra: { error: message }, - }) - .catch(() => {}) - } catch { - // Diagnostics must never interrupt the server-side toast fallback. - } - } - - const notifySuccess = (description: string) => { - api.ui.toast({ - title: LOOP_COPY_TITLE, - message: `${description} copied to clipboard.`, - variant: "success", - duration: 2500, - }) - } - - const notifyFailure = () => { - api.ui.toast({ - title: LOOP_COPY_TITLE, - message: "Could not copy to the system clipboard.", - variant: "error", - duration: 4000, - }) - } - - const openFeedback = (feedback: LoopFeedbackInput) => { - const generation = ++latestGeneration - - try { - const model = createLoopFeedbackModel(feedback) - - if (ownedGeneration !== undefined) closeGeneration(ownedGeneration) - - const actions = createLoopDialogActions(model.taskIds) - const runner = createLoopActionRunner({ - writeClipboard: dependencies.writeClipboard, - notifySuccess, - notifyFailure, - closeIfCurrent: () => closeGeneration(generation), - }) - - ownedGeneration = generation - api.ui.dialog.setSize("medium") - api.ui.dialog.replace( - () => - dependencies.renderDialog({ - api, - message: model.message, - variant: model.variant, - tasks: model.tasks, - actions, - theme: api.theme.current, - onActivate(action: LoopDialogAction) { - void runner.run(action, model.message) - }, - onClose: () => closeGeneration(generation), - }), - () => finishGeneration(generation) - ) - } catch (error) { - if (ownedGeneration === generation) { - try { - api.ui.dialog.clear() - } catch { - // Continue with local state cleanup and structured diagnostics. - } - finishGeneration(generation) - } - logDialogFailure(error) - } - } - - const directory = await dependencies.getDirectory(api) - const storageDir = join(directory, ".opencode", "cache", "loop") - const unwatch = dependencies.watchFeedback(storageDir, (payload) => { - if (payload.ts < startedAt) return - if (payload.directory !== directory) return - openFeedback({ message: payload.message, variant: "info" }) - }) - - // Legacy fallback: servers older than 0.6.0 deliver task lists via - // toast, not the feedback file. Newer servers never send these toasts, - // so both channels can be active without double-opening. - const unsubscribe = api.event.on("tui.toast.show", (event) => { - if (!isLoopTaskListToast(event)) return - openFeedback(event.properties) - }) - - api.lifecycle.onDispose(() => { - unwatch() - unsubscribe() - close() - }) - } +import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui" + +// Since 0.7.0 every /loop result is presented inline by the model (Claude +// Code style), so the TUI companion no longer renders dialogs or toasts. +// The entrypoint is kept as a no-op so OpenCode installs that auto-load both +// package entrypoints keep working. +export function createLoopTuiPlugin(): TuiPlugin { + return async () => {} } export const LoopTuiPlugin = createLoopTuiPlugin() diff --git a/tests/feedback-channel.test.mjs b/tests/feedback-channel.test.mjs deleted file mode 100644 index efec87e..0000000 --- a/tests/feedback-channel.test.mjs +++ /dev/null @@ -1,50 +0,0 @@ -import assert from "node:assert/strict" -import { mkdtempSync, rmSync, writeFileSync, readdirSync } from "node:fs" -import { tmpdir } from "node:os" -import { join } from "node:path" -import test from "node:test" - -import { - LOOP_FEEDBACK_FILE, - loopFeedbackPath, - readLoopFeedback, - writeLoopFeedback, -} from "../dist/feedback-channel.js" - -test("writes and reads back a feedback payload", () => { - const dir = mkdtempSync(join(tmpdir(), "loop-feedback-")) - try { - const storageDir = join(dir, "nested", "loop") - const payload = { directory: "/repo", message: "📋 1 loop task(s):", ts: 123 } - writeLoopFeedback(storageDir, payload) - - assert.equal(loopFeedbackPath(storageDir), join(storageDir, LOOP_FEEDBACK_FILE)) - assert.deepEqual(readLoopFeedback(loopFeedbackPath(storageDir)), payload) - // Atomic write leaves no temp files behind. - assert.deepEqual( - readdirSync(storageDir).filter((name) => name.endsWith(".tmp")), - [], - ) - } finally { - rmSync(dir, { recursive: true }) - } -}) - -test("readLoopFeedback tolerates missing files and malformed payloads", () => { - const dir = mkdtempSync(join(tmpdir(), "loop-feedback-")) - try { - const path = loopFeedbackPath(dir) - assert.equal(readLoopFeedback(path), undefined) - - writeFileSync(path, "not json", "utf8") - assert.equal(readLoopFeedback(path), undefined) - - writeFileSync(path, JSON.stringify({ message: "x", ts: 1 }), "utf8") - assert.equal(readLoopFeedback(path), undefined) - - writeFileSync(path, JSON.stringify({ directory: 1, message: "x", ts: 1 }), "utf8") - assert.equal(readLoopFeedback(path), undefined) - } finally { - rmSync(dir, { recursive: true }) - } -}) diff --git a/tests/integration.test.mjs b/tests/integration.test.mjs index 1be145f..de242e3 100644 --- a/tests/integration.test.mjs +++ b/tests/integration.test.mjs @@ -291,7 +291,7 @@ test("plugin loads legacy tasks.json, drops orphans, keeps session-bound ones", } }) -test("TUI-safe /loop list writes the feedback file and consumes the model-facing command", async () => { +test("/loop list is presented inline by the model with no toast and no feedback file", async () => { const dir = mkdtempSync(join(tmpdir(), "loop-int-")) const consoleCalls = [] const originalConsole = { @@ -330,6 +330,12 @@ test("TUI-safe /loop list writes the feedback file and consumes the model-facing serverUrl: new URL("http://localhost:3000"), experimental_workspace: { register: () => {} }, }) + + await hooks["command.execute.before"]( + { command: "loop", arguments: "30m check the build", sessionID: "sA" }, + { parts: [{ id: "p0", sessionID: "sA", messageID: "m0", type: "text", text: "30m check the build" }] } + ) + const output = { parts: [ { @@ -341,7 +347,6 @@ test("TUI-safe /loop list writes the feedback file and consumes the model-facing }, ], } - await hooks["command.execute.before"]( { command: "loop", arguments: "list", sessionID: "sA" }, output @@ -349,22 +354,22 @@ test("TUI-safe /loop list writes the feedback file and consumes the model-facing assert.equal(consoleCalls.length, 0) assert.equal(toastCalls.length, 0) - const feedback = JSON.parse( - readFileSync(join(dir, ".opencode/cache/loop/tui-feedback.json"), "utf8") - ) - assert.equal(feedback.directory, dir) - assert.match(feedback.message, /loop task/i) - assert.ok(typeof feedback.ts === "number") + assert.equal(existsSync(join(dir, ".opencode/cache/loop/tui-feedback.json")), false) + // The model receives the task data plus presentation instructions. + assert.match(output.parts[0].text, /loop task\(s\)/i) + assert.match(output.parts[0].text, /check the build/) + assert.match(output.parts[0].text, /markdown table/i) + assert.match(output.parts[0].text, /Job ID/) + assert.match(output.parts[0].text, /same language/i) + assert.match(output.parts[0].text, /do not call tools/i) assert.doesNotMatch(output.parts[0].text, /^list$/) - assert.match(output.parts[0].text, /already handled/i) // The instance lock also logs ("loop instance lock acquired") — select - // the command log entry. - const commandLogs = logCalls.filter((c) => c.body?.extra?.action !== undefined) + // the command log entry for the list command. + const commandLogs = logCalls.filter( + (c) => c.body?.extra?.action === "list" + ) assert.equal(commandLogs.length, 1) - assert.equal(commandLogs[0].throwOnError, true) - assert.equal(commandLogs[0].body.extra.action, "list") - assert.equal(commandLogs[0].body.extra.argumentLength, 4) - assert.equal(commandLogs[0].body.extra.command, undefined) + assert.match(commandLogs[0].body.message, /loop task/i) } finally { if (hooks) await hooks.dispose() console.log = originalConsole.log @@ -417,7 +422,7 @@ test("starting a loop stays silent on the TUI", async () => { } }) -test("command failure becomes an error toast instead of rejecting", async () => { +test("command failure is presented inline instead of rejecting", async () => { const dir = mkdtempSync(join(tmpdir(), "loop-int-")) let hooks try { @@ -479,10 +484,9 @@ test("command failure becomes an error toast instead of rejecting", async () => output ) - assert.equal(toastCalls.length, 1) - assert.equal(toastCalls[0].body.variant, "error") - assert.match(toastCalls[0].body.message, /max tasks/i) + assert.equal(toastCalls.length, 0) assert.match(output.parts[0].text, /failed/i) + assert.match(output.parts[0].text, /max tasks/i) assert.match(output.parts[0].text, /same language/i) } finally { if (hooks) await hooks.dispose() @@ -535,51 +539,6 @@ test("natural adaptive command becomes the current model turn instead of an ackn } }) -test("toast transport failure is recorded in structured logs", async () => { - const dir = mkdtempSync(join(tmpdir(), "loop-int-")) - let hooks - try { - const logCalls = [] - const mockClient = { - app: { - async log(args) { - logCalls.push(args) - return { data: true } - }, - }, - tui: { - async showToast(args) { - if (args.throwOnError) throw new Error("toast unavailable") - return { error: { message: "toast unavailable" } } - }, - }, - } - hooks = await pluginModule.LoopPlugin({ - client: mockClient, - project: { id: "test" }, - directory: dir, - worktree: dir, - $: {}, - serverUrl: new URL("http://localhost:3000"), - experimental_workspace: { register: () => {} }, - }) - - await hooks["command.execute.before"]( - { command: "loop", arguments: "cancel", sessionID: "sA" }, - { parts: [] } - ) - - // Lock acquisition adds a log entry; find the toast-failure warn. - const warn = logCalls.find((c) => c.body?.message === "failed to show loop command result") - assert.ok(warn, "toast failure recorded") - assert.equal(warn.body.level, "warn") - assert.equal(warn.body.extra.error, "toast unavailable") - } finally { - if (hooks) await hooks.dispose() - rmSync(dir, { recursive: true }) - } -}) - test("ephemeral lifecycle: plugin reload in the same process keeps tasks", async () => { const dir = mkdtempSync(join(tmpdir(), "loop-int-")) try { diff --git a/tests/package-exports.test.mjs b/tests/package-exports.test.mjs index d060726..12c568b 100644 --- a/tests/package-exports.test.mjs +++ b/tests/package-exports.test.mjs @@ -5,16 +5,9 @@ import test from "node:test" const packageJson = JSON.parse( await readFile(new URL("../package.json", import.meta.url), "utf8"), ) -const tsconfig = JSON.parse( - await readFile(new URL("../tsconfig.json", import.meta.url), "utf8"), -) -const builtDialogView = await readFile( - new URL("../dist/tui-dialog-view.js", import.meta.url), - "utf8", -) -test("publishes the 0.6.1 release", () => { - assert.equal(packageJson.version, "0.6.1") +test("publishes the 0.7.0 release", () => { + assert.equal(packageJson.version, "0.7.0") }) test("publishes explicit server and TUI plugin entrypoints", () => { @@ -35,41 +28,18 @@ test("keeps the root server entrypoint for backward compatibility", () => { }) }) -test("declares the minimum OpenCode version required by the TUI API", () => { +test("declares the minimum OpenCode version required by the plugin API", () => { assert.equal(packageJson.engines.opencode, ">=1.17.18") assert.equal(packageJson.peerDependencies["@opencode-ai/plugin"], ">=1.17.18") }) -test("pins the clipboard runtime used by the published TUI companion", () => { - assert.equal(packageJson.dependencies.clipboardy, "4.0.0") -}) - -test("declares the host TUI peers used by the responsive dialog", () => { - assert.equal(packageJson.peerDependencies["@opentui/core"], ">=0.4.3") - assert.equal(packageJson.peerDependencies["@opentui/solid"], ">=0.4.3") - assert.equal(packageJson.peerDependencies["solid-js"], "1.9.12") +test("ships no dialog runtime dependencies", () => { + assert.equal(packageJson.dependencies, undefined) + assert.equal(packageJson.peerDependencies["@opentui/core"], undefined) + assert.equal(packageJson.peerDependencies["@opentui/solid"], undefined) + assert.equal(packageJson.peerDependencies["solid-js"], undefined) }) test("test command always builds fresh output before running tests", () => { assert.match(packageJson.scripts.test, /^npm run build && /) }) - -test("preserves TSX for the OpenTUI Solid compiler", () => { - assert.equal(tsconfig.compilerOptions.jsx, "preserve") -}) - -test("builds JavaScript through the Solid-aware build script", () => { - assert.equal(packageJson.scripts.build, "node scripts/build.mjs") -}) - -test("emits reactive Solid updates for selected dialog rows", () => { - assert.doesNotMatch(builtDialogView, /@opentui\/solid\/jsx-runtime/) - assert.match( - builtDialogView, - /import \{ effect as \S+ \} from ["']@opentui\/solid["']/, - ) - assert.match( - builtDialogView, - /setProp\([^\n]+["']backgroundColor["'][^\n]+\)/, - ) -}) diff --git a/tests/tui-dialog-actions.test.mjs b/tests/tui-dialog-actions.test.mjs deleted file mode 100644 index 8203389..0000000 --- a/tests/tui-dialog-actions.test.mjs +++ /dev/null @@ -1,95 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" - -import { - createLoopActionRunner, - createLoopDialogActions, -} from "../dist/tui-dialog-actions.js" - -test("orders task copies before Copy all and Close", () => { - assert.deepEqual(createLoopDialogActions(["abc123", "def456"]), [ - { type: "copy-id", taskId: "abc123" }, - { type: "copy-id", taskId: "def456" }, - { type: "copy-all" }, - { type: "close" }, - ]) -}) - -test("successful copies close only their current generation", async () => { - const copied = [] - let current = true - let closes = 0 - const runner = createLoopActionRunner({ - writeClipboard: async (text) => copied.push(text), - notifySuccess() {}, - notifyFailure() {}, - closeIfCurrent: () => { - if (current) closes++ - }, - }) - - await runner.run({ type: "copy-id", taskId: "abc123" }, "full message") - assert.deepEqual(copied, ["abc123"]) - assert.equal(closes, 1) - - current = false - await runner.run({ type: "copy-all" }, "full message") - assert.deepEqual(copied, ["abc123", "full message"]) - assert.equal(closes, 1) -}) - -test("clipboard failure keeps the dialog open", async () => { - let failures = 0 - let closes = 0 - const runner = createLoopActionRunner({ - writeClipboard: async () => { - throw new Error("denied") - }, - notifySuccess() {}, - notifyFailure: () => failures++, - closeIfCurrent: () => closes++, - }) - - await runner.run({ type: "copy-all" }, "exact") - assert.equal(failures, 1) - assert.equal(closes, 0) - assert.equal(runner.busy, false) -}) - -test("duplicate activation is ignored while copying", async () => { - let release - let writes = 0 - const runner = createLoopActionRunner({ - writeClipboard: () => - new Promise((resolve) => { - writes++ - release = resolve - }), - notifySuccess() {}, - notifyFailure() {}, - closeIfCurrent() {}, - }) - - const first = runner.run({ type: "copy-all" }, "exact") - const second = runner.run({ type: "copy-all" }, "exact") - assert.equal(writes, 1) - assert.equal(runner.busy, true) - release() - await Promise.all([first, second]) - assert.equal(runner.busy, false) -}) - -test("Close bypasses clipboard and closes immediately", async () => { - let writes = 0 - let closes = 0 - const runner = createLoopActionRunner({ - writeClipboard: async () => writes++, - notifySuccess() {}, - notifyFailure() {}, - closeIfCurrent: () => closes++, - }) - - await runner.run({ type: "close" }, "message") - assert.equal(writes, 0) - assert.equal(closes, 1) -}) diff --git a/tests/tui-dialog-interaction.test.mjs b/tests/tui-dialog-interaction.test.mjs deleted file mode 100644 index 9e4a702..0000000 --- a/tests/tui-dialog-interaction.test.mjs +++ /dev/null @@ -1,106 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" - -import { - createLoopDialogPointerHandlers, - handleLoopDialogKey, -} from "../dist/tui-dialog-interaction.js" - -function createController() { - const calls = [] - return { - calls, - controller: { - move: (delta) => calls.push(["move", delta]), - select: (index) => calls.push(["select", index]), - activate: () => calls.push(["activate"]), - activateAt: (index) => calls.push(["activateAt", index]), - pageMessage: (delta) => calls.push(["pageMessage", delta]), - close: () => calls.push(["close"]), - }, - } -} - -function keyEvent(name, options = {}) { - const consumed = [] - return { - event: { - name, - shift: options.shift ?? false, - preventDefault: () => consumed.push("preventDefault"), - stopPropagation: () => consumed.push("stopPropagation"), - }, - consumed, - } -} - -test("maps navigation keys to Loop dialog movement and consumes them", () => { - const cases = [ - ["up", {}, ["move", -1]], - ["down", {}, ["move", 1]], - ["tab", {}, ["move", 1]], - ["tab", { shift: true }, ["move", -1]], - ] - - for (const [name, options, expected] of cases) { - const { calls, controller } = createController() - const { event, consumed } = keyEvent(name, options) - - assert.equal(handleLoopDialogKey(event, controller), true) - assert.deepEqual(calls, [expected]) - assert.deepEqual(consumed, ["preventDefault", "stopPropagation"]) - } -}) - -test("maps activation, paging, and close keys to the selected dialog action", () => { - const cases = [ - ["enter", ["activate"]], - ["return", ["activate"]], - ["space", ["activate"]], - ["pageup", ["pageMessage", -1]], - ["pagedown", ["pageMessage", 1]], - ["q", ["close"]], - ] - - for (const [name, expected] of cases) { - const { calls, controller } = createController() - const { event, consumed } = keyEvent(name) - - assert.equal(handleLoopDialogKey(event, controller), true) - assert.deepEqual(calls, [expected]) - assert.deepEqual(consumed, ["preventDefault", "stopPropagation"]) - } -}) - -test("leaves unrelated keyboard input available to OpenCode", () => { - const { calls, controller } = createController() - const { event, consumed } = keyEvent("a") - - assert.equal(handleLoopDialogKey(event, controller), false) - assert.deepEqual(calls, []) - assert.deepEqual(consumed, []) -}) - -test("ignores the command-submitting key until the mounted dialog is armed", () => { - const { calls, controller } = createController() - const { event, consumed } = keyEvent("return") - - assert.equal(handleLoopDialogKey(event, controller, false), false) - assert.deepEqual(calls, []) - assert.deepEqual(consumed, []) -}) - -test("mouse hover and press select a row while release activates that row", () => { - const { calls, controller } = createController() - const handlers = createLoopDialogPointerHandlers(2, controller) - - handlers.onMouseOver() - handlers.onMouseDown() - handlers.onMouseUp() - - assert.deepEqual(calls, [ - ["select", 2], - ["select", 2], - ["activateAt", 2], - ]) -}) diff --git a/tests/tui-dialog-layout.test.mjs b/tests/tui-dialog-layout.test.mjs deleted file mode 100644 index a89d1cb..0000000 --- a/tests/tui-dialog-layout.test.mjs +++ /dev/null @@ -1,52 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" - -import { - allocateLoopDialogRows, - moveLoopActionIndex, -} from "../dist/tui-dialog-layout.js" - -test("allocates a capped 70-percent dialog with independent viewports", () => { - assert.deepEqual(allocateLoopDialogRows(24, 3), { - maxHeight: 16, - messageRows: 10, - listRows: 3, - }) - assert.deepEqual(allocateLoopDialogRows(80, 52), { - maxHeight: 28, - messageRows: 19, - listRows: 6, - }) -}) - -test("never allocates beyond an extremely short terminal", () => { - assert.deepEqual(allocateLoopDialogRows(7, 3), { - maxHeight: 3, - messageRows: 0, - listRows: 0, - }) -}) - -test("task list mode reserves rows for the hint bar and caps the list", () => { - assert.deepEqual(allocateLoopDialogRows(24, 6, true), { - maxHeight: 16, - messageRows: 0, - listRows: 6, - }) - assert.deepEqual(allocateLoopDialogRows(24, 30, true), { - maxHeight: 16, - messageRows: 0, - listRows: 11, - }) - assert.deepEqual(allocateLoopDialogRows(6, 4, true), { - maxHeight: 2, - messageRows: 0, - listRows: 1, - }) -}) - -test("action selection wraps in both directions", () => { - assert.equal(moveLoopActionIndex(0, -1, 3), 2) - assert.equal(moveLoopActionIndex(2, 1, 3), 0) - assert.equal(moveLoopActionIndex(4, 2, 0), 0) -}) diff --git a/tests/tui-feedback-model.test.mjs b/tests/tui-feedback-model.test.mjs deleted file mode 100644 index 5eb93c5..0000000 --- a/tests/tui-feedback-model.test.mjs +++ /dev/null @@ -1,141 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" - -import { - LOOP_COPY_TITLE, - LOOP_FEEDBACK_TITLE, - createLoopFeedbackModel, - extractTaskIds, - isLoopFeedbackToast, - parseLoopTaskList, -} from "../dist/tui-feedback-model.js" - -test("extracts a task id from loop creation feedback", () => { - assert.deepEqual(extractTaskIds("Loop started [id=abc123]"), ["abc123"]) -}) - -test("extracts task ids from list rows in display order", () => { - const message = "[first01] active\n[second2] paused" - assert.deepEqual(extractTaskIds(message), ["first01", "second2"]) -}) - -test("preserves source order across list rows and inline ids", () => { - const message = "[first01] active\nCreated [id=second2]" - assert.deepEqual(extractTaskIds(message), ["first01", "second2"]) -}) - -test("deduplicates task ids while preserving the first occurrence", () => { - const message = "Created [id=repeat1]\n[repeat1] active\n[other2] active" - assert.deepEqual(extractTaskIds(message), ["repeat1", "other2"]) -}) - -test("rejects malformed task ids", () => { - assert.deepEqual(extractTaskIds("Created [id=bad id] and [not an id]"), []) -}) - -test("parses task rows with status, interval, and prompt", () => { - const message = [ - "📋 2 loop task(s):", - " [first01] ▶ active • every 60s • check the build", - " [second2] ⏸ paused • adaptive 30s–120s • watch deploys", - "Manage: `/loop cancel|pause|resume `", - ].join("\n") - - assert.deepEqual(parseLoopTaskList(message), [ - { - id: "first01", - status: "active", - interval: "every 60s", - prompt: "check the build", - once: false, - }, - { - id: "second2", - status: "paused", - interval: "adaptive 30s–120s", - prompt: "watch deploys", - once: false, - }, - ]) -}) - -test("parses once flags and session tags", () => { - const message = - " [abc123] [s:ses_070e] ▶ active • every 60s • once • run one time" - - assert.deepEqual(parseLoopTaskList(message), [ - { - id: "abc123", - status: "active", - interval: "every 60s", - prompt: "run one time", - once: true, - session: "ses_070e", - }, - ]) -}) - -test("returns no tasks for empty or unparseable messages", () => { - assert.deepEqual(parseLoopTaskList("📭 No loop tasks."), []) - assert.deepEqual(parseLoopTaskList("Loop started [id=abc123]"), []) -}) - -test("creates an immutable feedback model with the exact message", () => { - const input = { - message: "[first01] active\n[second2] paused", - variant: "info", - } - const model = createLoopFeedbackModel(input) - - assert.deepEqual(model, { - message: input.message, - variant: "info", - taskIds: ["first01", "second2"], - tasks: [], - }) - assert.ok(Object.isFrozen(model)) - assert.ok(Object.isFrozen(model.taskIds)) - assert.ok(Object.isFrozen(model.tasks)) -}) - -test("parses tasks into the model only for info feedback", () => { - const message = - "📋 1 loop task(s):\n [first01] ▶ active • every 60s • check the build" - - const info = createLoopFeedbackModel({ message, variant: "info" }) - assert.equal(info.tasks.length, 1) - assert.equal(info.tasks[0].id, "first01") - - const success = createLoopFeedbackModel({ message, variant: "success" }) - assert.deepEqual(success.tasks, []) -}) - -test("recognizes only plugin-owned Loop feedback toast events", () => { - const event = { - type: "tui.toast.show", - properties: { - title: LOOP_FEEDBACK_TITLE, - message: "Loop started [id=abc123]", - variant: "success", - duration: 5000, - }, - } - - assert.equal(isLoopFeedbackToast(event), true) - assert.equal( - isLoopFeedbackToast({ - ...event, - properties: { ...event.properties, title: LOOP_COPY_TITLE }, - }), - false, - ) - assert.equal( - isLoopFeedbackToast({ - ...event, - properties: { ...event.properties, variant: "unknown" }, - }), - false, - ) - assert.equal(isLoopFeedbackToast({ type: "other", properties: event.properties }), false) - assert.equal(isLoopFeedbackToast(null), false) -}) diff --git a/tests/tui-plugin.test.mjs b/tests/tui-plugin.test.mjs deleted file mode 100644 index 081d46d..0000000 --- a/tests/tui-plugin.test.mjs +++ /dev/null @@ -1,401 +0,0 @@ -import assert from "node:assert/strict" -import test from "node:test" - -import LoopTuiModule, { createLoopTuiPlugin } from "../dist/tui.js" -import { LOOP_COPY_TITLE } from "../dist/tui-feedback-model.js" - -const TEST_DIR = "/tmp/loop-tui-plugin-test" - -function createFakeApi() { - const disposers = [] - const toasts = [] - const logs = [] - const layers = [] - const eventHandlers = new Map() - let dialogEntry - - const dialog = { - get depth() { - return dialogEntry ? 1 : 0 - }, - get open() { - return Boolean(dialogEntry) - }, - size: "medium", - setSize(size) { - this.size = size - }, - replace(render, onClose) { - if (dialogEntry?.onClose) dialogEntry.onClose() - dialogEntry = { view: render(), onClose } - }, - clear() { - const entry = dialogEntry - dialogEntry = undefined - entry?.onClose?.() - }, - } - - return { - client: { - app: { - async log(input) { - logs.push(input) - }, - }, - }, - ui: { - dialog, - DialogSelect(props) { - return props - }, - toast(input) { - toasts.push(input) - }, - }, - theme: { current: {} }, - event: { - on(type, handler) { - eventHandlers.set(type, handler) - return () => eventHandlers.delete(type) - }, - }, - keymap: { - registerLayer(layer) { - const record = { layer, active: true } - layers.push(record) - return () => { - record.active = false - } - }, - }, - lifecycle: { - onDispose(fn) { - disposers.push(fn) - return () => { - const index = disposers.indexOf(fn) - if (index >= 0) disposers.splice(index, 1) - } - }, - }, - __view() { - return dialogEntry?.view - }, - __emit(type, event) { - eventHandlers.get(type)?.(event) - }, - __toasts: toasts, - __logs: logs, - __layers: layers, - async __dispose() { - for (const dispose of [...disposers]) await dispose() - }, - } -} - -function createTestLoopTuiPlugin(dependencies = {}) { - const feedbackHandlers = [] - const unwatchCalls = [] - const plugin = createLoopTuiPlugin({ - writeClipboard: async () => {}, - renderDialog(props) { - return props - }, - getDirectory: async () => TEST_DIR, - watchFeedback(storageDir, onFeedback) { - feedbackHandlers.push(onFeedback) - return () => unwatchCalls.push(storageDir) - }, - ...dependencies, - }) - const emitLoop = (message, overrides = {}) => { - const payload = { - directory: TEST_DIR, - message, - ts: Date.now(), - ...overrides, - } - for (const handler of feedbackHandlers) handler(payload) - } - return { plugin, emitLoop, unwatchCalls, feedbackHandlers } -} - -function select(api, title) { - const view = api.__view() - const action = view.actions.find((candidate) => { - if (candidate.type === "copy-id") return `Copy ID: ${candidate.taskId}` === title - if (candidate.type === "copy-all") return title === "Copy all" - return title === "Close" - }) - assert.ok(action, `missing dialog action: ${title}`) - view.onActivate(action) -} - -const settle = () => new Promise((resolve) => setImmediate(resolve)) - -test("exports a TUI-only OpenCode plugin module", () => { - assert.equal(LoopTuiModule.id, "opencode-plugin-loop-tui") - assert.equal(typeof LoopTuiModule.tui, "function") - assert.equal(LoopTuiModule.server, undefined) -}) - -test("opens the dialog when task list feedback arrives", async () => { - const api = createFakeApi() - const { plugin, emitLoop } = createTestLoopTuiPlugin() - await plugin(api) - - emitLoop("📋 1 loop task(s):\n [abc123] ▶ active • every 60s • work") - assert.equal(api.ui.dialog.open, true) - assert.equal(api.__view().variant, "info") -}) - -test("opens the dialog for legacy toast feedback from older servers", async () => { - const api = createFakeApi() - const { plugin } = createTestLoopTuiPlugin() - await plugin(api) - - api.__emit("tui.toast.show", { - type: "tui.toast.show", - properties: { - title: "Loop · opencode-plugin-loop", - message: "📋 1 loop task(s):\n [abc123] ▶ active • every 60s • work", - variant: "info", - duration: 5000, - }, - }) - assert.equal(api.ui.dialog.open, true) - assert.match(api.__view().message, /abc123/) - - api.__view().onClose() - api.__emit("tui.toast.show", { - type: "tui.toast.show", - properties: { - title: "Loop · opencode-plugin-loop", - message: "🔁 Loop started [id=zzz999]", - variant: "success", - duration: 5000, - }, - }) - assert.equal(api.ui.dialog.open, false) -}) - -test("ignores stale feedback and feedback from other directories", async () => { - const api = createFakeApi() - const { plugin, emitLoop } = createTestLoopTuiPlugin() - await plugin(api) - - emitLoop("📋 stale", { ts: Date.now() - 60_000 }) - assert.equal(api.ui.dialog.open, false) - - emitLoop("📋 elsewhere", { directory: "/somewhere/else" }) - assert.equal(api.ui.dialog.open, false) - - emitLoop("📋 current") - assert.equal(api.ui.dialog.open, true) -}) - -test("passes parsed tasks to the dialog view", async () => { - const api = createFakeApi() - const { plugin, emitLoop } = createTestLoopTuiPlugin() - await plugin(api) - - emitLoop( - "📋 2 loop task(s):\n [first01] ▶ active • every 60s • check the build\n [second2] ⏸ paused • every 30s • once • ping", - ) - - assert.deepEqual(api.__view().tasks, [ - { - id: "first01", - status: "active", - interval: "every 60s", - prompt: "check the build", - once: false, - }, - { - id: "second2", - status: "paused", - interval: "every 30s", - prompt: "ping", - once: true, - }, - ]) -}) - -test("opens one native dialog with per-task copy actions", async () => { - const api = createFakeApi() - const copied = [] - const { plugin, emitLoop } = createTestLoopTuiPlugin({ - writeClipboard: async (text) => copied.push(text), - }) - await plugin(api) - - emitLoop("[first01] active\n[second2] paused") - - assert.equal(api.ui.dialog.open, true) - assert.equal(api.ui.dialog.depth, 1) - assert.match(api.__view().message, /\[first01\] active/) - assert.deepEqual( - api.__view().actions.map((action) => - action.type === "copy-id" ? `Copy ID: ${action.taskId}` : action.type === "copy-all" ? "Copy all" : "Close" - ), - ["Copy ID: first01", "Copy ID: second2", "Copy all", "Close"], - ) - - select(api, "Copy ID: second2") - await settle() - assert.deepEqual(copied, ["second2"]) - assert.equal(api.ui.dialog.open, false) - assert.equal(api.__layers.filter((layer) => layer.active).length, 0) -}) - -test("copies the exact complete feedback text", async () => { - const api = createFakeApi() - const copied = [] - const { plugin, emitLoop } = createTestLoopTuiPlugin({ - writeClipboard: async (text) => copied.push(text), - }) - await plugin(api) - const message = "📋 1 loop task(s):\n [abc123] ▶ active • every 60s • work" - - emitLoop(message) - select(api, "Copy all") - await settle() - - assert.deepEqual(copied, [message]) - assert.equal(api.__toasts.at(-1).title, LOOP_COPY_TITLE) - assert.equal(api.ui.dialog.open, false) - assert.equal(api.__layers.filter((layer) => layer.active).length, 0) -}) - -test("keeps the dialog open and reports clipboard errors without recursion", async () => { - const api = createFakeApi() - const { plugin, emitLoop } = createTestLoopTuiPlugin({ - writeClipboard: async () => { - throw new Error("clipboard unavailable") - }, - }) - await plugin(api) - - emitLoop("📋 1 loop task(s):\n [abc123] ▶ active • every 60s • work") - select(api, "Copy ID: abc123") - await settle() - - assert.equal(api.ui.dialog.open, true) - assert.equal(api.__toasts.length, 1) - assert.equal(api.__toasts[0].title, LOOP_COPY_TITLE) - assert.equal(api.__toasts[0].variant, "error") -}) - -test("replaces prior Loop feedback instead of stacking dialogs", async () => { - const api = createFakeApi() - const { plugin, emitLoop } = createTestLoopTuiPlugin() - await plugin(api) - - emitLoop("📋 1 loop task(s):\n [first01] ▶ active • every 60s • work") - emitLoop("📋 1 loop task(s):\n [second2] ▶ active • every 30s • work") - - assert.equal(api.ui.dialog.depth, 1) - assert.match(api.__view().message, /second2/) - assert.equal(api.__layers.length, 0) -}) - -test("closes from the action or mounted view callback", async () => { - const api = createFakeApi() - const { plugin, emitLoop } = createTestLoopTuiPlugin() - await plugin(api) - - emitLoop("No loop tasks found") - select(api, "Close") - assert.equal(api.ui.dialog.open, false) - assert.equal(api.__layers.filter((layer) => layer.active).length, 0) - - emitLoop("No loop tasks found") - api.__view().onClose() - assert.equal(api.ui.dialog.open, false) -}) - -test("mounts dialog interaction without a plugin-level keymap layer", async () => { - const api = createFakeApi() - const { plugin, emitLoop } = createTestLoopTuiPlugin() - await plugin(api) - - emitLoop("📋 1 loop task(s):\n [abc123] ▶ active • every 60s • work") - assert.equal(api.ui.dialog.open, true) - assert.equal(typeof api.__view().onClose, "function") - assert.equal(api.__layers.length, 0) -}) - -test("a slow copy from a replaced dialog cannot close the current dialog", async () => { - const api = createFakeApi() - let resolveCopy - const { plugin, emitLoop } = createTestLoopTuiPlugin({ - writeClipboard: () => new Promise((resolve) => { - resolveCopy = resolve - }), - }) - await plugin(api) - - emitLoop("📋 1 loop task(s):\n [first01] ▶ active • every 60s • work") - select(api, "Copy ID: first01") - emitLoop("📋 1 loop task(s):\n [second2] ▶ active • every 30s • work") - assert.match(api.__view().message, /second2/) - - resolveCopy() - await settle() - assert.equal(api.ui.dialog.open, true) - assert.match(api.__view().message, /second2/) -}) - -test("stops watching and closes the dialog on disposal", async () => { - const api = createFakeApi() - const { plugin, emitLoop, unwatchCalls } = createTestLoopTuiPlugin() - await plugin(api) - - emitLoop("📋 1 loop task(s):\n [abc123] ▶ active • every 60s • work") - assert.equal(api.ui.dialog.open, true) - - await api.__dispose() - assert.equal(api.ui.dialog.open, false) - assert.equal(unwatchCalls.length, 1) - assert.equal(api.__layers.filter((layer) => layer.active).length, 0) -}) - -test("cleans up and logs when dialog rendering fails, then recovers", async () => { - const api = createFakeApi() - let failRender = true - const { plugin, emitLoop } = createTestLoopTuiPlugin({ - renderDialog(props) { - if (failRender) throw new Error("render failed") - return props - }, - }) - await plugin(api) - - assert.doesNotThrow(() => - emitLoop("📋 1 loop task(s):\n [first01] ▶ active • every 60s • work")) - await settle() - assert.equal(api.ui.dialog.open, false) - assert.equal(api.__layers.filter((layer) => layer.active).length, 0) - assert.equal(api.__logs.length, 1) - assert.equal(api.__logs[0].service, "opencode-plugin-loop") - assert.equal(api.__logs[0].level, "warn") - assert.match(api.__logs[0].message, /dialog/i) - - failRender = false - emitLoop("📋 1 loop task(s):\n [second2] ▶ active • every 30s • work") - assert.equal(api.ui.dialog.open, true) - assert.match(api.__view().message, /second2/) -}) - -test("does not depend on plugin-level keymap registration", async () => { - const api = createFakeApi() - api.keymap.registerLayer = () => { - throw new Error("plugin-level keymap registration is forbidden") - } - const { plugin, emitLoop } = createTestLoopTuiPlugin() - await plugin(api) - - emitLoop("📋 1 loop task(s):\n [second2] ▶ active • every 30s • work") - assert.equal(api.ui.dialog.open, true) - assert.equal(api.__logs.length, 0) -}) diff --git a/tsconfig.json b/tsconfig.json index b1ce5f2..a67d84c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,10 +13,14 @@ "declarationMap": false, "sourceMap": false, "resolveJsonModule": true, - "allowSyntheticDefaultImports": true, - "jsx": "preserve", - "jsxImportSource": "@opentui/solid" + "allowSyntheticDefaultImports": true }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "tests"] + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist", + "tests" + ] }