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 }) {${content}
${escapeHtml(logCommand)}
- ${escapeHtml(text.pollingHint)}
+${escapeHtml(pollingText)}