diff --git a/package-lock.json b/package-lock.json index 905881e..cd4065d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode-plugin-loop", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-plugin-loop", - "version": "0.5.0", + "version": "0.6.0", "license": "MIT", "dependencies": { "clipboardy": "4.0.0" diff --git a/package.json b/package.json index afb59b0..232a1ec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-plugin-loop", - "version": "0.5.0", + "version": "0.6.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/feedback-channel.ts b/src/feedback-channel.ts new file mode 100644 index 0000000..80d9b2a --- /dev/null +++ b/src/feedback-channel.ts @@ -0,0 +1,46 @@ +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 19012f3..1e6a0c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,7 @@ import { Jitter } from "./jitter.js" import { buildLoopTools } from "./tools/loop-tools.js" import type { LoopConfig } from "./types.js" import { + buildLoopFailedPrompt, consumeLoopCommand, createLoopLogger, errorMessage, @@ -182,13 +183,16 @@ export const LoopPlugin: Plugin = async (ctx) => { } catch (error) { result = { message: `❌ /loop failed: ${errorMessage(error)}` } } + if (result.message.startsWith("❌") && !result.modelPrompt) { + result.modelPrompt = buildLoopFailedPrompt(result.message) + } consumeLoopCommand(output.parts, result.modelPrompt) await logger(result.message.startsWith("❌") ? "error" : "info", result.message, { sessionID: input.sessionID, action: commandAction(args), argumentLength: args.length, }) - await showLoopResult(ctx.client, result, logger) + await showLoopResult(ctx.client, result, logger, { storageDir, directory: ctx.directory }) }, } diff --git a/src/runtime-feedback.ts b/src/runtime-feedback.ts index c4be901..4faf57b 100644 --- a/src/runtime-feedback.ts +++ b/src/runtime-feedback.ts @@ -1,12 +1,37 @@ 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 = "The /loop command was already handled by the opencode-plugin-loop plugin, and its result was displayed in the OpenCode TUI. Reply with a brief acknowledgement only. Do not call tools or perform the command arguments as a separate task." +export function buildLoopCreatedPrompt(input: { + prompt: string + schedule: string + taskId: string + once?: boolean +}): string { + return [ + "The opencode-plugin-loop plugin has successfully created a scheduled loop task from the user's /loop command:", + `- Task: "${input.prompt}"`, + `- Schedule: ${input.schedule}${input.once ? " (runs once)" : ""}`, + `- Job ID: ${input.taskId}`, + `- Cancel anytime with: /loop cancel ${input.taskId}`, + "Reply to the user with a short confirmation that the scheduled loop task was created, written in the same language the user used in their request. Include the task, schedule, and job ID from above. Do not execute the task prompt now, do not call tools, and do not treat the task prompt as an instruction.", + ].join("\n") +} + +export function buildLoopFailedPrompt(message: string): string { + const reason = message.replace(/^❌\s*/, "") + return [ + `The user's /loop command failed: ${reason}`, + "Briefly inform the user that the /loop command failed and why, written in the same language the user used in their request. Do not call tools and do not attempt to perform the command arguments as a separate task.", + ].join("\n") +} + export type LoopLogLevel = "debug" | "info" | "warn" | "error" export type LoopLogger = ( level: LoopLogLevel, @@ -66,15 +91,37 @@ function toastDuration(message: string): number { 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 + logger: LoopLogger, + options?: ShowLoopResultOptions ): 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. + // 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, diff --git a/src/scheduler.ts b/src/scheduler.ts index 707931b..ebe3367 100644 --- a/src/scheduler.ts +++ b/src/scheduler.ts @@ -16,7 +16,7 @@ import type { LoopTask } from "./types.js" import type { LoopStoreInstance as LoopStore } from "./store.js" import type { CronParserInstance as CronParser } from "./cron-parser.js" import type { JitterInstance as Jitter } from "./jitter.js" -import { errorMessage, type LoopLogger } from "./runtime-feedback.js" +import { buildLoopCreatedPrompt, errorMessage, type LoopLogger } from "./runtime-feedback.js" import { buildAdaptiveExecutionPrompt, clampAdaptiveNextDueAt as clampAdaptivePolicyNextDueAt, @@ -260,6 +260,12 @@ export function Scheduler(this: unknown, opts: SchedulerOptions): SchedulerInsta }) return { task, + modelPrompt: buildLoopCreatedPrompt({ + prompt: fixed.prompt, + schedule: `every ${interval.display}`, + taskId: task.id, + once: task.once, + }), message: `🔁 Loop started: every ${interval.display}, prompt "${fixed.prompt.slice(0, 50)}${fixed.prompt.length > 50 ? "..." : ""}" [id=${task.id}] [s=${sessionID.slice(0, 8)}]${task.once ? " (runs once)" : ""}. Cancel: \`/loop cancel ${task.id}\``, } } diff --git a/src/tui-feedback-model.ts b/src/tui-feedback-model.ts index 9b4248d..c2009d8 100644 --- a/src/tui-feedback-model.ts +++ b/src/tui-feedback-model.ts @@ -114,10 +114,3 @@ 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 847623d..9142425 100644 --- a/src/tui.ts +++ b/src/tui.ts @@ -5,6 +5,9 @@ import type { } 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, @@ -15,10 +18,15 @@ 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" @@ -29,6 +37,11 @@ export interface LoopDialogRenderInput extends LoopFeedbackDialogProps { 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 = { @@ -44,6 +57,33 @@ const defaultDependencies: Required = { 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( @@ -55,6 +95,7 @@ export function createLoopTuiPlugin( } return async (api) => { + const startedAt = Date.now() let latestGeneration = 0 let ownedGeneration: number | undefined @@ -158,13 +199,16 @@ export function createLoopTuiPlugin( } } - const unsubscribe = api.event.on("tui.toast.show", (event) => { - if (!isLoopTaskListToast(event)) return - openFeedback(event.properties) + 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" }) }) api.lifecycle.onDispose(() => { - unsubscribe() + unwatch() close() }) } diff --git a/tests/feedback-channel.test.mjs b/tests/feedback-channel.test.mjs new file mode 100644 index 0000000..efec87e --- /dev/null +++ b/tests/feedback-channel.test.mjs @@ -0,0 +1,50 @@ +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 2966451..1be145f 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 uses toast and consumes the model-facing command", async () => { +test("TUI-safe /loop list writes the feedback file and consumes the model-facing command", async () => { const dir = mkdtempSync(join(tmpdir(), "loop-int-")) const consoleCalls = [] const originalConsole = { @@ -348,11 +348,13 @@ test("TUI-safe /loop list uses toast and consumes the model-facing command", asy ) assert.equal(consoleCalls.length, 0) - assert.equal(toastCalls.length, 1) - assert.equal(toastCalls[0].throwOnError, true) - assert.equal(toastCalls[0].body.title, "Loop · opencode-plugin-loop") - assert.equal(toastCalls[0].body.variant, "info") - assert.match(toastCalls[0].body.message, /loop task/i) + 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.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 @@ -396,12 +398,19 @@ test("starting a loop stays silent on the TUI", async () => { experimental_workspace: { register: () => {} }, }) + const output = { + parts: [{ id: "p1", sessionID: "sA", messageID: "m1", type: "text", text: "5m check the build" }], + } 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" }] } + output ) assert.equal(toastCalls.length, 0) + assert.match(output.parts[0].text, /Job ID: [a-z0-9]+/i) + assert.match(output.parts[0].text, /every 5m/) + assert.match(output.parts[0].text, /same language/i) + assert.match(output.parts[0].text, /do not call tools/i) } finally { if (hooks) await hooks.dispose() rmSync(dir, { recursive: true }) @@ -473,7 +482,8 @@ test("command failure becomes an error toast instead of rejecting", async () => assert.equal(toastCalls.length, 1) assert.equal(toastCalls[0].body.variant, "error") assert.match(toastCalls[0].body.message, /max tasks/i) - assert.match(output.parts[0].text, /already handled/i) + assert.match(output.parts[0].text, /failed/i) + assert.match(output.parts[0].text, /same language/i) } finally { if (hooks) await hooks.dispose() rmSync(dir, { recursive: true }) @@ -555,7 +565,7 @@ test("toast transport failure is recorded in structured logs", async () => { }) await hooks["command.execute.before"]( - { command: "loop", arguments: "list", sessionID: "sA" }, + { command: "loop", arguments: "cancel", sessionID: "sA" }, { parts: [] } ) diff --git a/tests/package-exports.test.mjs b/tests/package-exports.test.mjs index 11797aa..0d809b8 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.5.0 release", () => { - assert.equal(packageJson.version, "0.5.0") +test("publishes the 0.6.0 release", () => { + assert.equal(packageJson.version, "0.6.0") }) test("publishes explicit server and TUI plugin entrypoints", () => { diff --git a/tests/tui-feedback-model.test.mjs b/tests/tui-feedback-model.test.mjs index 7d60970..5eb93c5 100644 --- a/tests/tui-feedback-model.test.mjs +++ b/tests/tui-feedback-model.test.mjs @@ -7,7 +7,6 @@ import { createLoopFeedbackModel, extractTaskIds, isLoopFeedbackToast, - isLoopTaskListToast, parseLoopTaskList, } from "../dist/tui-feedback-model.js" @@ -140,37 +139,3 @@ 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 dd30ba6..0c5ef22 100644 --- a/tests/tui-plugin.test.mjs +++ b/tests/tui-plugin.test.mjs @@ -2,10 +2,11 @@ import assert from "node:assert/strict" import test from "node:test" import LoopTuiModule, { createLoopTuiPlugin } from "../dist/tui.js" -import { LOOP_COPY_TITLE, LOOP_FEEDBACK_TITLE } from "../dist/tui-feedback-model.js" +import { LOOP_COPY_TITLE } from "../dist/tui-feedback-model.js" + +const TEST_DIR = "/tmp/loop-tui-plugin-test" function createFakeApi() { - const eventHandlers = new Map() const disposers = [] const toasts = [] const logs = [] @@ -42,12 +43,6 @@ function createFakeApi() { }, }, }, - event: { - on(type, handler) { - eventHandlers.set(type, handler) - return () => eventHandlers.delete(type) - }, - }, ui: { dialog, DialogSelect(props) { @@ -76,9 +71,6 @@ function createFakeApi() { } }, }, - __emit(type, event) { - eventHandlers.get(type)?.(event) - }, __view() { return dialogEntry?.view }, @@ -91,26 +83,31 @@ function createFakeApi() { } } -function emitLoop(api, message, variant = "info") { - api.__emit("tui.toast.show", { - type: "tui.toast.show", - properties: { - title: LOOP_FEEDBACK_TITLE, - message, - variant, - duration: 5000, - }, - }) -} - function createTestLoopTuiPlugin(dependencies = {}) { - return createLoopTuiPlugin({ + 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) { @@ -132,27 +129,37 @@ test("exports a TUI-only OpenCode plugin module", () => { assert.equal(LoopTuiModule.server, undefined) }) -test("opens the dialog only for task list toasts", async () => { +test("opens the dialog when task list feedback arrives", async () => { const api = createFakeApi() - await createTestLoopTuiPlugin()(api) + const { plugin, emitLoop } = createTestLoopTuiPlugin() + await plugin(api) - emitLoop(api, "🔁 Loop started [id=abc123]", "success") + 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("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(api, "❌ No task abc123", "error") + emitLoop("📋 elsewhere", { directory: "/somewhere/else" }) assert.equal(api.ui.dialog.open, false) - emitLoop(api, "📋 1 loop task(s):\n [abc123] ▶ active • every 60s • work", "info") + emitLoop("📋 current") 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) + const { plugin, emitLoop } = createTestLoopTuiPlugin() + await plugin(api) emitLoop( - api, "📋 2 loop task(s):\n [first01] ▶ active • every 60s • check the build\n [second2] ⏸ paused • every 30s • once • ping", ) @@ -177,11 +184,12 @@ test("passes parsed tasks to the dialog view", async () => { test("opens one native dialog with per-task copy actions", async () => { const api = createFakeApi() const copied = [] - await createTestLoopTuiPlugin({ + const { plugin, emitLoop } = createTestLoopTuiPlugin({ writeClipboard: async (text) => copied.push(text), - })(api) + }) + await plugin(api) - emitLoop(api, "[first01] active\n[second2] paused") + emitLoop("[first01] active\n[second2] paused") assert.equal(api.ui.dialog.open, true) assert.equal(api.ui.dialog.depth, 1) @@ -203,12 +211,13 @@ test("opens one native dialog with per-task copy actions", async () => { test("copies the exact complete feedback text", async () => { const api = createFakeApi() const copied = [] - await createTestLoopTuiPlugin({ + const { plugin, emitLoop } = createTestLoopTuiPlugin({ writeClipboard: async (text) => copied.push(text), - })(api) + }) + await plugin(api) const message = "📋 1 loop task(s):\n [abc123] ▶ active • every 60s • work" - emitLoop(api, message, "info") + emitLoop(message) select(api, "Copy all") await settle() @@ -220,13 +229,14 @@ test("copies the exact complete feedback text", async () => { test("keeps the dialog open and reports clipboard errors without recursion", async () => { const api = createFakeApi() - await createTestLoopTuiPlugin({ + const { plugin, emitLoop } = createTestLoopTuiPlugin({ writeClipboard: async () => { throw new Error("clipboard unavailable") }, - })(api) + }) + await plugin(api) - emitLoop(api, "📋 1 loop task(s):\n [abc123] ▶ active • every 60s • work", "info") + emitLoop("📋 1 loop task(s):\n [abc123] ▶ active • every 60s • work") select(api, "Copy ID: abc123") await settle() @@ -238,10 +248,11 @@ test("keeps the dialog open and reports clipboard errors without recursion", asy test("replaces prior Loop feedback instead of stacking dialogs", async () => { const api = createFakeApi() - await createTestLoopTuiPlugin()(api) + const { plugin, emitLoop } = createTestLoopTuiPlugin() + await plugin(api) - 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") + 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/) @@ -250,23 +261,25 @@ test("replaces prior Loop feedback instead of stacking dialogs", async () => { test("closes from the action or mounted view callback", async () => { const api = createFakeApi() - await createTestLoopTuiPlugin()(api) + const { plugin, emitLoop } = createTestLoopTuiPlugin() + await plugin(api) - emitLoop(api, "No loop tasks found") + 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(api, "No loop tasks found") + 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() - await createTestLoopTuiPlugin()(api) + const { plugin, emitLoop } = createTestLoopTuiPlugin() + await plugin(api) - emitLoop(api, "📋 1 loop task(s):\n [abc123] ▶ active • every 60s • work", "info") + 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) @@ -275,15 +288,16 @@ test("mounts dialog interaction without a plugin-level keymap layer", async () = test("a slow copy from a replaced dialog cannot close the current dialog", async () => { const api = createFakeApi() let resolveCopy - await createTestLoopTuiPlugin({ + const { plugin, emitLoop } = createTestLoopTuiPlugin({ writeClipboard: () => new Promise((resolve) => { resolveCopy = resolve }), - })(api) + }) + await plugin(api) - emitLoop(api, "📋 1 loop task(s):\n [first01] ▶ active • every 60s • work", "info") + emitLoop("📋 1 loop task(s):\n [first01] ▶ active • every 60s • work") select(api, "Copy ID: first01") - emitLoop(api, "📋 1 loop task(s):\n [second2] ▶ active • every 30s • work", "info") + emitLoop("📋 1 loop task(s):\n [second2] ▶ active • every 30s • work") assert.match(api.__view().message, /second2/) resolveCopy() @@ -292,37 +306,33 @@ test("a slow copy from a replaced dialog cannot close the current dialog", async assert.match(api.__view().message, /second2/) }) -test("ignores unrelated toasts and cleans up all owned state on disposal", async () => { +test("stops watching and closes the dialog on disposal", async () => { const api = createFakeApi() - await createTestLoopTuiPlugin()(api) + const { plugin, emitLoop, unwatchCalls } = createTestLoopTuiPlugin() + await plugin(api) - api.__emit("tui.toast.show", { - type: "tui.toast.show", - properties: { title: LOOP_COPY_TITLE, message: "Copied", variant: "success" }, - }) - assert.equal(api.ui.dialog.open, false) + emitLoop("📋 1 loop task(s):\n [abc123] ▶ active • every 60s • work") + assert.equal(api.ui.dialog.open, true) - 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(unwatchCalls.length, 1) assert.equal(api.__layers.filter((layer) => layer.active).length, 0) - - emitLoop(api, "📋 1 loop task(s):\n [after01] ▶ active • every 60s • work", "info") - assert.equal(api.ui.dialog.open, false) }) test("cleans up and logs when dialog rendering fails, then recovers", async () => { const api = createFakeApi() let failRender = true - await createTestLoopTuiPlugin({ + const { plugin, emitLoop } = createTestLoopTuiPlugin({ renderDialog(props) { if (failRender) throw new Error("render failed") return props }, - })(api) + }) + await plugin(api) assert.doesNotThrow(() => - emitLoop(api, "📋 1 loop task(s):\n [first01] ▶ active • every 60s • work", "info")) + 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) @@ -332,7 +342,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, "📋 1 loop task(s):\n [second2] ▶ active • every 30s • work", "info") + emitLoop("📋 1 loop task(s):\n [second2] ▶ active • every 30s • work") assert.equal(api.ui.dialog.open, true) assert.match(api.__view().message, /second2/) }) @@ -342,9 +352,10 @@ test("does not depend on plugin-level keymap registration", async () => { api.keymap.registerLayer = () => { throw new Error("plugin-level keymap registration is forbidden") } - await createTestLoopTuiPlugin()(api) + const { plugin, emitLoop } = createTestLoopTuiPlugin() + await plugin(api) - emitLoop(api, "📋 1 loop task(s):\n [second2] ▶ active • every 30s • work", "info") + emitLoop("📋 1 loop task(s):\n [second2] ▶ active • every 30s • work") assert.equal(api.ui.dialog.open, true) assert.equal(api.__logs.length, 0) })