diff --git a/src/core/actions.js b/src/core/actions.js index 5219093..1881177 100644 --- a/src/core/actions.js +++ b/src/core/actions.js @@ -12,6 +12,7 @@ import { deleteWikiNote, listWikiNotes, readWikiNote, + writeWikiForRunState, writeWikiSupportingNote } from "./wiki-store.js"; @@ -273,9 +274,19 @@ export async function deleteRunAction({ id, stateDir = DEFAULT_STATE_DIR, confir if (confirmationError) { return confirmationError; } + const notes = await listWikiNotes({ stateDir }); + const runNoteIds = notes + .filter((note) => note.kind === "run" && note.runId === id) + .map((note) => note.id); + const result = await deleteRunState(id, { stateDir }); + const wikiResults = []; + for (const noteId of runNoteIds) { + wikiResults.push(await deleteWikiNote(noteId, { stateDir })); + } return { ok: true, - result: await deleteRunState(id, { stateDir }) + result, + wikiResults }; } @@ -309,7 +320,8 @@ export async function markVerificationAction({ nextAction: "review verification evidence and decide whether the run is complete" }; const paths = await writeRunState(next, { stateDir }); - return { ok: true, state: next, paths }; + const wikiPaths = await writeWikiForRunState(next, { stateDir, paths }); + return { ok: true, state: next, paths, wikiPaths }; } /** @@ -342,7 +354,8 @@ export async function markCompleteAction({ nextAction: "complete" }); const paths = await writeRunState(completed, { stateDir }); - return { ok: true, state: completed, paths }; + const wikiPaths = await writeWikiForRunState(completed, { stateDir, paths }); + return { ok: true, state: completed, paths, wikiPaths }; } /** diff --git a/src/core/demo.js b/src/core/demo.js index f8a8ca0..d3b9a1b 100644 --- a/src/core/demo.js +++ b/src/core/demo.js @@ -1,12 +1,12 @@ export const demoWorkflows = [ { - title: "Darkwear luxury exhibition site", - description: "Start a fresh local project, let Loop create a git boundary, and watch the agent run.", + title: "Product quality loop", + description: "Improve a real dashboard through UX review, bug fixes, visual QA, docs, and follow-up issue capture.", commands: [ - "mkdir darkwear-exhibit", - "cd darkwear-exhibit", + "mkdir dashboard-quality-loop", + "cd dashboard-quality-loop", "loop doctor", - "loop \"Build a darkwear luxury exhibition site MVP\"", + "loop \"Improve the current dashboard until a real user can understand it. Repeat through UX review, bug fixes, visual QA, documentation updates, and follow-up issue creation.\"", "loop status", "loop logs --follow", "loop wiki" @@ -53,7 +53,7 @@ export function renderDemoGuide() { lines.push("Most users start with:"); lines.push(""); lines.push("```sh"); - lines.push("loop \"Build the thing you want\""); + lines.push("loop \"Improve this product workflow until a real user can understand what is happening, what to read, and what to do next.\""); lines.push("```"); lines.push(""); diff --git a/src/core/tui.js b/src/core/tui.js index 57daac8..f8cac2e 100644 --- a/src/core/tui.js +++ b/src/core/tui.js @@ -12,7 +12,7 @@ import { readGraphAction, readRunLogTailAction } from "./actions.js"; -import { dashboardUrl } from "./wiki-dashboard.js"; +import { dashboardUrl, serveWikiDashboard } from "./wiki-dashboard.js"; import { openTarget } from "./open-target.js"; import { codexCommandFromOpenEffect, @@ -137,7 +137,7 @@ export function renderTuiHome(snapshot) { "Commands", " 1-9 select run logs show tail wiki list notes", " note add note verify add evidence complete mark complete", - " follow prepare codex open terminal dashboard open wiki", + " follow prepare codex open terminal dashboard start/open wiki", " agent switch refresh q quit", "" ].join("\n"); @@ -151,6 +151,17 @@ function writeStatus(output, message) { output.write(`\n${message}\n`); } +/** + * @param {{ ok: boolean, error?: { message?: string, kind?: string } }} result + * @param {string} successMessage + */ +function actionStatus(result, successMessage) { + if (result.ok) { + return successMessage; + } + return `Action failed: ${result.error?.message ?? result.error?.kind ?? "unknown error"}`; +} + /** * @param {{ * stateDir?: string, @@ -160,7 +171,8 @@ function writeStatus(output, message) { * clearScreen?: boolean, * env?: NodeJS.ProcessEnv, * openTargetImpl?: typeof openTarget, - * launchTerminalCommandImpl?: typeof launchTerminalCommand + * launchTerminalCommandImpl?: typeof launchTerminalCommand, + * serveWikiDashboardImpl?: typeof serveWikiDashboard * }} [options] */ export async function runLoopTui({ @@ -171,7 +183,8 @@ export async function runLoopTui({ clearScreen = true, env = process.env, openTargetImpl = openTarget, - launchTerminalCommandImpl = launchTerminalCommand + launchTerminalCommandImpl = launchTerminalCommand, + serveWikiDashboardImpl = serveWikiDashboard } = {}) { if (!input.isTTY || !output.isTTY) { throw new Error("Loop Agent Console requires an interactive terminal."); @@ -179,6 +192,8 @@ export async function runLoopTui({ let selectedRunId = /** @type {string | null} */ (null); let agent = /** @type {"codex" | "claudecode"} */ ("codex"); let showLogo = true; + /** @type {import("node:http").Server | null} */ + let dashboardServer = null; const rl = createInterface({ input, output }); try { while (true) { @@ -217,9 +232,23 @@ export async function runLoopTui({ continue; } if (answer === "dashboard") { - const url = dashboardUrl(); - openTargetImpl(url); - writeStatus(output, `Dashboard: ${url}`); + try { + const url = dashboardUrl(); + if (!dashboardServer) { + const served = await serveWikiDashboardImpl({ stateDir }); + if (served.server) { + dashboardServer = served.server; + } + openTargetImpl(served.url); + writeStatus(output, `Dashboard: ${served.url}`); + } else { + openTargetImpl(url); + writeStatus(output, `Dashboard: ${url}`); + } + } catch (error) { + const fallbackUrl = dashboardUrl(); + writeStatus(output, `Dashboard failed to start: ${error instanceof Error ? error.message : String(error)}\nURL: ${fallbackUrl}`); + } continue; } if (!selectedRunId) { @@ -242,7 +271,7 @@ export async function runLoopTui({ const title = (await rl.question("Title: ")).trim(); const body = (await rl.question("Body: ")).trim(); if (title && body) { - await addWikiNoteAction({ + const result = await addWikiNoteAction({ stateDir, runId: selectedRunId, targetId: selectedRunId, @@ -251,29 +280,36 @@ export async function runLoopTui({ body, confirmation: createActionConfirmation({ action: "add-note", targetId: selectedRunId, stateDir }) }); + writeStatus(output, actionStatus(result, "Note added.")); + } else { + writeStatus(output, "Title and body are required."); } continue; } if (answer === "verify") { const summary = (await rl.question("Evidence summary: ")).trim(); if (summary) { - await markVerificationAction({ + const result = await markVerificationAction({ stateDir, id: selectedRunId, summary, confirmation: createActionConfirmation({ action: "verify-run", targetId: selectedRunId, stateDir }) }); + writeStatus(output, actionStatus(result, "Verification evidence recorded.")); + } else { + writeStatus(output, "Evidence summary is required."); } continue; } if (answer === "complete") { const confirm = (await rl.question(`Mark ${selectedRunId} complete? y/N `)).trim().toLowerCase(); if (confirm === "y" || confirm === "yes") { - await markCompleteAction({ + const result = await markCompleteAction({ stateDir, id: selectedRunId, confirmation: createActionConfirmation({ action: "mark-complete", targetId: selectedRunId, stateDir }) }); + writeStatus(output, actionStatus(result, "Run marked complete.")); } continue; } @@ -328,5 +364,8 @@ export async function runLoopTui({ } } finally { rl.close(); + if (dashboardServer) { + await new Promise((resolve) => dashboardServer?.close(resolve)); + } } } diff --git a/src/core/wiki-store.js b/src/core/wiki-store.js index 33e805b..30fdc5d 100644 --- a/src/core/wiki-store.js +++ b/src/core/wiki-store.js @@ -456,6 +456,7 @@ function wikiText(locale) { backToNotes: "노트로 돌아가기", liveRunLog: "실시간 실행 로그", liveTail: "라이브 테일", + logSnapshot: "로그 스냅샷", commandToWatch: "터미널에서 보기", commandToResume: "Codex 이어보기", commandUnavailable: "이 실행에서 이어보기 명령을 만들 수 없습니다.", @@ -534,6 +535,7 @@ function wikiText(locale) { backToNotes: "Back to notes", liveRunLog: "Live Run Log", liveTail: "Live tail", + logSnapshot: "Log snapshot", commandToWatch: "Watch in terminal", commandToResume: "Resume Codex", commandUnavailable: "No resume command can be built for this run.", @@ -2171,8 +2173,13 @@ export function renderRunLogHtml({ id, log, state = null, stateDir }) { : hasHangul(log) ? "ko" : "en"; const text = wikiText(locale); const content = log.trim() ? escapeHtml(log) : escapeHtml(text.outputEmpty); - const session = rawSessionFromState(state); - const sessionLabelText = sessionLabel(normalizeSession(session), locale); + const session = normalizeSession(rawSessionFromState(state)); + const sessionLabelText = sessionLabel(session, locale); + const isLive = session?.status === "running"; + const liveClass = isLive ? "live-pill" : "live-pill static"; + const liveDot = isLive ? "" : ""; + const liveLabel = isLive ? text.liveTail : text.logSnapshot; + const pollingText = isLive ? text.pollingHint : text.logSnapshot; const status = isRecord(state) && typeof state.status === "string" ? state.status : text.notRecorded; const phase = isRecord(state) && typeof state.phase === "string" ? state.phase : text.notRecorded; const statusText = status === text.notRecorded ? status : displayStatus(status, locale); @@ -2183,8 +2190,10 @@ export function renderRunLogHtml({ id, log, state = null, stateDir }) { const boot = jsonScript({ id, emptyText: text.outputEmpty, - pollingHint: text.pollingHint + pollingHint: pollingText, + isLive }); + const livePollingScript = isLive ? "setInterval(pollLog, 1000);" : ""; return ` @@ -2210,6 +2219,7 @@ export function renderRunLogHtml({ id, log, state = null, stateDir }) { .traffic span:nth-child(2) { background: var(--amber); } .traffic span:nth-child(3) { background: var(--green); } .live-pill { display: inline-flex; align-items: center; gap: 7px; color: var(--green); font-size: 12px; font-weight: 800; } + .live-pill.static { color: var(--muted); } .live-dot { width: 8px; height: 8px; border-radius: 999px; background: var(--green); box-shadow: 0 0 16px rgba(119,217,154,.75); animation: pulse 1.2s ease-in-out infinite; } pre { margin: 0; min-height: 68vh; max-height: 72vh; padding: 16px; overflow: auto; color: #f8fafc; white-space: pre-wrap; overflow-wrap: anywhere; font: 13px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; } #cursor { display: inline-block; width: 8px; height: 16px; margin-left: 2px; background: var(--blue); vertical-align: -2px; animation: blink 1s steps(2, start) infinite; } @@ -2244,7 +2254,7 @@ export function renderRunLogHtml({ id, log, state = null, stateDir }) {
- ${escapeHtml(text.liveTail)} + ${liveDot}${escapeHtml(liveLabel)}
${content}
@@ -2261,7 +2271,7 @@ export function renderRunLogHtml({ id, log, state = null, stateDir }) { ${escapeHtml(logCommand)} -

${escapeHtml(text.pollingHint)}

+

${escapeHtml(pollingText)}

${escapeHtml(text.commandToResume)}

@@ -2316,7 +2326,7 @@ export function renderRunLogHtml({ id, log, state = null, stateDir }) { }); } renderLog(output.textContent || ""); - setInterval(pollLog, 1000); + ${livePollingScript} `; diff --git a/test/actions.test.js b/test/actions.test.js index cde30e9..09552cb 100644 --- a/test/actions.test.js +++ b/test/actions.test.js @@ -11,6 +11,7 @@ import { deleteRunAction, deleteWikiNoteAction, listRunsAction, + listWikiNotes, markCompleteAction, markVerificationAction, prepareCodexOpenAction, @@ -19,9 +20,11 @@ import { readRunLog, readRunLogTailAction, readRunState, + readWikiNote, runLogPath, writeRunState, - writeWikiForRunState + writeWikiForRunState, + writeWikiSupportingNote } from "../src/index.js"; test("action module stays inside the domain boundary", async () => { @@ -104,6 +107,7 @@ test("verification and completion actions update run state through the store", a now: new Date("2026-06-13T08:00:00.000Z") }); await writeRunState(state, { stateDir }); + await writeWikiForRunState(state, { stateDir }); const verified = await markVerificationAction({ stateDir, @@ -111,18 +115,24 @@ test("verification and completion actions update run state through the store", a confirmation: createActionConfirmation({ action: "verify-run", targetId: state.id, stateDir }), summary: "manual QA passed" }); + const notesAfterVerify = await listWikiNotes({ stateDir }); + const noteAfterVerify = await readWikiNote(notesAfterVerify[0].id, { stateDir }); const blockedComplete = await markCompleteAction({ stateDir, id: state.id }); const completed = await markCompleteAction({ stateDir, id: state.id, confirmation: createActionConfirmation({ action: "mark-complete", targetId: state.id, stateDir }) }); + const notesAfterComplete = await listWikiNotes({ stateDir }); + const noteAfterComplete = await readWikiNote(notesAfterComplete[0].id, { stateDir }); assert.equal(verified.ok, true); assert.ok("state" in verified); const verifiedState = verified.state; assert.ok(verifiedState); assert.equal(verifiedState.phase, "verify"); + assert.equal(notesAfterVerify[0].phase, "verify"); + assert.match(noteAfterVerify.markdown, /manual QA passed/); assert.equal(blockedComplete.ok, false); assert.equal(completed.ok, true); assert.ok("state" in completed); @@ -130,6 +140,37 @@ test("verification and completion actions update run state through the store", a assert.ok(completedState); assert.equal(completedState.status, "complete"); assert.equal(completedState.verificationEvidence.length, 2); + assert.equal(notesAfterComplete[0].status, "complete"); + assert.match(noteAfterComplete.markdown, /complete/); +}); + +test("delete run action removes the run wiki card while preserving attached notes", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "loop-actions-delete-run-wiki-")); + const state = createRunState({ + objective: "Delete run wiki objective", + now: new Date("2026-06-13T08:00:00.000Z") + }); + await writeRunState(state, { stateDir }); + const runNote = await writeWikiForRunState(state, { stateDir }); + const attached = await writeWikiSupportingNote({ + stateDir, + runId: state.id, + kind: "plan", + title: "Preserved plan", + body: "Keep this attached note after deleting the run state." + }); + + const deleted = await deleteRunAction({ + stateDir, + id: state.id, + confirmation: createActionConfirmation({ action: "delete-run", targetId: state.id, stateDir }) + }); + const notes = await listWikiNotes({ stateDir }); + + assert.equal(deleted.ok, true); + assert.equal((await readRunState(state.id, { stateDir })).ok, false); + assert.ok(!notes.some((note) => note.id === runNote.id)); + assert.ok(notes.some((note) => note.id === attached.id)); }); test("follow-up action creates lineage without launching an agent", async () => { diff --git a/test/adapter.test.js b/test/adapter.test.js index af3746c..21f6f57 100644 --- a/test/adapter.test.js +++ b/test/adapter.test.js @@ -298,7 +298,8 @@ test("CLI demo prints workflows without writing local state", async () => { assert.equal(result.status, 0, result.stderr || result.stdout); assert.match(result.stdout, /Loop Demo/); - assert.match(result.stdout, /darkwear luxury exhibition/i); + assert.match(result.stdout, /Product quality loop/); + assert.match(result.stdout, /real user can understand/i); assert.match(result.stdout, /loop doctor/); assert.match(result.stdout, /loop wiki/); assert.match(result.stdout, /loop --dry-run/); diff --git a/test/tui.test.js b/test/tui.test.js index 7d5bcdb..0b4c4bb 100644 --- a/test/tui.test.js +++ b/test/tui.test.js @@ -2,6 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { mkdtemp } from "node:fs/promises"; +import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { PassThrough } from "node:stream"; @@ -197,6 +198,55 @@ test("TUI init logo appears only on the startup render", async () => { assert.equal((text.match(/Loop Agent Console/g) ?? []).length, 2); }); +test("TUI dashboard command starts and opens the local dashboard", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "loop-tui-dashboard-")); + const input = /** @type {PassThrough & { isTTY?: boolean }} */ (new PassThrough()); + const output = /** @type {PassThrough & { isTTY?: boolean }} */ (new PassThrough()); + input.isTTY = true; + output.isTTY = true; + let promptCount = 0; + let serveCalls = 0; + let opened = ""; + let closed = false; + const server = createServer(); + server.on("close", () => { + closed = true; + }); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve(undefined)); + }); + output.on("data", (chunk) => { + if (!String(chunk).includes("loop> ")) { + return; + } + promptCount += 1; + input.write(promptCount === 1 ? "dashboard\n" : "q\n"); + }); + + await runLoopTui({ + stateDir, + input, + output, + clearScreen: false, + serveWikiDashboardImpl: async () => { + serveCalls += 1; + return { + status: "started", + url: "http://127.0.0.1:3846", + server + }; + }, + openTargetImpl: (target) => { + opened = target; + return { opened: true, recorded: false, target }; + } + }); + + assert.equal(serveCalls, 1); + assert.equal(opened, "http://127.0.0.1:3846"); + assert.equal(closed, true); +}); + test("CLI no-arg non-TTY prints TUI guidance", () => { const result = spawnSync(process.execPath, ["bin/loop.js"], { encoding: "utf8" diff --git a/test/wiki-store.test.js b/test/wiki-store.test.js index 52ac537..477303d 100644 --- a/test/wiki-store.test.js +++ b/test/wiki-store.test.js @@ -438,6 +438,19 @@ test("dashboard renders run log pages", () => { assert.match(html, /라이브 테일/); }); +test("dashboard renders missing run logs as snapshots instead of live tails", () => { + const html = renderRunLogHtml({ + id: "missing-run", + log: "", + state: null, + stateDir: ".loop" + }); + + assert.match(html, /Log snapshot/); + assert.doesNotMatch(html, /Live tail/); + assert.doesNotMatch(html, /setInterval\(pollLog, 1000\)/); +}); + test("dashboard server serves the graph view route", async () => { const stateDir = await mkdtemp(join(tmpdir(), "loop-wiki-graph-route-")); const state = createRunState({ @@ -589,6 +602,7 @@ test("dashboard action endpoints add notes and update run state with confirmatio redirect: "manual" }); const afterVerify = await readRunState(state.id, { stateDir }); + const notesAfterVerify = await listWikiNotes({ stateDir }); const complete = await fetch(`http://127.0.0.1:${port}/actions/mark-complete`, { method: "POST", body: new URLSearchParams({ @@ -603,6 +617,7 @@ test("dashboard action endpoints add notes and update run state with confirmatio redirect: "manual" }); const afterComplete = await readRunState(state.id, { stateDir }); + const notesAfterComplete = await listWikiNotes({ stateDir }); const deleteRun = await fetch(`http://127.0.0.1:${port}/actions/delete-run`, { method: "POST", body: new URLSearchParams({ @@ -617,6 +632,7 @@ test("dashboard action endpoints add notes and update run state with confirmatio redirect: "manual" }); const afterDelete = await readRunState(state.id, { stateDir }); + const notesAfterDelete = await listWikiNotes({ stateDir }); assert.equal(addNote.status, 303); assert.equal(afterAdd.length, 2); @@ -625,11 +641,15 @@ test("dashboard action endpoints add notes and update run state with confirmatio assert.equal(afterVerify.ok, true); assert.equal(afterVerify.ok && afterVerify.state.phase, "verify"); assert.ok(afterVerify.ok && afterVerify.state.verificationEvidence.some((item) => item.summary === "Dashboard verification passed.")); + assert.ok(notesAfterVerify.some((note) => note.kind === "run" && note.phase === "verify")); assert.equal(complete.status, 303); assert.equal(afterComplete.ok, true); assert.equal(afterComplete.ok && afterComplete.state.status, "complete"); + assert.ok(notesAfterComplete.some((note) => note.kind === "run" && note.status === "complete")); assert.equal(deleteRun.status, 303); assert.equal(afterDelete.ok, false); + assert.ok(!notesAfterDelete.some((note) => note.kind === "run" && note.runId === state.id)); + assert.ok(notesAfterDelete.some((note) => note.kind === "plan" && note.title === "Dashboard plan")); } finally { if (served.server) { served.server.close();