From 73d9f241357f4827eed57fcecaaec29267ec707e Mon Sep 17 00:00:00 2001 From: jkrandom Date: Thu, 23 Jul 2026 21:49:14 +0800 Subject: [PATCH] feat(tui): silent non-view commands; interactive task list dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /loop start/cancel/pause/resume no longer pop a dialog or toast — only failures surface as error toasts. /loop list|status keep the native dialog, reworked into a selectable task list: color-coded status icons, aligned columns, Enter copies the task ID, plus a shortcut hint bar. Also documents the branch → PR → npm publish workflow in CLAUDE.md / AGENTS.md. Release 0.5.0. Co-Authored-By: Claude Opus 4.7 --- AGENTS.md | 20 +++ CLAUDE.md | 20 +++ README.md | 12 +- package-lock.json | 4 +- package.json | 2 +- src/runtime-feedback.ts | 6 +- src/tui-dialog-layout.ts | 24 ++-- src/tui-dialog-view.tsx | 207 ++++++++++++++++++++++-------- src/tui-feedback-model.ts | 44 +++++++ src/tui.ts | 6 +- tests/integration.test.mjs | 36 ++++++ tests/package-exports.test.mjs | 4 +- tests/tui-dialog-layout.test.mjs | 24 +++- tests/tui-feedback-model.test.mjs | 97 ++++++++++++++ tests/tui-plugin.test.mjs | 69 +++++++--- 15 files changed, 481 insertions(+), 94 deletions(-) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f3ab93f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,20 @@ +# AGENTS.md + +## Project + +opencode-plugin-loop — `/loop` command plugin for OpenCode (fixed / adaptive / maintenance scheduling). + +- Source: `src/` (TypeScript, SolidJS TUI views) +- Tests: `tests/` (node:test, runs against the `dist/` build output) +- Build + test: `npm test` (builds to `dist/` via Babel/tsc, then runs the full suite) + +## Development workflow (required) + +1. **Branch first**: cut a feature branch (e.g. `feat/xxx`) from the default branch; never commit directly to the default branch. +2. **Verify locally**: `npm test` must pass; for TUI / command-behavior changes, also verify end-to-end in OpenCode. +3. **PR and merge**: open a PR with `gh pr create` and merge into the default branch after verification. +4. **Publish to npm**: + - Bump `package.json` semver after merging (feat → minor, fix → patch). + - The npm access token lives in `/Users/wangshuai/Downloads/npm_access_token.txt`; use the most recently issued token in that file. + - Use the token only via environment variables or throwaway publish-time config (e.g. `//registry.npmjs.org/:_authToken`); **never write it into the repo or commit it**; local `.npmrc` must stay out of git. + - Publish with `npm publish`, then confirm via `npm view opencode-plugin-loop version`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9830c91 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,20 @@ +# CLAUDE.md + +## 项目 + +opencode-plugin-loop — OpenCode 的 `/loop` 命令插件,支持 fixed / adaptive / maintenance 三种调度模式。 + +- 源码:`src/`(TypeScript,SolidJS TUI 视图) +- 测试:`tests/`(node:test,运行于 `dist/` 构建产物之上) +- 构建 + 测试:`npm test`(先 Babel/tsc 构建到 `dist/`,再跑全部测试) + +## 开发流程(必须遵守) + +1. **新分支开发**:所有改动从默认分支切 feature 分支(如 `feat/xxx`),不直接在默认分支上提交。 +2. **本地验证**:`npm test` 全部通过;涉及 TUI/命令行为的改动需在 OpenCode 中手动端到端验证。 +3. **提交 PR 并合并**:验证通过后 `gh pr create`,合并到默认分支。 +4. **发布 npm**: + - 合并后按语义化版本 bump `package.json` 版本号(feat → minor,fix → patch)。 + - npm access token 存放在 `/Users/wangshuai/Downloads/npm_access_token.txt`,使用该文件中最新下发的 token 发布。 + - token 只通过环境变量或发布时临时配置使用(如 `//registry.npmjs.org/:_authToken`),**绝不写入仓库、绝不提交**;本地 `.npmrc` 不得进入 git。 + - 发布:`npm publish`,发布后 `npm view opencode-plugin-loop version` 确认。 diff --git a/README.md b/README.md index 3fd4c24..8fd5cbe 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,10 @@ 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 Loop results** — `/loop` results open in a dedicated native dialog instead of writing over the prompt +- **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 independently scrollable result and action areas +- **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 ## Requirements @@ -187,15 +187,15 @@ 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 result dialog +### Interactive task list dialog -Every `/loop` command result opens in a separate native OpenCode dialog. It keeps task output away from the prompt and provides: +`/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: -- **Copy ID: ``** for every distinct task shown in the result +- 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 -Use `Up`/`Down` or `Tab`/`Shift+Tab` to change the selected action, 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 ID** or **Copy all** action 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 result text, or press `q` or `Esc` to close. In short or narrow terminals, the dialog scales to the available viewport and keeps the result and action lists independently scrollable. A newer Loop result replaces the previous Loop dialog rather than stacking another one. +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. ### Programmatic (LLM tools) diff --git a/package-lock.json b/package-lock.json index fc94c73..905881e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode-plugin-loop", - "version": "0.2.11", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-plugin-loop", - "version": "0.2.11", + "version": "0.5.0", "license": "MIT", "dependencies": { "clipboardy": "4.0.0" diff --git a/package.json b/package.json index 9e5816e..afb59b0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-plugin-loop", - "version": "0.4.0", + "version": "0.5.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", diff --git a/src/runtime-feedback.ts b/src/runtime-feedback.ts index ae9cd10..c4be901 100644 --- a/src/runtime-feedback.ts +++ b/src/runtime-feedback.ts @@ -71,13 +71,17 @@ export async function showLoopResult( result: CommandParseResult, logger: LoopLogger ): Promise { + const variant = toastVariant(result.message) + // Non-view results (start/cancel/pause/resume/stop-all) stay silent by design; + // only task lists (info) and failures (error) surface a toast. + if (variant === "success") return try { await client.tui.showToast({ throwOnError: true, body: { title: LOOP_FEEDBACK_TITLE, message: result.message, - variant: toastVariant(result.message), + variant, duration: toastDuration(result.message), }, }) diff --git a/src/tui-dialog-layout.ts b/src/tui-dialog-layout.ts index 2fc7ee4..bd78e7c 100644 --- a/src/tui-dialog-layout.ts +++ b/src/tui-dialog-layout.ts @@ -1,31 +1,39 @@ export interface LoopDialogRows { maxHeight: number messageRows: number - actionRows: number + listRows: number } export function allocateLoopDialogRows( terminalRows: number, - actionCount: 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 contentRows = Math.max(0, maxHeight - 3) + 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, actionRows: contentRows } + return { maxHeight, messageRows: 0, listRows: contentRows } } - const actionRows = Math.min( - Math.max(0, actionCount), + const listRows = Math.min( + items, Math.max(1, Math.min(6, Math.floor(contentRows * 0.4))) ) return { maxHeight, - messageRows: contentRows - actionRows, - actionRows, + messageRows: contentRows - listRows, + listRows, } } diff --git a/src/tui-dialog-view.tsx b/src/tui-dialog-view.tsx index d23ca37..0fa2085 100644 --- a/src/tui-dialog-view.tsx +++ b/src/tui-dialog-view.tsx @@ -5,6 +5,7 @@ import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "so 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, @@ -18,6 +19,7 @@ import { export interface LoopFeedbackDialogProps { message: string variant: "info" | "success" | "warning" | "error" + tasks: readonly LoopTaskInfo[] actions: readonly LoopDialogAction[] theme: TuiThemeCurrent onActivate(action: LoopDialogAction): void | Promise @@ -38,12 +40,43 @@ function description(action: LoopDialogAction): string { 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) + 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 @@ -80,8 +113,9 @@ export function LoopFeedbackDialog(props: LoopFeedbackDialogProps): JSX.Element void props.onActivate(action) }, pageMessage(delta) { - if (!messageScroll) return - messageScroll.scrollBy(delta * Math.max(1, messageScroll.height - 1)) + const target = taskMode() ? actionScroll : messageScroll + if (!target) return + target.scrollBy(delta * Math.max(1, target.height - 1)) }, close() { props.onClose() @@ -128,9 +162,14 @@ export function LoopFeedbackDialog(props: LoopFeedbackDialogProps): JSX.Element justifyContent="space-between" flexShrink={0} > - - {icon()} Loop - + + + {taskMode() ? "🔁" : icon()} Loop tasks + + + ({props.tasks.length}) + + esc @@ -149,12 +188,12 @@ export function LoopFeedbackDialog(props: LoopFeedbackDialogProps): JSX.Element - 0}> + 0}> (actionScroll = value)} - height={rows().actionRows} - minHeight={rows().actionRows} - maxHeight={rows().actionRows} + height={rows().listRows} + minHeight={rows().listRows} + maxHeight={rows().listRows} flexShrink={0} paddingLeft={1} paddingRight={1} @@ -167,55 +206,121 @@ export function LoopFeedbackDialog(props: LoopFeedbackDialogProps): JSX.Element 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)} + + } - onMouseOver={pointer.onMouseOver} - onMouseDown={pointer.onMouseDown} - onMouseUp={pointer.onMouseUp} > - - {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 index 83fcb2f..9b4248d 100644 --- a/src/tui-feedback-model.ts +++ b/src/tui-feedback-model.ts @@ -8,10 +8,20 @@ export interface LoopFeedbackInput { 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 { @@ -25,6 +35,29 @@ const TASK_ID_PATTERNS = [ 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[] = [] @@ -49,10 +82,14 @@ export function extractTaskIds(message: string): string[] { 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, }) } @@ -77,3 +114,10 @@ export function isLoopFeedbackToast(event: unknown): event is { isVariant(event.properties.variant) ) } + +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 7292b3d..847623d 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -18,7 +18,7 @@ import { import { LOOP_COPY_TITLE, createLoopFeedbackModel, - isLoopFeedbackToast, + isLoopTaskListToast, type LoopFeedbackInput, } from "./tui-feedback-model.js" @@ -37,6 +37,7 @@ const defaultDependencies: Required = { return LoopFeedbackDialog({ message: input.message, variant: input.variant, + tasks: input.tasks, actions: input.actions, theme: input.theme, onActivate: input.onActivate, @@ -134,6 +135,7 @@ export function createLoopTuiPlugin( api, message: model.message, variant: model.variant, + tasks: model.tasks, actions, theme: api.theme.current, onActivate(action: LoopDialogAction) { @@ -157,7 +159,7 @@ export function createLoopTuiPlugin( } const unsubscribe = api.event.on("tui.toast.show", (event) => { - if (!isLoopFeedbackToast(event)) return + if (!isLoopTaskListToast(event)) return openFeedback(event.properties) }) diff --git a/tests/integration.test.mjs b/tests/integration.test.mjs index 779dad5..2966451 100644 --- a/tests/integration.test.mjs +++ b/tests/integration.test.mjs @@ -372,6 +372,42 @@ test("TUI-safe /loop list uses toast and consumes the model-facing command", asy } }) +test("starting a loop stays silent on the TUI", async () => { + const dir = mkdtempSync(join(tmpdir(), "loop-int-")) + let hooks + try { + const toastCalls = [] + const mockClient = { + app: { async log() { return true } }, + tui: { + async showToast(args) { + toastCalls.push(args) + return true + }, + }, + } + 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: "5m check the build", sessionID: "sA" }, + { parts: [{ id: "p1", sessionID: "sA", messageID: "m1", type: "text", text: "5m check the build" }] } + ) + + assert.equal(toastCalls.length, 0) + } finally { + if (hooks) await hooks.dispose() + rmSync(dir, { recursive: true }) + } +}) + test("command failure becomes an error toast instead of rejecting", async () => { const dir = mkdtempSync(join(tmpdir(), "loop-int-")) let hooks diff --git a/tests/package-exports.test.mjs b/tests/package-exports.test.mjs index 522457d..11797aa 100644 --- a/tests/package-exports.test.mjs +++ b/tests/package-exports.test.mjs @@ -13,8 +13,8 @@ const builtDialogView = await readFile( "utf8", ) -test("publishes the 0.4.0 fixes release", () => { - assert.equal(packageJson.version, "0.4.0") +test("publishes the 0.5.0 release", () => { + assert.equal(packageJson.version, "0.5.0") }) test("publishes explicit server and TUI plugin entrypoints", () => { diff --git a/tests/tui-dialog-layout.test.mjs b/tests/tui-dialog-layout.test.mjs index 9334e8b..a89d1cb 100644 --- a/tests/tui-dialog-layout.test.mjs +++ b/tests/tui-dialog-layout.test.mjs @@ -10,12 +10,12 @@ test("allocates a capped 70-percent dialog with independent viewports", () => { assert.deepEqual(allocateLoopDialogRows(24, 3), { maxHeight: 16, messageRows: 10, - actionRows: 3, + listRows: 3, }) assert.deepEqual(allocateLoopDialogRows(80, 52), { maxHeight: 28, messageRows: 19, - actionRows: 6, + listRows: 6, }) }) @@ -23,7 +23,25 @@ test("never allocates beyond an extremely short terminal", () => { assert.deepEqual(allocateLoopDialogRows(7, 3), { maxHeight: 3, messageRows: 0, - actionRows: 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, }) }) diff --git a/tests/tui-feedback-model.test.mjs b/tests/tui-feedback-model.test.mjs index d9db45b..7d60970 100644 --- a/tests/tui-feedback-model.test.mjs +++ b/tests/tui-feedback-model.test.mjs @@ -7,6 +7,8 @@ import { createLoopFeedbackModel, extractTaskIds, isLoopFeedbackToast, + isLoopTaskListToast, + parseLoopTaskList, } from "../dist/tui-feedback-model.js" test("extracts a task id from loop creation feedback", () => { @@ -32,6 +34,53 @@ 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", @@ -43,9 +92,23 @@ test("creates an immutable feedback model with the exact message", () => { 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", () => { @@ -77,3 +140,37 @@ test("recognizes only plugin-owned Loop feedback toast events", () => { assert.equal(isLoopFeedbackToast({ type: "other", properties: event.properties }), false) assert.equal(isLoopFeedbackToast(null), false) }) + +test("recognizes only info-variant Loop toasts as task lists", () => { + const base = { + type: "tui.toast.show", + properties: { + title: LOOP_FEEDBACK_TITLE, + message: "📋 1 loop task(s):", + duration: 5000, + }, + } + + assert.equal( + isLoopTaskListToast({ + ...base, + properties: { ...base.properties, variant: "info" }, + }), + true, + ) + assert.equal( + isLoopTaskListToast({ + ...base, + properties: { ...base.properties, variant: "success" }, + }), + false, + ) + assert.equal( + isLoopTaskListToast({ + ...base, + properties: { ...base.properties, variant: "error" }, + }), + false, + ) + assert.equal(isLoopTaskListToast(null), false) +}) diff --git a/tests/tui-plugin.test.mjs b/tests/tui-plugin.test.mjs index 258fd02..dd30ba6 100644 --- a/tests/tui-plugin.test.mjs +++ b/tests/tui-plugin.test.mjs @@ -132,14 +132,46 @@ test("exports a TUI-only OpenCode plugin module", () => { assert.equal(LoopTuiModule.server, undefined) }) -test("shows variant-aware status treatment in the dialog title", async () => { +test("opens the dialog only for task list toasts", async () => { const api = createFakeApi() await createTestLoopTuiPlugin()(api) - emitLoop(api, "Created", "success") - assert.equal(api.__view().variant, "success") - emitLoop(api, "Failed", "error") - assert.equal(api.__view().variant, "error") + emitLoop(api, "🔁 Loop started [id=abc123]", "success") + assert.equal(api.ui.dialog.open, false) + + emitLoop(api, "❌ No task abc123", "error") + assert.equal(api.ui.dialog.open, false) + + emitLoop(api, "📋 1 loop task(s):\n [abc123] ▶ active • every 60s • work", "info") + assert.equal(api.ui.dialog.open, true) + assert.equal(api.__view().variant, "info") +}) + +test("passes parsed tasks to the dialog view", async () => { + const api = createFakeApi() + await createTestLoopTuiPlugin()(api) + + emitLoop( + api, + "📋 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 () => { @@ -174,9 +206,9 @@ test("copies the exact complete feedback text", async () => { await createTestLoopTuiPlugin({ writeClipboard: async (text) => copied.push(text), })(api) - const message = "Loop started [id=abc123]\nCancel: /loop cancel abc123" + const message = "📋 1 loop task(s):\n [abc123] ▶ active • every 60s • work" - emitLoop(api, message, "success") + emitLoop(api, message, "info") select(api, "Copy all") await settle() @@ -194,7 +226,7 @@ test("keeps the dialog open and reports clipboard errors without recursion", asy }, })(api) - emitLoop(api, "Loop started [id=abc123]", "success") + emitLoop(api, "📋 1 loop task(s):\n [abc123] ▶ active • every 60s • work", "info") select(api, "Copy ID: abc123") await settle() @@ -208,8 +240,8 @@ test("replaces prior Loop feedback instead of stacking dialogs", async () => { const api = createFakeApi() await createTestLoopTuiPlugin()(api) - emitLoop(api, "Loop started [id=first01]", "success") - emitLoop(api, "Loop started [id=second2]", "success") + emitLoop(api, "📋 1 loop task(s):\n [first01] ▶ active • every 60s • work", "info") + emitLoop(api, "📋 1 loop task(s):\n [second2] ▶ active • every 30s • work", "info") assert.equal(api.ui.dialog.depth, 1) assert.match(api.__view().message, /second2/) @@ -234,7 +266,7 @@ test("mounts dialog interaction without a plugin-level keymap layer", async () = const api = createFakeApi() await createTestLoopTuiPlugin()(api) - emitLoop(api, "Loop started [id=abc123]", "success") + emitLoop(api, "📋 1 loop task(s):\n [abc123] ▶ active • every 60s • work", "info") assert.equal(api.ui.dialog.open, true) assert.equal(typeof api.__view().onClose, "function") assert.equal(api.__layers.length, 0) @@ -249,9 +281,9 @@ test("a slow copy from a replaced dialog cannot close the current dialog", async }), })(api) - emitLoop(api, "Loop started [id=first01]", "success") + emitLoop(api, "📋 1 loop task(s):\n [first01] ▶ active • every 60s • work", "info") select(api, "Copy ID: first01") - emitLoop(api, "Loop started [id=second2]", "success") + emitLoop(api, "📋 1 loop task(s):\n [second2] ▶ active • every 30s • work", "info") assert.match(api.__view().message, /second2/) resolveCopy() @@ -270,12 +302,12 @@ test("ignores unrelated toasts and cleans up all owned state on disposal", async }) assert.equal(api.ui.dialog.open, false) - emitLoop(api, "Loop started [id=abc123]", "success") + emitLoop(api, "📋 1 loop task(s):\n [abc123] ▶ active • every 60s • work", "info") await api.__dispose() assert.equal(api.ui.dialog.open, false) assert.equal(api.__layers.filter((layer) => layer.active).length, 0) - emitLoop(api, "Loop started [id=after01]", "success") + emitLoop(api, "📋 1 loop task(s):\n [after01] ▶ active • every 60s • work", "info") assert.equal(api.ui.dialog.open, false) }) @@ -289,7 +321,8 @@ test("cleans up and logs when dialog rendering fails, then recovers", async () = }, })(api) - assert.doesNotThrow(() => emitLoop(api, "First [id=first01]", "success")) + assert.doesNotThrow(() => + emitLoop(api, "📋 1 loop task(s):\n [first01] ▶ active • every 60s • work", "info")) await settle() assert.equal(api.ui.dialog.open, false) assert.equal(api.__layers.filter((layer) => layer.active).length, 0) @@ -299,7 +332,7 @@ test("cleans up and logs when dialog rendering fails, then recovers", async () = assert.match(api.__logs[0].message, /dialog/i) failRender = false - emitLoop(api, "Second [id=second2]", "success") + emitLoop(api, "📋 1 loop task(s):\n [second2] ▶ active • every 30s • work", "info") assert.equal(api.ui.dialog.open, true) assert.match(api.__view().message, /second2/) }) @@ -311,7 +344,7 @@ test("does not depend on plugin-level keymap registration", async () => { } await createTestLoopTuiPlugin()(api) - emitLoop(api, "Loop started [id=second2]", "success") + emitLoop(api, "📋 1 loop task(s):\n [second2] ▶ active • every 30s • work", "info") assert.equal(api.ui.dialog.open, true) assert.equal(api.__logs.length, 0) })