diff --git a/bun.lock b/bun.lock index e22cb8f..84eed56 100644 --- a/bun.lock +++ b/bun.lock @@ -18,7 +18,7 @@ "devDependencies": { "@effect/tsgo": "^0.45.0", "@oxlint/plugins": "1.82.0", - "@timmo001/oxlint-rules": "0.3.0", + "@timmo001/oxlint-rules": "0.3.3", "@types/bun": "^1.3.14", "@types/proper-lockfile": "^4.1.4", "oxlint": "1.82.0", @@ -142,7 +142,7 @@ "@timmo001/effect-gh": ["@timmo001/effect-gh@github:timmo001/effect-gh#b1bce7c", { "peerDependencies": { "effect": "4.0.0-rc.115" } }, "timmo001-effect-gh-b1bce7c", "sha512-MV4HrF/WFOG/pNGmxccPe+ZSeBzA1FoyhTU9SnvtLk2kCtBWbm8Y5UWssYWiZVnZPuRBMdWeouYW5O5rq5APOQ=="], - "@timmo001/oxlint-rules": ["@timmo001/oxlint-rules@0.3.0", "", { "peerDependencies": { "@oxlint/plugins": "1.81.0", "oxlint": "1.81.0" }, "bin": { "oxlint-rules": "dist/cli.js" } }, "sha512-TvpyUOFeQFfiLXYkFWFI7a4H6LXc/T0ILqP9w8VxBxldauATVsePddgN+rA4Ks2n63d8pkLBWedgfeVBx+8o/Q=="], + "@timmo001/oxlint-rules": ["@timmo001/oxlint-rules@0.3.3", "", { "peerDependencies": { "@oxlint/plugins": "1.82.0", "oxlint": "1.82.0" }, "bin": { "oxlint-rules": "dist/cli.js" } }, "sha512-Hshoa5LI9HV1VJRUOz7iAhdz1l12ygXB1bfp2bhYkaA1O3zvzUqAIDZa4GGuhbA9z8j3vDG1tzmxbgA7H+/cDQ=="], "@tuiparts/core": ["@tuiparts/core@0.0.6", "", { "peerDependencies": { "@opentui/core": "^0.4.3" } }, "sha512-DNqtRprlpMJGUHVTm+L0R/P2Gbn392NZdzna8YSPQkgfNjyroSKpZieg1jTTnYAQn7cz5LpiwtDjw3RCUBrulw=="], diff --git a/capture/public/service-worker.js b/capture/public/service-worker.js index 4e92941..e6c0eeb 100644 --- a/capture/public/service-worker.js +++ b/capture/public/service-worker.js @@ -1,4 +1,5 @@ const CACHE_NAME = "notes-capture-v2"; + const PUBLIC_ASSETS = ["/manifest.webmanifest", "/icons/icon.svg"]; self.addEventListener("install", (event) => { diff --git a/capture/src/capture/http.ts b/capture/src/capture/http.ts index 689566c..343bd5f 100644 --- a/capture/src/capture/http.ts +++ b/capture/src/capture/http.ts @@ -17,6 +17,7 @@ export const GENERIC_CAPTURE_ERROR = const CaptureErrorResponse = Schema.Struct({ error: Schema.Literals(Object.values(CAPTURE_ERRORS)), }); + const decodeCaptureErrorOption = Schema.decodeUnknownOption(CaptureErrorResponse); @@ -25,5 +26,6 @@ export const decodeCaptureError = (value: Input) => export function captureErrorMessage(value: Input): string { const error = decodeCaptureError(value); + return error ? `${error}. Your text is still here.` : GENERIC_CAPTURE_ERROR; } diff --git a/capture/src/capture/issuePayload.ts b/capture/src/capture/issuePayload.ts index 6867b3d..d713680 100644 --- a/capture/src/capture/issuePayload.ts +++ b/capture/src/capture/issuePayload.ts @@ -11,7 +11,9 @@ export interface IssuePayload { function defaultTitle(text: string): string { const firstLine = text.split("\n", 1)[0]?.trim() ?? ""; + if (firstLine.length <= 72) return firstLine; + return `${firstLine.slice(0, 69).trimEnd()}...`; } @@ -20,6 +22,7 @@ export function buildIssuePayload( queueLabel: string, ): IssuePayload { const title = capture.titleHint?.trim() || defaultTitle(capture.text); + return { title, labels: [queueLabel], diff --git a/capture/src/capture/repositories.ts b/capture/src/capture/repositories.ts index 3149e8d..5dfd3fa 100644 --- a/capture/src/capture/repositories.ts +++ b/capture/src/capture/repositories.ts @@ -17,12 +17,15 @@ export function parseRepositoryOptions( if (!raw) return undefined; const options = Schema.decodeUnknownSync(RepositoryOptions)(JSON.parse(raw)); + if (options.length === 0) return undefined; const repositories = new Set(options.map((option) => option.repository)); + if (repositories.size !== options.length) { throw new Error("Capture repositories contain duplicates"); } + return options; } @@ -31,16 +34,20 @@ export function validateTargetRepository( options: readonly RepositoryOption[] | undefined, ): void { if (selectedRepository === undefined) return; + if (options?.some(({ repository }) => repository === selectedRepository)) { return; } + throw new Error("Capture repository is not allowed"); } export function splitRepository(repository: string): readonly [string, string] { const separator = repository.indexOf("/"); + if (separator <= 0 || separator === repository.length - 1) { throw new Error("Capture repository is invalid"); } + return [repository.slice(0, separator), repository.slice(separator + 1)]; } diff --git a/capture/src/capture/services/AccessAuth.ts b/capture/src/capture/services/AccessAuth.ts index 7dab21a..fd8f64d 100644 --- a/capture/src/capture/services/AccessAuth.ts +++ b/capture/src/capture/services/AccessAuth.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect"; +import { Option, Schema } from "effect"; import { createRemoteJWKSet, jwtVerify } from "jose"; export interface AccessIdentity { @@ -16,20 +16,24 @@ export async function verifyAccessRequest( config: AccessConfig, ): Promise { const token = request.headers.get("Cf-Access-Jwt-Assertion"); + if (!token || config.audience === "configure-after-access-app-creation") { throw new Error("Cloudflare Access authentication is not configured"); } const issuer = `https://${config.teamDomain}`; const keys = createRemoteJWKSet(new URL(`${issuer}/cdn-cgi/access/certs`)); + const { payload } = await jwtVerify(token, keys, { audience: config.audience, issuer, }); + if (!payload.sub) throw new Error("Cloudflare Access token has no subject"); const email = Schema.decodeUnknownOption(Schema.String)(payload.email); - return email._tag === "Some" + + return Option.isSome(email) ? { subject: payload.sub, email: email.value } : { subject: payload.sub }; } diff --git a/capture/src/capture/services/GitHubIssues.ts b/capture/src/capture/services/GitHubIssues.ts index 3e82c31..a3bf494 100644 --- a/capture/src/capture/services/GitHubIssues.ts +++ b/capture/src/capture/services/GitHubIssues.ts @@ -41,11 +41,13 @@ export async function createGitHubIssue( body: JSON.stringify(payload), }, ); + if (!response.ok) { throw new Error(`GitHub issue creation failed (${response.status})`); } let result: typeof GitHubIssueResponse.Type; + try { result = Schema.decodeUnknownSync(GitHubIssueResponse)( await response.json(), @@ -53,5 +55,6 @@ export async function createGitHubIssue( } catch { throw new Error("GitHub returned an invalid issue response"); } + return { number: result.number, url: result.html_url }; } diff --git a/capture/src/middleware.ts b/capture/src/middleware.ts index c80b9d9..c467d7c 100644 --- a/capture/src/middleware.ts +++ b/capture/src/middleware.ts @@ -10,6 +10,7 @@ export const onRequest = defineMiddleware(async ({ request }, next) => { audience: env.ACCESS_AUD, teamDomain: env.ACCESS_TEAM_DOMAIN, }); + return next(); } catch { return new Response("Unauthorized", { status: 401 }); diff --git a/capture/src/pages/api/captures.ts b/capture/src/pages/api/captures.ts index cf84c5e..90997f9 100644 --- a/capture/src/pages/api/captures.ts +++ b/capture/src/pages/api/captures.ts @@ -32,30 +32,37 @@ export const POST = (async ({ request }) => { reason: "content-type", status: 415, }); + return json({ error: CAPTURE_ERRORS.expectedJson }, 415); } + const length = Number(request.headers.get("Content-Length") ?? 0); + if (length > MAX_REQUEST_BYTES) { console.warn("Capture submission rejected", { reason: "declared-size", status: 413, bytes: length, }); + return json({ error: CAPTURE_ERRORS.tooLarge }, 413); } const raw = await request.text(); const bytes = new TextEncoder().encode(raw).byteLength; + if (bytes > MAX_REQUEST_BYTES) { console.warn("Capture submission rejected", { reason: "measured-size", status: 413, bytes, }); + return json({ error: CAPTURE_ERRORS.tooLarge }, 413); } let capture: Capture; + try { capture = decodeCapture(JSON.parse(raw)); } catch { @@ -63,11 +70,13 @@ export const POST = (async ({ request }) => { reason: "invalid-capture", status: 400, }); + return json({ error: CAPTURE_ERRORS.invalidCapture }, 400); } const defaultRepository = `${env.GITHUB_OWNER}/${env.GITHUB_REPO}`; let repositories: readonly RepositoryOption[] | undefined; + try { repositories = parseRepositoryOptions(env.CAPTURE_REPOSITORIES); } catch { @@ -76,11 +85,13 @@ export const POST = (async ({ request }) => { status: 500, requestId: capture.requestId, }); + return json({ error: CAPTURE_ERRORS.invalidConfiguration }, 500); } let owner: string; let repository: string; + try { validateTargetRepository(capture.repository, repositories); [owner, repository] = splitRepository(defaultRepository); @@ -90,6 +101,7 @@ export const POST = (async ({ request }) => { status: 400, requestId: capture.requestId, }); + return json({ error: CAPTURE_ERRORS.invalidRepository }, 400); } @@ -105,6 +117,7 @@ export const POST = (async ({ request }) => { }), }), ); + return json(issue, 201); } catch { console.error("Capture submission failed", { @@ -112,6 +125,7 @@ export const POST = (async ({ request }) => { status: 502, requestId: capture.requestId, }); + return json({ error: CAPTURE_ERRORS.queueFailed }, 502); } }) satisfies APIRoute; diff --git a/capture/src/scripts/capture.ts b/capture/src/scripts/capture.ts index 8c9e50a..cf2ea5e 100644 --- a/capture/src/scripts/capture.ts +++ b/capture/src/scripts/capture.ts @@ -1,5 +1,5 @@ import { captureErrorMessage, GENERIC_CAPTURE_ERROR } from "../capture/http.js"; -import { Schema } from "effect"; +import { Option, Schema } from "effect"; import { filterRepositories, REPOSITORY_STORAGE_KEY, @@ -7,8 +7,11 @@ import { } from "./repositoryPicker.js"; const form = document.querySelector("[data-capture-form]"); + const textarea = document.querySelector("#capture"); + const status = document.querySelector("[data-status]"); + const repositoryPicker = document.querySelector( "[data-repository-picker]", ); @@ -18,27 +21,34 @@ if (!form || !textarea || !status) { } let preserveRepositorySelection = () => {}; + let repositoryForCapture: string | undefined; if (repositoryPicker) { const repositoryValue = repositoryPicker.querySelector( "[data-repository-value]", ); + const repositoryLabel = repositoryPicker.querySelector( "[data-repository-label]", ); + const repositoryTrigger = repositoryPicker.querySelector( "[data-repository-trigger]", ); + const popover = repositoryPicker.querySelector( "[data-repository-popover]", ); + const search = repositoryPicker.querySelector( "[data-repository-search]", ); + const empty = repositoryPicker.querySelector( "[data-repository-empty]", ); + const options = Array.from( repositoryPicker.querySelectorAll( "[data-repository-option]", @@ -57,10 +67,12 @@ if (repositoryPicker) { } let selectedRepository = repositoryValue.value; + const selectRepository = (repository: string) => { const selected = options.find( (option) => option.dataset.repositoryOption === repository, ); + if (!selected) return; repositoryValue.value = repository; @@ -72,6 +84,7 @@ if (repositoryPicker) { ); selectedRepository = repository; repositoryForCapture = repository; + for (const option of options) { option.setAttribute( "aria-pressed", @@ -81,11 +94,13 @@ if (repositoryPicker) { }; let storedRepository: string | null = null; + try { storedRepository = localStorage.getItem(REPOSITORY_STORAGE_KEY); } catch { // Storage can be unavailable without preventing capture submission. } + selectRepository( restoreRepository( storedRepository, @@ -102,13 +117,16 @@ if (repositoryPicker) { for (const option of options) { option.addEventListener("click", () => { const repository = option.dataset.repositoryOption; + if (repository === undefined) return; selectRepository(repository); + try { localStorage.setItem(REPOSITORY_STORAGE_KEY, repository); } catch { // Keep the in-page selection when persistence is blocked. } + popover.hidePopover?.(); }); } @@ -123,9 +141,11 @@ if (repositoryPicker) { search.value, ), ); + for (const option of options) { option.hidden = !visible.has(option.dataset.repositoryOption ?? ""); } + empty.hidden = visible.size > 0; }); @@ -141,11 +161,13 @@ if (repositoryPicker) { form.addEventListener("submit", async (event) => { event.preventDefault(); const submit = form.querySelector("[type=submit]"); + if (!submit || !textarea.value.trim()) return; submit.disabled = true; status.textContent = "Adding note..."; let responseError: string | undefined; + try { const capture = { version: 1, @@ -155,26 +177,34 @@ form.addEventListener("submit", async (event) => { source: "text", repository: repositoryForCapture, }; + const response = await fetch("/api/captures", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(capture), }); + const result = await response.json(); + if (!response.ok) { responseError = captureErrorMessage(result); throw new Error("Capture request failed"); } + const issue = Schema.decodeUnknownOption( Schema.Struct({ url: Schema.String }), )(result); - if (issue._tag === "None") { + + if (Option.isNone(issue)) { throw new Error("Capture request failed"); } + const issueUrl = new URL(issue.value.url); + if (issueUrl.protocol !== "https:" || issueUrl.hostname !== "github.com") { throw new Error("Unexpected issue URL"); } + form.reset(); preserveRepositorySelection(); const link = document.createElement("a"); diff --git a/capture/src/scripts/repositoryPicker.ts b/capture/src/scripts/repositoryPicker.ts index 9884fb0..d6cdcaa 100644 --- a/capture/src/scripts/repositoryPicker.ts +++ b/capture/src/scripts/repositoryPicker.ts @@ -5,11 +5,12 @@ export function filterRepositories( query: string, ): readonly string[] { const normalizedQuery = query.trim().toLocaleLowerCase(); - return options - .filter(({ searchText }) => - searchText.toLocaleLowerCase().includes(normalizedQuery), - ) - .map(({ repository }) => repository); + + return options.flatMap(({ repository, searchText }) => + searchText.toLocaleLowerCase().includes(normalizedQuery) + ? [repository] + : [], + ); } export function restoreRepository( diff --git a/capture/tests/capture/GitHubIssues.test.ts b/capture/tests/capture/GitHubIssues.test.ts index e91c938..9175122 100644 --- a/capture/tests/capture/GitHubIssues.test.ts +++ b/capture/tests/capture/GitHubIssues.test.ts @@ -21,6 +21,7 @@ describe("createGitHubIssue", () => { test("does not expose the response body on failure", async () => { let message = ""; + try { await createGitHubIssue( { title: "Test", body: "Body", labels: ["agent:ready"] }, @@ -30,6 +31,7 @@ describe("createGitHubIssue", () => { } catch (error) { message = error instanceof Error ? error.message : String(error); } + expect(message).toBe("GitHub issue creation failed (403)"); }); }); diff --git a/docs/scripts/generate-cli-reference.ts b/docs/scripts/generate-cli-reference.ts index eec99b5..2a04f64 100644 --- a/docs/scripts/generate-cli-reference.ts +++ b/docs/scripts/generate-cli-reference.ts @@ -6,8 +6,11 @@ import { renderHelp } from "../../src/cli/help.ts"; import { notesCommand } from "../../src/index.ts"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + const outFile = path.join(root, "src/content/docs/cli/commands.md"); + const commands = notesCommand.subcommands.flatMap((group) => group.commands); + const lines = [ "---", "title: Command Reference", @@ -34,5 +37,7 @@ for (const command of commands) { } await mkdir(path.dirname(outFile), { recursive: true }); + await writeFile(outFile, `${lines.join("\n").trimEnd()}\n`); + console.log(`Wrote ${path.relative(root, outFile)}`); diff --git a/docs/scripts/generate-mcp-reference.ts b/docs/scripts/generate-mcp-reference.ts index 60b2c5f..3d0def0 100644 --- a/docs/scripts/generate-mcp-reference.ts +++ b/docs/scripts/generate-mcp-reference.ts @@ -4,22 +4,35 @@ import path from "node:path"; import { mcpTools } from "../../src/mcp/toolMetadata.ts"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + const outFile = path.join(root, "src/content/docs/mcp/tools.md"); + const lines: string[] = []; + const push = (line = "") => lines.push(line); + const code = (text: string) => `\`${text}\``; push("---"); + push("title: MCP Tool Reference"); + push("description: Generated reference for the notes MCP tools."); + push("sidebar:"); + push(" order: 2"); + push("---"); + push(); + push( "", ); + push(); + for (const tool of mcpTools) { push(`## ${code(tool.name)}`); push(); @@ -28,14 +41,17 @@ for (const tool of mcpTools) { push(`CLI equivalent: ${code(tool.cli)}`); push(); const entries = Object.entries(tool.parameters); + if (entries.length) { push("| Parameter | Type | Default | CLI | Description |"); push("| --- | --- | --- | --- | --- |"); + for (const [name, parameter] of entries) { push( `| ${code(name)} | ${parameter.type} | ${parameter.default ?? ""} | ${parameter.cli ? code(parameter.cli) : ""} | ${parameter.description} |`, ); } + push(); } else { push("No parameters."); @@ -44,6 +60,7 @@ for (const tool of mcpTools) { } await mkdir(path.dirname(outFile), { recursive: true }); + await writeFile( outFile, `${lines @@ -51,4 +68,5 @@ await writeFile( .replace(/\n{3,}/g, "\n\n") .trimEnd()}\n`, ); + console.log(`Wrote ${path.relative(root, outFile)}`); diff --git a/docs/scripts/generate-og.mjs b/docs/scripts/generate-og.mjs index f03f93d..1d0b57d 100644 --- a/docs/scripts/generate-og.mjs +++ b/docs/scripts/generate-og.mjs @@ -8,12 +8,14 @@ import sharp from "sharp"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const W = 1200; + const H = 630; // Embed the logo as a nested SVG at a fixed position and size. const logoRaw = ( await readFile(path.join(root, "src/assets/logo.svg"), "utf8") ).trim(); + const logo = logoRaw.replace( '', '', @@ -34,4 +36,5 @@ const svg = ``; await sharp(Buffer.from(svg)).png().toFile(path.join(root, "public/og.png")); + console.log("Wrote public/og.png"); diff --git a/package.json b/package.json index 966d1d5..c40149a 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "devDependencies": { "@effect/tsgo": "^0.45.0", "@oxlint/plugins": "1.82.0", - "@timmo001/oxlint-rules": "0.3.0", + "@timmo001/oxlint-rules": "0.3.3", "@types/bun": "^1.3.14", "@types/proper-lockfile": "^4.1.4", "oxlint": "1.82.0", diff --git a/src/capture/run.ts b/src/capture/run.ts index 6941a76..96818b1 100644 --- a/src/capture/run.ts +++ b/src/capture/run.ts @@ -14,6 +14,7 @@ export const captureStatus = Effect.fn("NotesCapture.status")(function* ( ) { const client = yield* captureClient(configPath); yield* client.status; + return { available: true as const }; }); @@ -24,12 +25,15 @@ export const processLocalCapture = Effect.fn("NotesCapture.process")(function* ( ) { const capture = yield* Effect.try(() => decodeCapture(input)); const client = yield* captureClient(configPath); + const summary = yield* client.process( issuePrompt(captureBody(capture), 16_384, capture.repository), ); + if (!summary || summary.length > MAX_RESULT_LENGTH) { return yield* Effect.fail("OpenCode returned invalid result text"); } + return { status: "success" as const, requestId: capture.requestId, summary }; }); @@ -37,10 +41,12 @@ const captureClient = Effect.fn("NotesCapture.client")(function* ( configPath: string, ) { const config = yield* loadDaemonConfig(configPath); + const client = yield* OpenCodeClient.pipe( Effect.provide( OpenCodeClient.layer(config).pipe(Layer.provide(NodeServices.layer)), ), ); + return client; }); diff --git a/src/daemon/config.ts b/src/daemon/config.ts index 78cda47..b609ea2 100644 --- a/src/daemon/config.ts +++ b/src/daemon/config.ts @@ -14,8 +14,10 @@ export const loadDaemonConfig = Effect.fn("NotesDaemon.loadConfig")(function* ( const content = yield* Effect.promise(() => readFile(expandHomePath(filePath), "utf8"), ); + const value = yield* Effect.try(() => parse(content)); const decoded = yield* Schema.decodeUnknownEffect(DaemonConfig)(value); + return { ...decoded, opencodeCommand: expandHomePath(decoded.opencodeCommand ?? "opencode2"), diff --git a/src/daemon/coordinator.ts b/src/daemon/coordinator.ts index 054442a..f665422 100644 --- a/src/daemon/coordinator.ts +++ b/src/daemon/coordinator.ts @@ -14,6 +14,7 @@ import { } from "./services/OpenCodeClient.js"; const MAX_RESULT_LENGTH = 20_000; + const MAX_PUBLIC_ERROR_LENGTH = 1_000; /** Failure raised when daemon processing loses ownership or returns invalid output. */ @@ -36,6 +37,7 @@ function sanitizePublicErrorText(value: string, redactPaths = false): string { /\b(password|passwd|token|secret|api[-_]?key)\s*[:=]\s*[^\s,;]+/gi, "$1: [redacted]", ); + if (!redactPaths) return sanitized.trim(); return sanitized @@ -51,6 +53,7 @@ type PublicError = function publicErrorSummary(error: PublicError): string { let operation: string | undefined; let message: string; + if ( error instanceof OpenCodeClientError || error instanceof IssueQueueError @@ -66,6 +69,7 @@ function publicErrorSummary(error: PublicError): string { const summary = operation ? `**Error:** \`${operation}\`: ${message}` : `**Error:** ${message}`; + return summary.slice(0, MAX_PUBLIC_ERROR_LENGTH); } @@ -86,7 +90,9 @@ const requireOwnership = Effect.fn("NotesDaemon.requireOwnership")(function* ( claimLabel: string, ) { const queue = yield* IssueQueue; + if (yield* queue.owns(issueNumber, claimLabel)) return; + return yield* new DaemonProcessingError({ issueNumber, message: "Issue claim ownership was lost", @@ -97,6 +103,7 @@ const currentQueuedIssue = Effect.fn("NotesDaemon.currentQueuedIssue")( function* (issueNumber: number, queueLabel: string) { const queue = yield* IssueQueue; const issue = yield* queue.get(issueNumber); + return issue.state === "open" && issue.labels.includes(queueLabel) ? issue : null; @@ -113,15 +120,18 @@ const processClaimedIssue = Effect.fn("NotesDaemon.processClaimedIssue")( const queue = yield* IssueQueue; const opencode = yield* OpenCodeClient; const current = yield* currentQueuedIssue(issue.number, queueLabel); + if (!current) return false; if (issueIsComplete(current, workerActor)) { yield* requireOwnership(issue.number, claimLabel); yield* queue.complete(issue.number); + return true; } const result = (yield* opencode.process(issuePrompt(current.body))).trim(); + if (!result || result.length > MAX_RESULT_LENGTH) { return yield* new DaemonProcessingError({ issueNumber: issue.number, @@ -130,14 +140,17 @@ const processClaimedIssue = Effect.fn("NotesDaemon.processClaimedIssue")( } yield* requireOwnership(issue.number, claimLabel); + if (!(yield* currentQueuedIssue(issue.number, queueLabel))) return false; yield* queue.comment(issue.number, `${COMPLETION_MARKER}\n\n${result}`); yield* requireOwnership(issue.number, claimLabel); const beforeClose = yield* currentQueuedIssue(issue.number, queueLabel); + if (!beforeClose || !issueIsComplete(beforeClose, workerActor)) return false; yield* queue.complete(issue.number); + return true; }, ); @@ -149,9 +162,11 @@ const processIssue = Effect.fn("NotesDaemon.processIssue")(function* ( ) { const queue = yield* IssueQueue; const claimLabel = yield* queue.claim(issue.number); + if (!claimLabel) return "skipped" as const; const releaseFailed = yield* Ref.make(false); + const outcome = yield* Effect.acquireUseRelease( Effect.succeed(claimLabel), () => @@ -165,10 +180,12 @@ const processIssue = Effect.fn("NotesDaemon.processIssue")(function* ( `[notes-daemon] issue=${issue.number} processing failed`, error, ); + const [, current] = yield* Effect.all([ requireOwnership(issue.number, claimLabel), currentQueuedIssue(issue.number, queueLabel), ]); + if ( current && !issueIsComplete(current, workerActor) && @@ -179,6 +196,7 @@ const processIssue = Effect.fn("NotesDaemon.processIssue")(function* ( `${FAILURE_MARKER}\n\nProcessing failed and the issue was left open.\n\n${publicErrorSummary(error)}`, ); } + return "failed" as const; }).pipe(Effect.orElseSucceed(() => "failed" as const)), ), @@ -201,12 +219,14 @@ const processIssue = Effect.fn("NotesDaemon.processIssue")(function* ( ), ), ); + if (yield* Ref.get(releaseFailed)) { return yield* new DaemonProcessingError({ issueNumber: issue.number, message: `Failed to release issue claim ${claimLabel}`, }); } + return outcome; }); @@ -215,6 +235,7 @@ export const runProcessingPass = Effect.fn("NotesDaemon.runProcessingPass")( function* (queueLabel: string, workerActor: string) { const queue = yield* IssueQueue; const issues = yield* queue.list(); + const outcomes = yield* Effect.forEach( issues, (issue) => processIssue(issue, queueLabel, workerActor), diff --git a/src/daemon/run.ts b/src/daemon/run.ts index b5fa631..c82ebb8 100644 --- a/src/daemon/run.ts +++ b/src/daemon/run.ts @@ -12,10 +12,12 @@ export const runDaemon = Effect.fn("NotesDaemon.run")(function* ( once: boolean, ) { const config = yield* loadDaemonConfig(configPath); + const layers = Layer.mergeAll( IssueQueue.layer(config), OpenCodeClient.layer(config), ).pipe(Layer.provide(ghLayer()), Layer.provide(NodeServices.layer)); + const pass = runProcessingPass(config.queueLabel, config.workerActor).pipe( Effect.timeout(`${config.passTimeoutSeconds} seconds`), Effect.tap((result) => @@ -30,19 +32,23 @@ export const runDaemon = Effect.fn("NotesDaemon.run")(function* ( if (once) return yield* pass; const consecutiveFailures = yield* Ref.make(0); + const supervisedPass = pass.pipe( Effect.tap(() => Ref.set(consecutiveFailures, 0)), Effect.catch((error) => Effect.gen(function* () { console.error("[notes-daemon] pass failed", error); + const failures = yield* Ref.updateAndGet( consecutiveFailures, (count) => count + 1, ); + if (failures >= config.consecutiveFailureLimit) return yield* error; }), ), ); + return yield* supervisedPass.pipe( Effect.repeat( Schedule.spaced(`${config.pollIntervalSeconds} seconds`).pipe( diff --git a/src/daemon/services/IssueQueue.ts b/src/daemon/services/IssueQueue.ts index 43ac4f8..bcd3680 100644 --- a/src/daemon/services/IssueQueue.ts +++ b/src/daemon/services/IssueQueue.ts @@ -1,4 +1,4 @@ -import { Context, Effect, Layer, Schema } from "effect"; +import { Context, Effect, Layer, Predicate, Schema } from "effect"; import { Gh } from "@timmo001/effect-gh"; import { QueueIssue, type DaemonConfig } from "../schema.js"; @@ -47,10 +47,13 @@ export class IssueQueue extends Context.Service< IssueQueue, Effect.gen(function* () { const gh = yield* Gh; + const options = { timeout: `${config.commandTimeoutSeconds} seconds` as const, }; + const run = (args: readonly string[]) => gh.execute(args, options); + const json = Effect.fn("IssueQueue.ghJson")(function* ( operation: string, args: readonly string[], @@ -60,14 +63,14 @@ export class IssueQueue extends Context.Service< (error) => new IssueQueueError({ operation, - message: - error._tag === "GhDecodeError" - ? String(error.cause) - : String(error), + message: Predicate.isTagged(error, "GhDecodeError") + ? String(error.cause) + : String(error), }), ), ); }); + const get = Effect.fn("IssueQueue.get")(function* (number: number) { return yield* json("get", [ "issue", @@ -79,7 +82,9 @@ export class IssueQueue extends Context.Service< "number,title,body,state,labels,comments", ]).pipe(Effect.flatMap(decodeIssue)); }); + const claimLabel = `agent:processing:${config.workerId}:${crypto.randomUUID().slice(0, 8)}`; + const processingLabels = (issue: QueueIssue) => issue.labels.filter((label) => label.startsWith("agent:processing:")); @@ -138,6 +143,7 @@ export class IssueQueue extends Context.Service< claimLabel, ]); const labels = processingLabels(yield* get(number)); + if (labels.length === 1 && labels[0] === claimLabel) return claimLabel; yield* run([ @@ -148,6 +154,7 @@ export class IssueQueue extends Context.Service< config.repository, "--yes", ]); + return null; }).pipe( Effect.mapError((error) => @@ -163,6 +170,7 @@ export class IssueQueue extends Context.Service< get(number).pipe( Effect.map((issue) => { const labels = processingLabels(issue); + return labels.length === 1 && labels[0] === label; }), ), diff --git a/src/daemon/services/OpenCodeClient.ts b/src/daemon/services/OpenCodeClient.ts index e9dbb8b..5078e5f 100644 --- a/src/daemon/services/OpenCodeClient.ts +++ b/src/daemon/services/OpenCodeClient.ts @@ -1,10 +1,20 @@ -import { Context, Effect, Layer, Schema, Stream } from "effect"; +import { + Context, + Effect, + Layer, + Predicate, + Result, + Schema, + Stream, +} from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { resolve } from "node:path"; import type { DaemonConfig, OpenCodeModel } from "../schema.js"; const STATUS_PREFIX = /^STATUS: (success|failure)(?=\s|$)/; + const MAX_OUTPUT_BYTES = 10 * 1024 * 1024; + const MAX_RESULT_LENGTH = 20_000; /** Failure returned by the local OpenCode command boundary. */ @@ -35,6 +45,7 @@ export class OpenCodeClient extends Context.Service< Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const command = config.opencodeCommand ?? "opencode2"; + return OpenCodeClient.of({ status: Effect.try({ try: () => { @@ -64,6 +75,7 @@ const processWithFallback = Effect.fn("OpenCodeClient.processWithFallback")( prompt: string, ) { let lastError: OpenCodeClientError | undefined; + for (const [index, model] of config.opencodeModels.entries()) { const result = yield* processWithModel( config, @@ -71,15 +83,18 @@ const processWithFallback = Effect.fn("OpenCodeClient.processWithFallback")( prompt, model, ).pipe(Effect.result); - if (result._tag === "Success") { + + if (Result.isSuccess(result)) { const response = result.success.trim(); const status = STATUS_PREFIX.exec(response); + const summary = status ? response .slice(status[0].length) .trim() .replace(/^(?:-|:|\u2014)\s*/, "") : ""; + if (status?.[1] === "success" && summary) return summary; lastError = new OpenCodeClientError({ operation: "message.status", @@ -91,12 +106,14 @@ const processWithFallback = Effect.fn("OpenCodeClient.processWithFallback")( } else { lastError = result.failure; } + if (index < config.opencodeModels.length - 1) { console.warn( `[notes-daemon] model failed model=${modelName(model)} operation=${lastError.operation} message=${lastError.message}; trying fallback`, ); } } + return yield* new OpenCodeClientError({ operation: "process.models", message: `All models failed (${config.opencodeModels.map(modelName).join(", ")}): ${lastError?.message ?? "unknown error"}`, @@ -142,12 +159,15 @@ const processWithModel = Effect.fn("OpenCodeClient.processWithModel")( }, ), ); + let bytes = 0; let messageId = ""; let text = ""; + const output = child.stdout.pipe( Stream.mapEffect((chunk) => { bytes += chunk.byteLength; + return bytes <= MAX_OUTPUT_BYTES ? Effect.succeed(chunk) : Effect.fail( @@ -179,6 +199,7 @@ const processWithModel = Effect.fn("OpenCodeClient.processWithModel")( }), ), ); + if (event.type === "error") { const error = yield* Schema.decodeUnknownEffect( Schema.Struct({ message: Schema.String }), @@ -191,12 +212,15 @@ const processWithModel = Effect.fn("OpenCodeClient.processWithModel")( }), ), ); + return yield* new OpenCodeClientError({ operation: "command.run", message: error.message.slice(0, 500), }); } + if (event.type !== "text" && event.type !== "step_start") return; + const part = yield* Schema.decodeUnknownEffect( Schema.Struct({ messageID: Schema.NonEmptyString, @@ -211,12 +235,15 @@ const processWithModel = Effect.fn("OpenCodeClient.processWithModel")( }), ), ); + // OpenCode message IDs are ascending. Reconciliation may emit older text later. if (part.messageID < messageId) return; + if (part.messageID !== messageId) { messageId = part.messageID; text = ""; } + if (event.type === "text") { if (part.text === undefined) return yield* new OpenCodeClientError({ @@ -224,6 +251,7 @@ const processWithModel = Effect.fn("OpenCodeClient.processWithModel")( message: "OpenCode text event has no text", }); text += part.text; + if (text.length > MAX_RESULT_LENGTH) return yield* new OpenCodeClientError({ operation: "command.output", @@ -233,19 +261,23 @@ const processWithModel = Effect.fn("OpenCodeClient.processWithModel")( }), ), ); + const [exitCode] = yield* Effect.all([child.exitCode, output], { concurrency: "unbounded", }); + if (exitCode !== 0) return yield* new OpenCodeClientError({ operation: "command.exit", message: `OpenCode exited with code ${exitCode}`, }); + if (!text.trim()) return yield* new OpenCodeClientError({ operation: "message.decode", message: "OpenCode returned no assistant text", }); + return text; }, (effect, config) => @@ -257,10 +289,9 @@ const processWithModel = Effect.fn("OpenCodeClient.processWithModel")( ? error : new OpenCodeClientError({ operation: "command.run", - message: - error._tag === "TimeoutError" - ? "OpenCode session timed out" - : "OpenCode command could not complete", + message: Predicate.isTagged(error, "TimeoutError") + ? "OpenCode session timed out" + : "OpenCode command could not complete", }), ), ), diff --git a/src/git/committer.ts b/src/git/committer.ts index 4e1ee45..97a06c6 100644 --- a/src/git/committer.ts +++ b/src/git/committer.ts @@ -64,7 +64,9 @@ export function ensureRepo( "rev-parse", "--is-inside-work-tree", ]); + if (inside === "true") return { ok: true, text: "" }; + return yield* runStep(cwd, ["init"]); }); } @@ -82,8 +84,10 @@ export function stageIn( if (spec.mode === "paths" && spec.paths.length === 0) { return Effect.succeed({ ok: true, text: "" }); } + const args = spec.mode === "all" ? ["add", "-A"] : ["add", "--", ...spec.paths]; + return runStep(opts?.cwd, args); } @@ -93,12 +97,14 @@ export function unstageIn( opts?: { readonly cwd?: string; readonly io?: GitIo }, ): Effect.Effect { if (paths.length === 0) return Effect.succeed({ ok: true, text: "" }); + return Effect.gen(function* () { const hasHead = (yield* gitExitCode( ["rev-parse", "--verify", "HEAD"], opts?.cwd ? { cwd: opts.cwd } : undefined, )) === 0; + return yield* runStep( opts?.cwd, hasHead @@ -133,16 +139,20 @@ export function commitIn( return Effect.gen(function* () { if (step.tolerateEmpty) { const staged = yield* hasStagedChanges(step.cwd, step.paths); + if (!staged) return { ok: true, committed: false, text: "nothing to commit" }; } + const args = [ "commit", ...(step.message !== undefined ? ["-m", step.message] : []), ...(step.noVerify ? ["--no-verify"] : []), ...(step.paths?.length ? ["--only", "--", ...step.paths] : []), ]; + const result = yield* runStep(step.cwd, args); + return result.ok ? { ok: true, committed: true, text: result.text } : { ok: false, committed: false, text: result.text, error: result.error }; @@ -161,11 +171,13 @@ export function preflightMutation( cwd?: string, ): Effect.Effect { const opts = cwd ? { cwd } : undefined; + return Effect.gen(function* () { const stagedCode = yield* gitExitCode( ["diff", "--cached", "--quiet"], opts, ); + if (stagedCode > 1) { return { ok: false, @@ -173,6 +185,7 @@ export function preflightMutation( error: `Unable to inspect staged changes (git exited ${stagedCode}).`, }; } + if (stagedCode === 1) { return { ok: false, @@ -194,6 +207,7 @@ export function preflightMutation( "--git-path", marker, ]); + if ( markerPath && existsSync( @@ -217,6 +231,7 @@ export function preflightMutation( "--short", "HEAD", ]); + if (!branch.ok) { return { ok: false, @@ -224,12 +239,14 @@ export function preflightMutation( error: "The notes repository is in detached HEAD state.", }; } + const upstream = yield* readGitIn(cwd, [ "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}", ]); + if (!upstream) return { ok: true, text: "" }; const pulled = yield* runStep(cwd, [ @@ -238,8 +255,10 @@ export function preflightMutation( "--no-autostash", "--no-edit", ]); + if (pulled.ok) return pulled; yield* gitExitCode(["rebase", "--abort"], opts); + return { ok: false, text: "", @@ -258,8 +277,10 @@ export function pushBranch( } = {}, ): Effect.Effect { const { cwd } = options; + return Effect.gen(function* () { const branch = yield* readGitIn(cwd, ["branch", "--show-current"]); + if (!branch) { return { ok: false, @@ -267,20 +288,25 @@ export function pushBranch( error: "Cannot push from a detached HEAD.", }; } + const upstream = yield* readGitIn(cwd, [ "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}", ]); + if (upstream) { const pushed = yield* runStep(cwd, ["push"]); + return pushed.ok ? { ok: true, message: `Pushed to ${upstream}` } : { ok: false, message: "", error: pushed.error }; } + const { remote } = resolveDefaultRemote(yield* readGitIn(cwd, ["remote"])); const pushed = yield* runStep(cwd, ["push", "-u", remote, branch]); + return pushed.ok ? { ok: true, message: `Pushed to ${remote}/${branch} (new upstream)` } : { ok: false, message: "", error: pushed.error }; diff --git a/src/git/remotes.ts b/src/git/remotes.ts index 69f8727..d56d65c 100644 --- a/src/git/remotes.ts +++ b/src/git/remotes.ts @@ -22,6 +22,7 @@ export function parseRepositoryRemoteUrl( ): { readonly owner: string; readonly repo: string } | null { let path: string; const scpMatch = remoteUrl.match(/^[^@\s]+@[^:\s]+:(.+)$/); + if (scpMatch?.[1]) { path = scpMatch[1]; } else { @@ -33,15 +34,19 @@ export function parseRepositoryRemoteUrl( } let decoded: string; + try { decoded = decodeURIComponent(path); } catch { return null; } + const parts = decoded.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) return null; const owner = parts[0]; const repo = parts[1].replace(/\.git$/, ""); + return isSafeRepositorySegment(owner) && isSafeRepositorySegment(repo) ? { owner, repo } : null; @@ -53,18 +58,22 @@ export function resolveDefaultRemote(remotesOutput: string): ResolvedRemote { .split("\n") .map((line) => line.trim()) .filter(Boolean); + const remote = remotes.includes("upstream") ? "upstream" : remotes.includes("origin") ? "origin" : remotes[0] || "origin"; + return { remote, remotes }; } /** Parse `git symbolic-ref refs/remotes//HEAD` output. */ export function parseDefaultBranch(ref: string, remote: string): string { const prefix = `refs/remotes/${remote}/`; + if (ref.startsWith(prefix)) return ref.slice(prefix.length); const parts = ref.split("/"); + return parts[parts.length - 1] || "main"; } diff --git a/src/index.ts b/src/index.ts index 1ef59a3..15d2ad3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,6 +40,7 @@ class UsageError extends Schema.TaggedError()("UsageError", { function invokedCommand(): string | undefined { const name = basename(process.argv[1] ?? ""); + return name === "handoffs" || name === "handoff" ? "handoffs" : undefined; } @@ -83,8 +84,10 @@ function emitNoteResult( if (json) { return writeLine(JSON.stringify(result)); } + return Effect.gen(function* () { yield* writeLine(result.output); + if (result.push) yield* writeLine(formatPushLine(result.push)); }); } @@ -96,6 +99,7 @@ function emitGitMutation( ): Effect.Effect { return Effect.gen(function* () { yield* writeLine(output); + if (result.commit.ok && result.commit.committed) { yield* writeLine(`Committed to git: \`${commitMessage}\``); } else if (!result.commit.ok) { @@ -103,12 +107,14 @@ function emitGitMutation( `Git commit failed (saved locally): ${result.commit.error ?? "unknown error"}`, ); } + if (result.push) yield* writeLine(formatPushLine(result.push)); }); } function hasTag(entry: NoteEntry, tag: string): boolean { const wanted = tag.toLowerCase(); + return entry.tags.some((current) => current.toLowerCase() === wanted); } @@ -124,12 +130,12 @@ function filterSections( tag: string | undefined, ): readonly NoteRepoSection[] { if (!tag) return sections; - return sections - .map((section) => ({ - ...section, - entries: section.entries.filter((entry) => hasTag(entry, tag)), - })) - .filter((section) => section.entries.length > 0); + + return sections.flatMap((section) => { + const entries = section.entries.filter((entry) => hasTag(entry, tag)); + + return entries.length > 0 ? [{ ...section, entries }] : []; + }); } function formatHandoffLabel(entry: NoteEntry): string { @@ -140,6 +146,7 @@ function sortHandoffs(entries: readonly NoteEntry[]): readonly NoteEntry[] { return [...entries].sort((a, b) => { const rankDelta = priorityRank(notePriority(a)) - priorityRank(notePriority(b)); + return rankDelta !== 0 ? rankDelta : b.mtime - a.mtime; }); } @@ -160,10 +167,12 @@ function includeAllRepos(filter: NotesViewFilter): NotesViewFilter { function guardInteractiveTui(mode: TuiMode): void { if (process.stdout.isTTY) return; const filter = mode.initialNotesFilter; + const alternative = filter?.tag === "handoff" ? `notes handoffs --list${filter.includeAllRepos ? " --all" : ""}` : `notes list${filter?.includeAllRepos ? " --all" : ""}`; + console.error( "notes: not opening the interactive TUI (stdout is not an interactive terminal).", ); @@ -202,11 +211,14 @@ function runContext({ return handleNotesError( Effect.gen(function* () { const notes = yield* Notes; + if (json) { const payload = yield* notes.contextPayload({ command }); yield* writeLine(JSON.stringify(payload, null, 2)); + return; } + yield* writeLine(yield* notes.context({ command })); }), ); @@ -224,21 +236,27 @@ function runList({ return handleNotesError( Effect.gen(function* () { const notes = yield* Notes; + if (all) { const sections = filterSections(yield* notes.listAll(), tag); + const output = format === "json" ? JSON.stringify(sections, null, 2) : formatNoteSections(sections); + yield* writeLine(output); + return; } const entries = filterEntries(yield* notes.list(), tag); + const output = format === "json" ? JSON.stringify(entries, null, 2) : entries.map(formatNoteLabel).join("\n"); + yield* writeLine(output); }), ); @@ -258,9 +276,11 @@ function runSearch({ return handleNotesError( Effect.gen(function* () { const notes = yield* Notes; + const entries = all ? (yield* notes.listAll()).flatMap((section) => section.entries) : yield* notes.list(); + const results = searchNoteEntries(filterEntries(entries, tag), query); yield* writeLine( format === "json" @@ -358,6 +378,7 @@ function runCreate({ return handleNotesError( Effect.gen(function* () { const notes = yield* Notes; + const result = yield* notes.createFromInput( repository, kind, @@ -365,6 +386,7 @@ function runCreate({ description, yield* Effect.promise(() => Bun.stdin.text()), ); + if (json) { yield* writeLine(JSON.stringify(result)); } else { @@ -402,6 +424,7 @@ function runAgents({ format }: { readonly format: NotesListFormat }) { }), ), ); + yield* writeLine( format === "json" ? JSON.stringify(agents, null, 2) @@ -422,6 +445,7 @@ function runPriority({ return handleNotesError( Effect.gen(function* () { const result = yield* (yield* Notes).setPriority(path, value); + if (json) { yield* writeLine(JSON.stringify({ path, priority: value, ...result })); } else { @@ -447,6 +471,7 @@ function runOpenAgent({ return handleNotesError( Effect.gen(function* () { const notes = yield* Notes; + const target = (yield* detectAgentTargets().pipe( Effect.mapError( (error) => @@ -455,11 +480,13 @@ function runOpenAgent({ }), ), )).find((candidate) => candidate.command === agent); + if (!target) return yield* new NotesError({ message: `Agent target is not installed: ${agent}`, }); const note = yield* notes.resolveEntry(path); + const result = yield* openNoteAgent(note.entry, note.content, target, { mode, }).pipe( @@ -470,6 +497,7 @@ function runOpenAgent({ }), ), ); + yield* writeLine(JSON.stringify(result)); }).pipe( Effect.provide(herdrSdkLayer), @@ -500,23 +528,31 @@ function runHandoffs({ }), ); } + return handleNotesError( Effect.gen(function* () { const notes = yield* Notes; + if (all) { const sections = filterSections(yield* notes.listAll(), "handoff"); + const output = format === "json" ? JSON.stringify(sections, null, 2) : formatHandoffSections(sections); + yield* writeLine(output || "No handoff notes found."); + return; } + const entries = sortHandoffs((yield* notes.list()).filter(isHandoff)); + const output = format === "json" ? JSON.stringify(entries, null, 2) : entries.map(formatHandoffLabel).join("\n"); + yield* writeLine(output || "No handoff notes found."); }), ); @@ -527,6 +563,7 @@ async function runTui(mode: TuiMode): Promise { const { extractNativeLibIfNeeded } = await import("./lib/extractNativeLib.js"); + const nativeLibPath = await extractNativeLibIfNeeded(); const { Renderer } = await import("./services/Renderer.js"); const { loadTheme } = await import("./theme.js"); @@ -534,6 +571,7 @@ async function runTui(mode: TuiMode): Promise { const { openNoteInEditor } = await import("./notes/tui/NoteEditor.js"); const theme = Effect.runSync(loadTheme); + const TuiLayers = Renderer.layer(theme, nativeLibPath).pipe( Layer.provideMerge(Notes.layer), Layer.provideMerge(CommandExecutor.layer), @@ -592,6 +630,7 @@ async function runTui(mode: TuiMode): Promise { ); renderer.start(); + return yield* Effect.callback((resume) => { renderer.once("destroy", () => resume(Effect.void)); }); @@ -604,12 +643,15 @@ async function runTui(mode: TuiMode): Promise { const describedFlag = (flag: Flag.Flag, description: string) => flag.pipe(Flag.withDescription(description)); + const optionalString = (name: string, description: string) => describedFlag(Flag.string(name), description).pipe( Flag.withDefault(undefined), ); + const booleanFlag = (name: string, description: string) => describedFlag(Flag.boolean(name), description).pipe(Flag.withDefault(false)); + const requiredBooleanFlag = (name: string, description: string) => booleanFlag(name, description).pipe( Flag.mapEffect((enabled) => @@ -618,18 +660,22 @@ const requiredBooleanFlag = (name: string, description: string) => : new CliError.MissingOption({ option: name }), ), ); + const pathFlag = () => describedFlag( Flag.path("path"), "Absolute path to a note file inside the notes vault", ); + const formatFlag = (required = false) => { const flag = describedFlag( Flag.choice("format", ["labels", "json"] as const), "Output format", ); + return required ? flag : flag.pipe(Flag.withDefault("labels" as const)); }; + const examples = (...commands: readonly string[]) => Command.withExamples(commands.map((command) => ({ command }))); @@ -707,6 +753,7 @@ const expectedHashFlag = describedFlag( if (!/^[0-9a-f]{64}$/.test(value)) { throw new Error("must be a lowercase SHA-256 hash"); } + return value; }, () => "Expected a lowercase SHA-256 hash", @@ -885,19 +932,24 @@ const captureCommand = Command.make( message: "notes capture requires exactly one of --status or --stdin", }); } + if (status && repository !== undefined) { return yield* new UsageError({ message: "notes capture --status does not accept --repository", }); } + if (status) { const result = yield* captureStatus(config); yield* writeLine( json ? JSON.stringify(result) : "Local capture is available", ); + return; } + const text = yield* Effect.promise(() => Bun.stdin.text()); + const result = yield* processLocalCapture(config, { version: 1, requestId: crypto.randomUUID(), @@ -906,6 +958,7 @@ const captureCommand = Command.make( source: "text", repository, }); + yield* writeLine(json ? JSON.stringify(result) : result.summary); }), ).pipe( @@ -953,14 +1006,17 @@ const CliLayers = Notes.layer.pipe( Layer.provideMerge(CommandExecutor.layer), Layer.provideMerge(Config.layer), ); + export const MainLayer = Layer.merge(CliLayers, NodeServices.layer); setHelpRenderer((commandName) => { const lines: string[] = []; + const output: Console.Console = Object.assign(Object.create(console), { log: (...values: readonly unknown[]) => lines.push(values.join(" ")), error: (...values: readonly unknown[]) => lines.push(values.join(" ")), }); + return runCli(commandName ? [commandName, "--help"] : ["--help"]).pipe( Effect.provideService(Console.Console, output), Effect.provide(MainLayer), @@ -971,8 +1027,10 @@ setHelpRenderer((commandName) => { if (import.meta.main) { const initialCommand = invokedCommand(); + const cliArgs = initialCommand ? [initialCommand, ...process.argv.slice(2)] : process.argv.slice(2); + NodeRuntime.runMain(runCli(cliArgs).pipe(Effect.provide(MainLayer))); } diff --git a/src/lib/env.ts b/src/lib/env.ts index 3df6910..46a9663 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -20,7 +20,9 @@ export function envString(name: string): string | undefined { /** Read a non-negative integer from the environment, falling back on invalid input. */ export function envNonNegativeInt(name: string, fallback: number): number { const value = envString(name); + if (!value) return fallback; const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; } diff --git a/src/lib/extractNativeLib.ts b/src/lib/extractNativeLib.ts index aba924f..7957923 100644 --- a/src/lib/extractNativeLib.ts +++ b/src/lib/extractNativeLib.ts @@ -38,10 +38,12 @@ export async function extractNativeLibIfNeeded(): Promise { if (!isCompiledBinary()) return undefined; let embeddedLibPath: string; + try { const nativeModule = await import( `@opentui/core-${process.platform}-${process.arch}` ); + embeddedLibPath = Schema.decodeUnknownSync(Schema.String)( nativeModule.default, ); @@ -75,6 +77,7 @@ export async function extractNativeLibIfNeeded(): Promise { mkdirSync(dir, { recursive: true }); const tmpPath = `${destPath}.tmp-${process.pid}-${Date.now()}`; + try { writeFileSync(tmpPath, readFileSync(embeddedLibPath), { mode: 0o755 }); renameSync(tmpPath, destPath); @@ -84,6 +87,7 @@ export async function extractNativeLibIfNeeded(): Promise { } catch { // Best-effort cleanup. } + throw error; } diff --git a/src/lib/git.ts b/src/lib/git.ts index 4b78191..47d4ddd 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -24,6 +24,7 @@ function commandFailureMessage( error: CommandError, ): string { const stderr = error.stderr ? `: ${error.stderr}` : ""; + return `${[command, ...args].join(" ")} failed with exit ${error.exitCode}${stderr}`; } @@ -34,6 +35,7 @@ export function gitOutput( ): Effect.Effect { return Effect.gen(function* () { const executor = yield* CommandExecutor; + return yield* executor.run("git", args, opts).pipe( Effect.catchTag("CommandError", (error) => Effect.fail( @@ -53,6 +55,7 @@ export function gitExitCode( ): Effect.Effect { return Effect.gen(function* () { const executor = yield* CommandExecutor; + return yield* executor.exitCode("git", args, opts); }); } diff --git a/src/mcp/resources/notes.ts b/src/mcp/resources/notes.ts index 8ab3191..acee6c4 100644 --- a/src/mcp/resources/notes.ts +++ b/src/mcp/resources/notes.ts @@ -4,6 +4,7 @@ import { renderHelp } from "../../cli/help.js"; import { Notes } from "../../notes/services/Notes.js"; const commandParam = McpSchema.param("name", Schema.String); + const CONTEXT_COMMAND = "notes-list"; /** Register notes read-only resources on the current MCP server. */ @@ -16,6 +17,7 @@ export const registerNotesResources = Effect.gen(function* () { mimeType: "text/markdown", content: Effect.gen(function* () { const notes = yield* Notes; + return yield* notes.context({ command: CONTEXT_COMMAND }); }), }); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 8a5f145..6931834 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -6,6 +6,7 @@ import { registerNotesResources } from "./resources/notes.js"; import { registerNotesTools } from "./tools/notes.js"; const SERVER_NAME = "notes"; + const SERVER_VERSION = "0.1.0"; const registerAll = Effect.gen(function* () { diff --git a/src/mcp/services/Notifier.ts b/src/mcp/services/Notifier.ts index 60881d4..e143f72 100644 --- a/src/mcp/services/Notifier.ts +++ b/src/mcp/services/Notifier.ts @@ -16,6 +16,7 @@ export class Notifier extends Context.Service()( Notifier, Effect.gen(function* () { const executor = yield* CommandExecutor; + return { notify: (title, message) => executor.exitCode("notify-send", [title, message]).pipe( diff --git a/src/mcp/tools/notes.ts b/src/mcp/tools/notes.ts index 420be2a..357633d 100644 --- a/src/mcp/tools/notes.ts +++ b/src/mcp/tools/notes.ts @@ -70,6 +70,7 @@ const NoteDeleteParams = Schema.Struct({ function hasTag(entry: NoteEntry, tag: string): boolean { const wanted = tag.toLowerCase(); + return entry.tags.some((current) => current.toLowerCase() === wanted); } @@ -84,26 +85,28 @@ function filterSectionsByTag( sections: readonly NoteRepoSection[], tag: string, ): readonly NoteRepoSection[] { - return sections - .map((section) => ({ - ...section, - entries: section.entries.filter((entry) => hasTag(entry, tag)), - })) - .filter((section) => section.entries.length > 0); + return sections.flatMap((section) => { + const entries = section.entries.filter((entry) => hasTag(entry, tag)); + + return entries.length > 0 ? [{ ...section, entries }] : []; + }); } function formatMutationOutput( result: NoteWriteResult | NoteDeleteResult, ): string { const outcome = noteGitOutcome(result); + const commit = result.commit.sha ? `\n\nCommit: \`${result.commit.sha}\`` : ""; + const output = outcome.complete ? result.push ? `${result.output}\n\nPushed: ${result.push.message}` : result.output : `${result.output}\n\nPartial success: ${outcome.detail}`; + return `${output}${commit}`; } @@ -114,6 +117,7 @@ function notifyMutation( ): Effect.Effect { const name = result.path.split("/").pop() || result.path; const detail = noteGitOutcome(result).detail; + return notifier.notify(`notes: ${action}`, `${name} - ${detail}`); } @@ -149,15 +153,20 @@ export const registerNotesTools = Effect.gen(function* () { Effect.gen(function* () { if (params.all) { const sections = yield* notes.listAll(); + const filtered = params.tag ? filterSectionsByTag(sections, params.tag) : sections; + return JSON.stringify(filtered, null, 2); } + const entries = yield* notes.list(); + const filtered = params.tag ? filterEntriesByTag(entries, params.tag) : entries; + return JSON.stringify(filtered, null, 2); }), }); @@ -176,7 +185,9 @@ export const registerNotesTools = Effect.gen(function* () { const result = yield* notes.write(params.path, params.content, { expectedHash: params.expectedHash, }); + yield* notifyMutation(notifier, "written", result); + return formatMutationOutput(result); }), }); @@ -193,6 +204,7 @@ export const registerNotesTools = Effect.gen(function* () { Effect.gen(function* () { const result = yield* notes.delete(params.path); yield* notifyMutation(notifier, "deleted", result); + return formatMutationOutput(result); }), }); diff --git a/src/mcp/tools/register.ts b/src/mcp/tools/register.ts index c6b585a..8d3ed31 100644 --- a/src/mcp/tools/register.ts +++ b/src/mcp/tools/register.ts @@ -51,8 +51,10 @@ export const toolRegistrar: Effect.Effect< McpServer.McpServer > = Effect.gen(function* () { const server = yield* McpServer.McpServer; + const register: ToolRegistrar = (options) => { const decode = Schema.decodeEffect(options.parameters); + return server.addTool({ tool: new McpSchema.Tool({ name: options.name, @@ -79,5 +81,6 @@ export const toolRegistrar: Effect.Effect< ), }); }; + return register; }); diff --git a/src/notes/activeCount.ts b/src/notes/activeCount.ts index cd18d18..2b9054a 100644 --- a/src/notes/activeCount.ts +++ b/src/notes/activeCount.ts @@ -7,16 +7,21 @@ import { Notes } from "./services/Notes.js"; export const activeNoteCount = Effect.fn("activeNoteCount")(function* () { const sdk = yield* HerdrSdk; const snapshot = yield* sdk.session.snapshot(); + const pane = snapshot.panes.find( (pane) => pane.id === Option.getOrNull(snapshot.focusedPaneId), ); + if (!pane) return null; + const cwd = Option.getOrNull( Option.orElse(pane.foregroundCwd, () => pane.cwd), ); + if (!cwd) return null; const config = yield* Config; + const entries = yield* Effect.gen(function* () { return yield* (yield* Notes).list(); }).pipe( @@ -27,6 +32,7 @@ export const activeNoteCount = Effect.fn("activeNoteCount")(function* () { { local: true }, ), ); + return { workspaceId: pane.workspaceId, paneId: pane.id, diff --git a/src/notes/agentTargets.ts b/src/notes/agentTargets.ts index 9f63d2d..317682d 100644 --- a/src/notes/agentTargets.ts +++ b/src/notes/agentTargets.ts @@ -33,9 +33,11 @@ class AgentOpenError extends Schema.TaggedError()( ) {} const OPENCODE2 = "/home/aidan/.local/bin/opencode2"; + const RepositoryPicker = Schema.Array( Schema.Struct({ name: Schema.String, path: Schema.String }), ); + const TARGETS: readonly AgentTarget[] = [ { command: "opencode2", executable: OPENCODE2, label: "OpenCode 2" }, { command: "opencode", executable: "opencode", label: "OpenCode 1" }, @@ -67,11 +69,13 @@ export const detectAgentTargets = Effect.fn("detectAgentTargets")(function* ( ) { const sdk = yield* HerdrSdk; const integrations = yield* sdk.integrations.list(); + const installed = new Set( integrations .filter(({ state }) => state === "current" || state === "outdated") .map(({ target }) => target), ); + return TARGETS.filter((target) => target.command === "opencode2" ? installed.has("opencode") && executableAvailable(OPENCODE2) @@ -87,8 +91,10 @@ export const openNoteAgent = Effect.fn("openNoteAgent")(function* ( options: OpenAgentOptions = {}, ) { const mode = options.mode ?? "default"; + const executableAvailable = options.executableAvailable ?? isRegularExecutable; + if ( target.command === "opencode2" && !executableAvailable(target.executable) @@ -97,14 +103,17 @@ export const openNoteAgent = Effect.fn("openNoteAgent")(function* ( message: `${target.executable} is not a regular executable file`, }); } + const cwd = entry.projectDir ?? homedir(); const workspaceLabel = yield* workspaceLabelForDirectory(cwd); const sdk = yield* HerdrSdk; const listed = yield* sdk.workspaces.list(); + let workspaceId = listed.find( (workspace) => workspace.label.toLowerCase() === workspaceLabel.toLowerCase(), )?.id; + let tabId: TabId; let paneId: PaneId; @@ -113,6 +122,7 @@ export const openNoteAgent = Effect.fn("openNoteAgent")(function* ( label: workspaceLabel, focus: false, }); + workspaceId = created.workspace.id; tabId = created.tab.id; paneId = created.rootPane.id; @@ -124,6 +134,7 @@ export const openNoteAgent = Effect.fn("openNoteAgent")(function* ( label: target.label, focus: false, }); + tabId = created.tab.id; paneId = created.rootPane.id; } @@ -135,10 +146,12 @@ export const openNoteAgent = Effect.fn("openNoteAgent")(function* ( "opencode2", ])).trim() : null; + const agentArgs = mode === "plan" && target.command === "opencode" ? [target.executable, "--agent", "plan"] : [target.executable]; + yield* sdk.panes.sendInput(paneId, { text: agentArgs.join(" "), keys: ["enter"], @@ -158,8 +171,10 @@ export const openNoteAgent = Effect.fn("openNoteAgent")(function* ( { timeoutMs: 30_000 }, { requestTimeout: Duration.seconds(35) }, ); + if (expectedOpenCode2) { const processInfo = yield* sdk.panes.processInfo(paneId); + if ( !processInfo.foregroundProcesses?.some((process) => Option.exists(process.argv, (argv) => argv.includes(expectedOpenCode2)), @@ -170,6 +185,7 @@ export const openNoteAgent = Effect.fn("openNoteAgent")(function* ( }); } } + yield* sdk.agents.prompt( { paneId }, { @@ -178,6 +194,7 @@ export const openNoteAgent = Effect.fn("openNoteAgent")(function* ( }, { requestTimeout: Duration.seconds(125) }, ); + return { note: entry.filePath, agent: target, @@ -196,12 +213,15 @@ export function workspaceLabelForDirectory( ), ) { const fallback = basename(directory); + return Effect.gen(function* () { const value = yield* Effect.try(() => JSON.parse(readFileSync(pickerCache, "utf8")), ); + const repositories = yield* Schema.decodeUnknownEffect(RepositoryPicker)(value); + return ( repositories.find((repository) => repository.path === directory)?.name ?? fallback @@ -214,6 +234,7 @@ export function isRegularExecutable(path: string): boolean { try { if (!statSync(path).isFile()) return false; accessSync(path, constants.X_OK); + return true; } catch { return false; diff --git a/src/notes/files.ts b/src/notes/files.ts index 490d015..ec2f8d9 100644 --- a/src/notes/files.ts +++ b/src/notes/files.ts @@ -54,6 +54,7 @@ function errorCode(error: ErrorValue): string | undefined { function isInsideDirectory(parent: string, child: string): boolean { const relativePath = relative(parent, child); + return ( relativePath === "" || (!relativePath.startsWith(`..${sep}`) && relativePath !== "..") @@ -62,22 +63,28 @@ function isInsideDirectory(parent: string, child: string): boolean { function notePathParts(projectsRoot: string, input: string): NotePathParts { const expanded = expandHomePath(input); + if (!isAbsolute(expanded)) throw new Error(`Note path must be absolute: ${input}`); const root = resolve(projectsRoot); const path = resolve(expanded); const relativePath = relative(root, path); + if (!isInsideDirectory(root, path)) { throw new Error(`Path is outside the repository notes directory: ${input}`); } + const parts = relativePath.split(sep); + if (parts.length !== 3) { throw new Error( `Note path must match projects///.md: ${input}`, ); } + const [owner, repo, filename] = parts; + if ( !owner || !repo || @@ -90,11 +97,13 @@ function notePathParts(projectsRoot: string, input: string): NotePathParts { ) { throw new Error(`Invalid repository note path: ${input}`); } + return { path, owner, repo, filename }; } function assertDirectory(path: string): void { const stat = lstatSync(path); + if (stat.isSymbolicLink() || !stat.isDirectory()) { throw new Error(`Note directory is not a physical directory: ${path}`); } @@ -112,8 +121,10 @@ function lstatIfPresent(path: string) { /** Create the vault root when needed and reject a symlinked root. */ export function ensurePhysicalVaultRoot(notesRoot: string): string { const path = resolve(notesRoot); + if (!lstatIfPresent(path)) mkdirSync(path, { recursive: true }); assertDirectory(path); + return path; } @@ -132,24 +143,29 @@ function ensurePhysicalParents( if (!create) throw new Error(`Note directory does not exist: ${path}`); mkdirSync(path); } + assertDirectory(path); } const physicalRoot = realpathSync(root); const parent = join(root, owner, repo); const physicalParent = realpathSync(parent); + if (!isInsideDirectory(physicalRoot, physicalParent)) { throw new Error(`Note directory resolves outside projects: ${parent}`); } + return parent; } function assertRegularTarget(path: string, allowMissing: boolean): void { const stat = lstatIfPresent(path); + if (!stat) { if (allowMissing) return; throw new Error(`Note file does not exist: ${path}`); } + if (stat.isSymbolicLink() || !stat.isFile()) { throw new Error(`Note path is not a physical regular file: ${path}`); } @@ -163,6 +179,7 @@ export function resolveRepositoryNotesDirectory( const root = resolve(projectsRoot); const path = resolve(input); const parts = relative(root, path).split(sep); + if ( !isInsideDirectory(root, path) || parts.length !== 2 || @@ -173,6 +190,7 @@ export function resolveRepositoryNotesDirectory( ) { throw new Error(`Invalid repository notes directory: ${input}`); } + return ensurePhysicalParents(root, parts[0], parts[1], false); } @@ -200,6 +218,7 @@ function prepareNotePath( options.createParents, ); assertRegularTarget(parts.path, options.allowMissing); + return parts.path; } @@ -236,11 +255,14 @@ export function readNoteFile( ): ReadNoteFileResult { const path = resolveExistingNotePath(projectsRoot, input); const fd = openSync(path, constants.O_RDONLY | NO_FOLLOW); + try { const stat = fstatSync(fd); + if (!stat.isFile()) throw new Error(`Note path is not a regular file: ${path}`); const content = readFileSync(fd, "utf8"); + return { path, content, @@ -261,11 +283,13 @@ function writeTemporaryFile( dirname(path), `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`, ); + const fd = openSync( temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NO_FOLLOW, mode, ); + try { writeFileSync(fd, content, "utf8"); fsyncSync(fd); @@ -274,7 +298,9 @@ function writeTemporaryFile( unlinkSync(temporary); throw error; } + closeSync(fd); + return temporary; } @@ -287,6 +313,7 @@ export function atomicWriteNoteFile( const path = resolveWritableNotePath(projectsRoot, input); const mode = existsSync(path) ? statSync(path).mode & 0o777 : 0o666; const temporary = writeTemporaryFile(path, content, mode); + try { resolveWritableNotePath(projectsRoot, path); renameSync(temporary, path); @@ -294,6 +321,7 @@ export function atomicWriteNoteFile( if (existsSync(temporary)) unlinkSync(temporary); throw error; } + return path; } @@ -308,21 +336,28 @@ export function createExclusiveNoteFile( if (!isSafeRepositorySegment(owner) || !isSafeRepositorySegment(repo)) { throw new Error(`Invalid repository identity: ${owner}/${repo}`); } + ensurePhysicalParents(projectsRoot, owner, repo, true); const directory = join(projectsRoot, owner, repo); + for (let suffix = 1; ; suffix += 1) { const filename = suffix === 1 ? `${slug}.md` : `${slug}-${suffix}.md`; + const path = resolveWritableNotePath( projectsRoot, join(directory, filename), ); + const temporary = writeTemporaryFile(path, content, 0o666); + try { linkSync(temporary, path); unlinkSync(temporary); + return path; } catch (error) { unlinkSync(temporary); + if (errorCode(error) !== "EEXIST") throw error; } } @@ -332,5 +367,6 @@ export function createExclusiveNoteFile( export function deleteNoteFile(projectsRoot: string, input: string): string { const path = resolveExistingNotePath(projectsRoot, input); unlinkSync(path); + return path; } diff --git a/src/notes/frontmatter.ts b/src/notes/frontmatter.ts index 9a29ce4..f107079 100644 --- a/src/notes/frontmatter.ts +++ b/src/notes/frontmatter.ts @@ -38,6 +38,7 @@ interface ParsedFrontmatter { function parseFrontmatter(content: string): ParsedFrontmatter { const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?=\r?\n|$)/); + if (!match) throw new Error("Note content must start with YAML frontmatter"); const document = parseDocument(match[1], { @@ -46,14 +47,17 @@ function parseFrontmatter(content: string): ParsedFrontmatter { stringKeys: true, uniqueKeys: true, }); + if (document.errors.length > 0) { throw new Error(`Invalid note frontmatter: ${document.errors[0].message}`); } + if (!isMap(document.contents)) { throw new Error("Note frontmatter must be a YAML mapping"); } let data: FrontmatterRecord; + try { data = Schema.decodeUnknownSync(Frontmatter)( document.toJS({ maxAliasCount: 0 }), @@ -63,7 +67,9 @@ function parseFrontmatter(content: string): ParsedFrontmatter { `Invalid note frontmatter: ${error instanceof Error ? error.message : String(error)}`, ); } + validateKnownFields(data); + return { document, data, @@ -86,6 +92,7 @@ function validateKnownFields(data: FrontmatterRecord): void { export function readFrontmatter(content: string): NoteFrontmatter { const { data, body } = parseFrontmatter(content); const heading = body.match(/^#\s+(.+)\s*$/m)?.[1]?.trim(); + return { name: data.name !== undefined @@ -108,6 +115,7 @@ export function setFrontmatterField( ): string { const { document, body } = parseFrontmatter(content); document.set(key, value); + return `---\n${document.toString().trimEnd()}\n---${body}`; } @@ -138,6 +146,7 @@ export function renderDraft( description: description || "Draft repository note.", tags: ["draft"], }; + const body = kind === "handoff" ? [ @@ -160,6 +169,7 @@ export function renderDraft( "", ] : [`# ${name}`, "", ""]; + return `---\n${stringify(frontmatter).trimEnd()}\n---\n\n${body.join("\n")}`; } diff --git a/src/notes/gitOutcome.ts b/src/notes/gitOutcome.ts index 8dc8be4..a085e4e 100644 --- a/src/notes/gitOutcome.ts +++ b/src/notes/gitOutcome.ts @@ -13,12 +13,14 @@ export function noteGitOutcome(result: NoteGitResult): NoteGitOutcome { detail: `saved locally but git commit failed: ${result.commit.error ?? "unknown error"}`, }; } + if (result.push && !result.push.ok) { return { complete: false, detail: `committed locally but push failed: ${result.push.error ?? "unknown error"}`, }; } + return { complete: true, detail: result.push?.message ?? "saved locally", diff --git a/src/notes/repositoryDirectories.ts b/src/notes/repositoryDirectories.ts index b45f915..be04a0b 100644 --- a/src/notes/repositoryDirectories.ts +++ b/src/notes/repositoryDirectories.ts @@ -5,6 +5,7 @@ import { Option, Schema } from "effect"; type RepositoryDirectories = Record; const FILENAME = "repository-directories.json"; + const RepositoryDirectoriesFile = Schema.Record(Schema.String, Schema.String); /** Read locally known source checkout directories by repository slug. */ diff --git a/src/notes/search.ts b/src/notes/search.ts index cd65ed6..cfb7d9d 100644 --- a/src/notes/search.ts +++ b/src/notes/search.ts @@ -18,7 +18,9 @@ export function searchNoteEntries( query: string, ): readonly NoteEntry[] { const trimmed = query.trim(); + if (!trimmed) return entries; + return new Fuse([...entries], NOTE_SEARCH_OPTIONS) .search(trimmed) .map((result) => result.item); diff --git a/src/notes/services/Notes.ts b/src/notes/services/Notes.ts index 30f0d9d..9316777 100644 --- a/src/notes/services/Notes.ts +++ b/src/notes/services/Notes.ts @@ -72,6 +72,7 @@ import { } from "../types.js"; const PROJECTS_SUBDIR = "projects"; + const COMMANDS_NEEDING_LIST = new Set([ "note-append", "notes-list", @@ -153,23 +154,32 @@ const ErrorDetails = Schema.Struct({ stderr: Schema.optional(Schema.String), message: Schema.optional(Schema.String), }); + const ErrorCode = Schema.Struct({ code: Schema.optional(Schema.String) }); function errorMessage(error: ErrorValue): string { const text = Option.getOrUndefined( Schema.decodeUnknownOption(Schema.String)(error), ); + if (text !== undefined) return text; + const nativeError = Option.getOrUndefined( Schema.decodeUnknownOption(Schema.instanceOf(Error))(error), ); + if (nativeError !== undefined) return nativeError.message; + const details = Option.getOrUndefined( Schema.decodeUnknownOption(ErrorDetails)(error), ); + if (details?.stderr?.trim()) return details.stderr.trim(); + if (details?.message?.trim()) return details.message.trim(); + if (error === null || error === undefined) return "Unknown error"; + return String(error); } @@ -196,6 +206,7 @@ function listNoteEntries( projectDir?: string, ): readonly NoteEntry[] { if (!existsSync(notesPath)) return []; + const physicalNotesPath = resolveRepositoryNotesDirectory( projectsRoot, notesPath, @@ -207,11 +218,13 @@ function listNoteEntries( .map((filename) => { const filePath = join(physicalNotesPath, filename); const stat = lstatSync(filePath); + if (stat.isSymbolicLink() || !stat.isFile()) { throw new Error( `Note path is not a physical regular file: ${filePath}`, ); } + return { filename, filePath, @@ -235,6 +248,7 @@ function listNoteRepoSections( ): readonly NoteRepoSection[] { try { const rootStat = lstatSync(projectsRoot); + if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) { throw new Error( `Projects root is not a physical directory: ${projectsRoot}`, @@ -246,12 +260,15 @@ function listNoteRepoSections( } const sections: NoteRepoSection[] = []; + for (const owner of sortedDirectories(projectsRoot)) { const ownerPath = join(projectsRoot, owner.name); + for (const repo of sortedDirectories(ownerPath)) { const repoSlug = `${owner.name}/${repo.name}`; const notesPath = join(ownerPath, repo.name); const entries = listNoteEntries(projectsRoot, notesPath, repoSlug); + if (entries.length > 0) sections.push({ repoSlug, notesPath, entries }); } } @@ -275,6 +292,7 @@ function formatTag( const body = [`Description: ${description}`, ...lines.filter(Boolean)] .join("\n") .trim(); + return [`<${name}>`, body || "(empty)", ``].join("\n"); } @@ -300,6 +318,7 @@ function payloadToContextBlock(payload: NoteContextPayload): string { } const repository = payload.repository; + const parts = [ "", formatTag("metadata", "How this context was generated.", [ @@ -328,6 +347,7 @@ function payloadToContextBlock(payload: NoteContextPayload): string { : payload.notesExist ? ["(no .md files found in notes directory)"] : ["(notes directory does not exist yet)"]; + parts.push( formatTag( "existing-notes", @@ -342,6 +362,7 @@ function payloadToContextBlock(payload: NoteContextPayload): string { "", "Description: Full content of all note files for this repository.", ]; + for (const note of payload.contents) { contentParts.push( ``, @@ -349,6 +370,7 @@ function payloadToContextBlock(payload: NoteContextPayload): string { "", ); } + contentParts.push(""); parts.push(contentParts.join("\n")); } @@ -364,14 +386,17 @@ function payloadToContextBlock(payload: NoteContextPayload): string { } parts.push(""); + return parts.join("\n\n"); } function commitOutputLine(result: NoteCommitResult, message: string): string[] { if (result.ok && result.committed) return ["", `Committed to git: \`${message}\``]; + if (!result.ok) return ["", `Git commit failed (saved locally): ${result.error}`]; + return []; } @@ -426,15 +451,19 @@ export class Notes extends Context.Service()("Notes") { const resolveIdentity = Effect.fn("Notes.resolveIdentity")(function* () { const warnings: string[] = []; + const gitRoot = yield* commandResult( "git", ["rev-parse", "--show-toplevel"], { cwd: config.projectDir }, ); + const identityRoot = gitRoot.ok ? gitRoot.text : config.projectDir; + const remotesResult = gitRoot.ok ? yield* commandResult("git", ["remote"], { cwd: config.projectDir }) : { ok: true as const, text: "" }; + const remotes = remotesResult.ok ? remotesResult.text .split(/\r?\n/g) @@ -451,14 +480,17 @@ export class Notes extends Context.Service()("Notes") { : remotes.includes("origin") ? "origin" : remotes[0]; + if (remote) { const remoteUrl = yield* commandResult( "git", ["remote", "get-url", remote], { cwd: config.projectDir }, ); + if (remoteUrl.ok) { const parsed = parseRepositoryRemoteUrl(remoteUrl.text); + if (parsed) { return { identity: { @@ -471,6 +503,7 @@ export class Notes extends Context.Service()("Notes") { warnings, }; } + warnings.push( `Could not parse owner/repo from remote URL; using local project identity: ${remoteUrl.text}`, ); @@ -486,11 +519,13 @@ export class Notes extends Context.Service()("Notes") { } const project = basename(identityRoot); + if (!isSafeRepositorySegment(project)) { return yield* fail( `Unable to derive a safe local project name from: ${identityRoot}`, ); } + return { identity: { source: "local" as const, @@ -505,6 +540,7 @@ export class Notes extends Context.Service()("Notes") { const currentNotesPath = Effect.fn("Notes.currentNotesPath")( function* () { const { identity } = yield* resolveIdentity(); + return join(projectsRoot, identity.owner, identity.repo); }, ); @@ -532,13 +568,16 @@ export class Notes extends Context.Service()("Notes") { const prepareMutation = Effect.fn("Notes.prepareMutation")(function* () { const init = yield* withExecutor(ensureRepo(notesRoot)); + if (!init.ok) { return yield* fail( "Unable to prepare the notes repository.", init.error, ); } + const preflight = yield* withExecutor(preflightMutation(notesRoot)); + if (!preflight.ok) { return yield* fail( "The notes repository is not ready for a mutation.", @@ -560,8 +599,10 @@ export class Notes extends Context.Service()("Notes") { "The notes repository gained staged changes before the note could be committed. The note was saved locally and nothing new was staged.", }; } + const relativePath = relative(notesRoot, filePath); const init = yield* withExecutor(ensureRepo(notesRoot)); + if (!init.ok) { return { ok: false as const, @@ -570,12 +611,14 @@ export class Notes extends Context.Service()("Notes") { error: `git init failed: ${init.error ?? "unknown error"}`, }; } + const staged = yield* withExecutor( stageIn( { mode: "paths", paths: [relativePath] }, { cwd: notesRoot, io: "capture" }, ), ); + if (!staged.ok) { return { ok: false as const, @@ -584,6 +627,7 @@ export class Notes extends Context.Service()("Notes") { error: `git add failed: ${staged.error ?? "unknown error"}`, }; } + const outcome = yield* withExecutor( commitIn({ cwd: notesRoot, @@ -594,12 +638,14 @@ export class Notes extends Context.Service()("Notes") { paths: [relativePath], }), ); + if (outcome.ok) { const sha = outcome.committed ? yield* commandResult("git", ["rev-parse", "HEAD"], { cwd: notesRoot, }) : undefined; + return { ok: true as const, committed: outcome.committed, @@ -607,9 +653,11 @@ export class Notes extends Context.Service()("Notes") { ...(sha?.ok && { sha: sha.text }), }; } + const restored = yield* withExecutor( unstageIn([relativePath], { cwd: notesRoot, io: "capture" }), ); + return { ok: false as const, committed: false, @@ -632,13 +680,16 @@ export class Notes extends Context.Service()("Notes") { "The notes repository gained staged changes before the note could be committed. The note was moved locally and nothing new was staged.", }; } + const paths = [ relative(notesRoot, fromPath), relative(notesRoot, toPath), ]; + const staged = yield* withExecutor( stageIn({ mode: "paths", paths }, { cwd: notesRoot, io: "capture" }), ); + if (!staged.ok) { return { ok: false as const, @@ -647,6 +698,7 @@ export class Notes extends Context.Service()("Notes") { error: `git add failed: ${staged.error ?? "unknown error"}`, }; } + const outcome = yield* withExecutor( commitIn({ cwd: notesRoot, @@ -657,10 +709,12 @@ export class Notes extends Context.Service()("Notes") { paths, }), ); + if (!outcome.ok) { const restored = yield* withExecutor( unstageIn(paths, { cwd: notesRoot, io: "capture" }), ); + return { ok: false as const, committed: false, @@ -668,11 +722,13 @@ export class Notes extends Context.Service()("Notes") { error: `git commit failed: ${outcome.error ?? "unknown error"}${restored.ok ? "" : `; index cleanup failed: ${restored.error ?? "unknown error"}`}`, }; } + const sha = outcome.committed ? yield* commandResult("git", ["rev-parse", "HEAD"], { cwd: notesRoot, }) : undefined; + return { ok: true as const, committed: outcome.committed, @@ -688,20 +744,25 @@ export class Notes extends Context.Service()("Notes") { "rev-parse", "--is-inside-work-tree", ]); + if (!isRepo.ok) return false; + const remotes = yield* commandResult("git", [ "-C", notesRoot, "remote", ]); + return remotes.ok && remotes.text.trim().length > 0; }); const pushNotes = Effect.fn("Notes.pushNotes")(function* () { if (!(yield* hasRemote())) return undefined; + const outcome = yield* withExecutor( pushBranch({ cwd: notesRoot, io: "capture" }), ); + return { ok: outcome.ok, message: outcome.message, @@ -715,6 +776,7 @@ export class Notes extends Context.Service()("Notes") { ) { const outcome = yield* commitNote(filePath, message); const push = outcome.committed ? yield* pushNotes() : undefined; + return { commit: toNoteCommitResult(outcome), push }; }); @@ -727,18 +789,21 @@ export class Notes extends Context.Service()("Notes") { try: () => readNoteFile(projectsRoot, filePath), catch: (error) => fail(errorMessage(error)), }); + const entry: NoteEntry = { filename: basename(before.path), filePath: before.path, mtime: before.mtime, ...readNoteFrontmatter(projectsRoot, before.path), }; + yield* Effect.tryPromise({ try: () => runEditor(entry), catch: (error) => fail(`Editor failed for ${filePath}: ${errorMessage(error)}`), }); const resolvedPath = resolveOptionalNotePath(projectsRoot, filePath); + if (!existsSync(resolvedPath)) { if (create) { return { @@ -749,9 +814,12 @@ export class Notes extends Context.Service()("Notes") { }, }; } + const message = `notes: delete ${basename(resolvedPath)}`; + return yield* commitAndPush(resolvedPath, message); } + yield* Effect.try({ try: () => validateNoteContent( @@ -762,6 +830,7 @@ export class Notes extends Context.Service()("Notes") { }); const filename = basename(resolvedPath); const message = `notes: ${create ? "create" : "edit"} ${filename}`; + return yield* commitAndPush(resolvedPath, message); }); @@ -775,6 +844,7 @@ export class Notes extends Context.Service()("Notes") { ) { const slug = slugifyName(name) || "note"; const now = new Date(yield* Clock.currentTimeMillis); + const draftContent = renderDraft( kind, identity, @@ -782,10 +852,12 @@ export class Notes extends Context.Service()("Notes") { name, description, ); + const content = body === undefined ? draftContent : `${draftContent.slice(0, draftContent.indexOf("\n---\n") + 5)}\n${body.replace(/(?:\r\n|\r|\n)+$/, "")}\n`; + const filePath = yield* Effect.try({ try: () => createExclusiveNoteFile( @@ -798,7 +870,9 @@ export class Notes extends Context.Service()("Notes") { catch: (error) => fail(`createDraft: failed to write draft: ${errorMessage(error)}`), }); + const note = readNoteFile(projectsRoot, filePath); + const entry: NoteEntry = { filename: basename(filePath), filePath, @@ -806,19 +880,23 @@ export class Notes extends Context.Service()("Notes") { mtime: note.mtime, ...readFrontmatter(note.content), }; + const draft = { entry, content } satisfies NoteCreateDraft; const git = yield* editAndCommit(filePath, runEditor, true); + return { draft, git, created: existsSync(filePath) }; }); const buildContextPayload = ({ command }: NoteContextOptions) => Effect.gen(function* () { const generatedAt = new Date().toISOString(); + const resolved = yield* resolveIdentity().pipe( Effect.catch((error: NotesError) => Effect.succeed({ error } as const), ), ); + if ("error" in resolved) { return { generatedAt, @@ -840,9 +918,11 @@ export class Notes extends Context.Service()("Notes") { resolved.identity.owner, resolved.identity.repo, ); + const notesExist = existsSync(notesPath); const warnings = [...resolved.warnings]; let entries: readonly NoteEntry[] = []; + if (COMMANDS_NEEDING_LIST.has(command)) { const listed = yield* Effect.try({ try: () => listNoteEntries(projectsRoot, notesPath), @@ -853,14 +933,17 @@ export class Notes extends Context.Service()("Notes") { onSuccess: (value) => ({ ok: true as const, value }), }), ); + if (listed.ok) entries = listed.value; else warnings.push(`Unable to list existing notes: ${listed.error}`); } + const contents = command === "note-reference" && entries.length > 0 ? entries.map((entry) => { let content: string; + try { content = readNoteFile( projectsRoot, @@ -869,6 +952,7 @@ export class Notes extends Context.Service()("Notes") { } catch (error) { content = `(error reading file: ${errorMessage(error)})`; } + return { filename: entry.filename, filePath: entry.filePath, @@ -903,12 +987,14 @@ export class Notes extends Context.Service()("Notes") { list: () => Effect.gen(function* () { const notesPath = yield* currentNotesPath(); + return listNoteEntries(projectsRoot, notesPath); }), listAll: () => Effect.try({ try: () => { const directories = readRepositoryDirectories(config.stateDir); + return listNoteRepoSections(projectsRoot).map((section) => ({ ...section, entries: section.entries.map((entry) => ({ @@ -937,8 +1023,10 @@ export class Notes extends Context.Service()("Notes") { `notes TUI: failed to remember project directory: ${errorMessage(error)}`, ), }); + if (identity.source === "local") { const directories = readRepositoryDirectories(config.stateDir); + const sections = yield* Effect.try({ try: () => listNoteRepoSections(projectsRoot).map((section) => ({ @@ -951,22 +1039,26 @@ export class Notes extends Context.Service()("Notes") { catch: (error) => fail(`notes TUI: failed to list notes: ${errorMessage(error)}`), }); + return { scope: "all" as const, repoSlug, sections }; } const notesPath = join(projectsRoot, identity.owner, identity.repo); + const entries = yield* Effect.try({ try: () => listNoteEntries(projectsRoot, notesPath, repoSlug, projectDir), catch: (error) => fail(`notes TUI: failed to list notes: ${errorMessage(error)}`), }); + return { scope: "current" as const, repoSlug, entries }; }), read: (filePath) => Effect.try({ try: () => { const result = readNoteFile(projectsRoot, filePath); + return { path: result.path, content: result.content, @@ -985,11 +1077,14 @@ export class Notes extends Context.Service()("Notes") { const pathParts = relative(projectsRoot, note.path).split("/"); const owner = pathParts[0]; const repo = pathParts[1]; + if (!owner || !repo || pathParts.length !== 3) { throw new Error(`Invalid repository note path: ${filePath}`); } + const repoSlug = `${owner}/${repo}`; const directories = readRepositoryDirectories(config.stateDir); + return { entry: { filename: basename(note.path), @@ -1011,12 +1106,14 @@ export class Notes extends Context.Service()("Notes") { withMutationLock( Effect.gen(function* () { yield* prepareMutation(); + const existing = yield* Effect.try({ try: () => { const resolvedPath = resolveWritableNotePath( projectsRoot, filePath, ); + return existsSync(resolvedPath) ? readNoteFile(projectsRoot, resolvedPath) : undefined; @@ -1026,6 +1123,7 @@ export class Notes extends Context.Service()("Notes") { `Failed to inspect note file ${filePath}: ${errorMessage(error)}`, ), }); + if ( options.expectedHash !== undefined && existing?.hash !== options.expectedHash @@ -1035,6 +1133,7 @@ export class Notes extends Context.Service()("Notes") { `Expected ${options.expectedHash}, found ${existing?.hash ?? "no existing note"}.`, ); } + const stamped = options.stampDate === false ? content @@ -1045,10 +1144,12 @@ export class Notes extends Context.Service()("Notes") { new Date(yield* Clock.currentTimeMillis), ), ); + yield* Effect.try({ try: () => validateNoteContent(stamped), catch: (error) => fail(errorMessage(error)), }); + const resolvedPath = yield* Effect.try({ try: () => atomicWriteNoteFile(projectsRoot, filePath, stamped), catch: (error) => @@ -1056,13 +1157,16 @@ export class Notes extends Context.Service()("Notes") { `Failed to write note file ${filePath}: ${errorMessage(error)}`, ), }); + const dir = dirname(resolvedPath); const filename = basename(resolvedPath); const message = `notes: write ${filename}`; + const { commit, push } = yield* commitAndPush( resolvedPath, message, ); + const output = [ `Written: ${resolvedPath}`, "", @@ -1093,6 +1197,7 @@ export class Notes extends Context.Service()("Notes") { withMutationLock( Effect.gen(function* () { yield* prepareMutation(); + const resolvedPath = yield* Effect.try({ try: () => deleteNoteFile(projectsRoot, filePath), catch: (error) => @@ -1100,13 +1205,16 @@ export class Notes extends Context.Service()("Notes") { `Failed to delete note file ${filePath}: ${errorMessage(error)}`, ), }); + const dir = dirname(resolvedPath); const filename = basename(resolvedPath); const message = `notes: delete ${filename}`; + const { commit, push } = yield* commitAndPush( resolvedPath, message, ); + const output = [ `Deleted: ${resolvedPath}`, ...commitOutputLine(commit, message), @@ -1144,6 +1252,7 @@ export class Notes extends Context.Service()("Notes") { Effect.gen(function* () { yield* prepareMutation(); const [owner, repo, ...extra] = repoSlug.split("/"); + if ( !owner || !repo || @@ -1156,22 +1265,26 @@ export class Notes extends Context.Service()("Notes") { "Expected an existing or known owner/repo scope.", ); } + const targets = new Set([ ...listNoteRepoSections(projectsRoot).map( (section) => section.repoSlug, ), ...Object.keys(readRepositoryDirectories(config.stateDir)), ]); + if (!targets.has(repoSlug)) { return yield* fail( `Unknown move destination: ${repoSlug}`, "Use `notes list --all` or the TUI move picker to see existing destinations.", ); } + const fromPath = yield* Effect.try({ try: () => resolveExistingNotePath(projectsRoot, filePath), catch: (error) => fail(errorMessage(error)), }); + const toPath = yield* Effect.try({ try: () => resolveWritableNotePath( @@ -1180,24 +1293,30 @@ export class Notes extends Context.Service()("Notes") { ), catch: (error) => fail(errorMessage(error)), }); + if (toPath === fromPath) { return yield* fail(`Note is already in ${repoSlug}`); } + if (existsSync(toPath)) { return yield* fail( `A note named ${basename(toPath)} already exists in ${repoSlug}`, ); } + yield* Effect.try({ try: () => renameSync(fromPath, toPath), catch: (error) => fail(`Failed to move note: ${errorMessage(error)}`), }); const message = `notes: move ${basename(toPath)} to ${repoSlug}`; + const commit = toNoteCommitResult( yield* commitMovedNote(fromPath, toPath, message), ); + const push = commit.committed ? yield* pushNotes() : undefined; + return { from: fromPath, path: toPath, @@ -1212,6 +1331,7 @@ export class Notes extends Context.Service()("Notes") { Effect.gen(function* () { yield* prepareMutation(); const { identity } = yield* resolveIdentity(); + return yield* createNote( identity, kind, @@ -1226,6 +1346,7 @@ export class Notes extends Context.Service()("Notes") { withMutationLock( Effect.gen(function* () { const [owner, repo, ...extra] = repository.split("/"); + if ( !owner || !repo || @@ -1238,7 +1359,9 @@ export class Notes extends Context.Service()("Notes") { "Expected owner/repo with safe path segments.", ); } + yield* prepareMutation(); + return yield* createNote( { owner, repo }, kind, @@ -1253,6 +1376,7 @@ export class Notes extends Context.Service()("Notes") { withMutationLock( Effect.gen(function* () { yield* prepareMutation(); + return yield* editAndCommit(filePath, runEditor, create); }), ), @@ -1260,6 +1384,7 @@ export class Notes extends Context.Service()("Notes") { withMutationLock( Effect.gen(function* () { yield* prepareMutation(); + const note = yield* Effect.try({ try: () => readNoteFile(projectsRoot, filePath), catch: (error) => @@ -1267,11 +1392,13 @@ export class Notes extends Context.Service()("Notes") { `setPriority: failed to read file ${filePath}: ${errorMessage(error)}`, ), }); + const updated = yield* Effect.try({ try: () => setFrontmatterField(note.content, "priority", priority), catch: (error) => fail(errorMessage(error)), }); + yield* Effect.try({ try: () => atomicWriteNoteFile(projectsRoot, note.path, updated), @@ -1282,6 +1409,7 @@ export class Notes extends Context.Service()("Notes") { }); const filename = basename(note.path); const message = `notes: set priority ${filename}`; + return yield* commitAndPush(note.path, message); }), ), diff --git a/src/notes/time.ts b/src/notes/time.ts index 65cc1dd..1526fdd 100644 --- a/src/notes/time.ts +++ b/src/notes/time.ts @@ -11,6 +11,7 @@ export function formatNoteTimestamp(date: Date): string { const hours = String(date.getHours()).padStart(2, "0"); const minutes = String(date.getMinutes()).padStart(2, "0"); const seconds = String(date.getSeconds()).padStart(2, "0"); + return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}${sign}${offsetHours}:${offsetRemainder}`; } diff --git a/src/notes/tui/App.ts b/src/notes/tui/App.ts index e3eed03..fed816f 100644 --- a/src/notes/tui/App.ts +++ b/src/notes/tui/App.ts @@ -109,7 +109,9 @@ export class App { private notesTitle(): string { const title = this.activeNotesFilter?.title ?? "Notes"; + if (!this.activeNotesFilter?.includeAllRepos) return title; + return title.startsWith("All ") ? title : `All ${title}`; } } diff --git a/src/notes/tui/NotesView.ts b/src/notes/tui/NotesView.ts index 241a9dd..aa2d4f5 100644 --- a/src/notes/tui/NotesView.ts +++ b/src/notes/tui/NotesView.ts @@ -75,6 +75,7 @@ const COMMANDS: readonly CommandHint[] = [ ]; type NotesPane = "list" | "content"; + type NoteSortMode = "modified-desc" | "modified-asc" | "name-asc" | "name-desc"; const SORT_CYCLE: readonly NoteSortMode[] = [ @@ -83,6 +84,7 @@ const SORT_CYCLE: readonly NoteSortMode[] = [ "name-asc", "name-desc", ]; + /** Configuration callbacks for the repository notes view. */ export interface NotesViewOptions { /** Resolve the initial repository scope and its note entries. */ @@ -292,6 +294,7 @@ export class NotesView { }); this.contentTitle = new PaneHeader(renderer, "notes-content-title", theme); this.rightPane.add(this.contentTitle); + const heading = new BoxRenderable(renderer, { id: "notes-content-heading", flexDirection: "column", @@ -299,6 +302,7 @@ export class NotesView { flexShrink: 0, backgroundColor: surfaceBackground(theme), }); + this.noteHeading = new TextRenderable(renderer, { id: "notes-content-heading-title", content: t`${bold(fg(theme.fgMuted)("No note selected"))}`, @@ -472,6 +476,7 @@ export class NotesView { this.bodySurface.syncMarker(); }); }; + renderer.keyInput.on("keypress", this.keyHandler); renderer.on(CliRenderEvents.RESIZE, this.resizeHandler); renderer.root.add(this.root); @@ -483,6 +488,7 @@ export class NotesView { setFilter(filter: NotesViewFilter | null): void { const previous = this.filterKey; this.filter = filter; + if (previous !== this.filterKey) { this.clearDeleteConfirmation(false); this.searchActive = false; @@ -495,6 +501,7 @@ export class NotesView { this.usingAllReposFallback = false; this.updateAppHeader(); this.applyFilter(); + if (this.isVisible) void this.refresh(); } } @@ -503,12 +510,16 @@ export class NotesView { setVisible(visible: boolean): void { this.isVisible = visible; this.root.visible = visible; + if (!visible) { this.clearDeleteConfirmation(false); + return; } + if (this.layout.mode === "minimum") this.renderer.focusRenderable(this.minimumSize); + if (this.requestedInitialRefresh) return; this.requestedInitialRefresh = true; void this.refresh(); @@ -537,14 +548,17 @@ export class NotesView { private get filterKey(): string { const tag = this.filter?.tag?.toLowerCase() ?? ""; const scope = this.filter?.includeAllRepos ? "all" : "current"; + return `${tag}:${scope}`; } private async refresh(): Promise { const version = ++this.loadVersion; this.statusBar.content = t`${fg(this.theme.yellow)("Refreshing notes...")}`; + try { const loaded = await this.loadEntriesForActiveScope(); + if (version !== this.loadVersion) return false; this.entries = loaded.entries; this.showingAllRepos = loaded.allRepos; @@ -553,6 +567,7 @@ export class NotesView { this.updateAppHeader(); this.applyFilter(); this.updateStatusBar(); + return true; } catch (error) { if (version !== this.loadVersion) return false; @@ -561,6 +576,7 @@ export class NotesView { this.noteList.setItems([]); this.showEmptyContent("Unable to load notes", errorMessage(error)); this.statusBar.content = t`${fg(this.theme.red)(`Unable to load notes: ${errorMessage(error)}`)}`; + return false; } } @@ -580,6 +596,7 @@ export class NotesView { } const scope = await this.callbacks.loadTuiScope(); + if (scope.scope === "all") { return { entries: flattenNoteSections(scope.sections), @@ -588,6 +605,7 @@ export class NotesView { preferredRepoSlug: scope.repoSlug, }; } + return { entries: scope.entries, allRepos: false, fallback: false }; } @@ -595,11 +613,13 @@ export class NotesView { const tagFiltered = this.entries.filter((entry) => matchesFilter(entry, this.filter), ); + const query = this.searchQuery.trim(); const searching = query.length > 0; this.visibleEntries = searching ? this.searchEntries(tagFiltered, query) : this.sortEntries(tagFiltered); + const preferredFilePath = this.selectedFilePath ?? (this.preferredInitialRepoSlug @@ -607,6 +627,7 @@ export class NotesView { (entry) => entry.repoSlug === this.preferredInitialRepoSlug, )?.filePath : undefined); + this.preferredInitialRepoSlug = null; this.noteList.setItems( this.visibleEntries.map((entry) => this.listItem(entry, !searching)), @@ -614,6 +635,7 @@ export class NotesView { ); this.updateAppHeader(); this.updatePaneTitles(); + if (this.visibleEntries.length === 0) this.showEmptyContent(this.emptyTitle(), this.emptyBody()); } @@ -645,16 +667,22 @@ export class NotesView { private handleSearchKey(key: KeyEvent): void { if (key.name === "escape" || key.name === "return") { this.exitSearch(); + return; } + if (key.name === "up") { this.noteList.selectPrevious(); + return; } + if (key.name === "down") { this.noteList.selectNext(); + return; } + if (key.name === "backspace") { if (this.searchQuery.length > 0) { this.searchQuery = this.searchQuery.slice(0, -1); @@ -662,8 +690,10 @@ export class NotesView { } else { this.exitSearch(); } + return; } + if ( key.sequence && key.sequence.length === 1 && @@ -684,6 +714,7 @@ export class NotesView { private cycleSortMode(): void { const nextIndex = (SORT_CYCLE.indexOf(this.sortMode) + 1) % SORT_CYCLE.length; + this.sortMode = SORT_CYCLE[nextIndex]; this.applyFilter(); this.updateStatusBar(); @@ -692,6 +723,7 @@ export class NotesView { private cycleGroupMode(): void { const nextIndex = (GROUP_CYCLE.indexOf(this.groupMode) + 1) % GROUP_CYCLE.length; + this.groupMode = GROUP_CYCLE[nextIndex]; this.applyFilter(); this.updateStatusBar(); @@ -711,38 +743,48 @@ export class NotesView { private sortEntries(entries: readonly NoteEntry[]): readonly NoteEntry[] { const compare = sortComparator(this.sortMode); + if (this.groupingByPriority()) { return [...entries].sort((a, b) => { const rankDelta = priorityRank(notePriority(a)) - priorityRank(notePriority(b)); + return rankDelta !== 0 ? rankDelta : compare(a, b); }); } if (!this.groupingByRepo()) return [...entries].sort(compare); const sectionOrder = new Map(); + for (const entry of entries) { const key = entry.repoSlug ?? ""; + if (!sectionOrder.has(key)) sectionOrder.set(key, sectionOrder.size); } + return [...entries].sort((a, b) => { const sectionDelta = (sectionOrder.get(a.repoSlug ?? "") ?? 0) - (sectionOrder.get(b.repoSlug ?? "") ?? 0); + return sectionDelta !== 0 ? sectionDelta : compare(a, b); }); } private toggleAllRepos(): void { const currentFilter = this.filter; + if (currentFilter?.includeAllRepos) { const nextFilter: NotesViewFilter = { ...(currentFilter.tag && { tag: currentFilter.tag }), ...(currentFilter.title && { title: currentFilter.title }), }; + this.setFilter(Object.keys(nextFilter).length > 0 ? nextFilter : null); + return; } + this.setFilter({ ...currentFilter, includeAllRepos: true }); } @@ -760,6 +802,7 @@ export class NotesView { try { const content = await this.callbacks.readNote(entry.filePath); + if (version !== this.loadVersion) return; this.loadedNoteContent = content; this.setMarkdownContent(noteBodyContent(content)); @@ -784,13 +827,16 @@ export class NotesView { const minimum = layout.mode === "minimum"; this.minimumSize.visible = minimum; this.shell.visible = !minimum; + if (minimum) { this.noteList.setActive(false); this.bodyScroll.blur(); this.minimumSizeText.content = t`${bold(fg(this.theme.accent)("Notes needs more room"))}\n${fg(this.theme.fgMuted)(`Resize to at least ${layout.requiredWidth}x${layout.requiredHeight}.`)}\n${fg(this.theme.fgSubtle)("Esc exits")}`; this.renderer.focusRenderable(this.minimumSize); + return; } + if (layout.mode === "split") { this.leftPane.visible = true; this.rightPane.visible = true; @@ -804,6 +850,7 @@ export class NotesView { this.leftPane.visible = this.activePane === "list"; this.rightPane.visible = this.activePane === "content"; } + this.metadata.setOpen(this.metadataOpen()); this.commandBar.update( this.currentStatusText(), @@ -826,19 +873,27 @@ export class NotesView { private async requestOpenSelectedInAgent(mode: AgentOpenMode): Promise { if (this.activeOperation) { this.showActiveOperation(); + return; } + const entry = this.selectedEntry; + if (!entry) { this.statusBar.content = t`${fg(this.theme.yellow)("Select a note before opening an agent")}`; + return; } + try { const targets = await this.callbacks.listAgentTargets(); + if (targets.length === 0) { this.statusBar.content = t`${fg(this.theme.yellow)("No installed agent targets")}`; + return; } + this.noteList.setActive(false); this.bodyScroll.blur(); this.agentMode = mode; @@ -859,18 +914,23 @@ export class NotesView { const mode = this.agentMode; this.agentMode = "default"; const modeLabel = mode === "plan" ? `${target.label} plan` : target.label; + if (!entry || !this.beginOperation(`opening ${modeLabel}`)) { this.focusPane(this.activePane); + return; } + const label = notePathLabel(entry); this.statusBar.content = t`${fg(this.theme.yellow)(`Opening ${label} in ${modeLabel}...`)}`; + try { const content = this.loadedNoteContentPath === entry.filePath && this.loadedNoteContent !== null ? this.loadedNoteContent : await this.callbacks.readNote(entry.filePath); + this.loadedNoteContent = content; this.loadedNoteContentPath = entry.filePath; await this.callbacks.onOpenAgent(entry, content, target, mode); @@ -885,10 +945,13 @@ export class NotesView { private async openSelectedInEditor(kind: NoteEditorKind): Promise { const entry = this.selectedEntry; + if (!entry) { this.statusBar.content = t`${fg(this.theme.yellow)("Select a note before editing")}`; + return; } + if (!this.beginOperation(`editing ${notePathLabel(entry)}`)) return; this.editingFilePath = entry.filePath; @@ -899,12 +962,14 @@ export class NotesView { let editError: unknown; let gitResult: NoteGitResult | undefined; let refreshed = false; + try { try { gitResult = await this.callbacks.editNote(entry, kind, false); } catch (error) { editError = error; } + refreshed = await this.refresh(); } finally { this.editingFilePath = null; @@ -913,13 +978,17 @@ export class NotesView { if (editError) { this.statusBar.content = t`${fg(this.theme.red)(`Failed to edit ${label}: ${errorMessage(editError)}`)}`; + return; } + if (refreshed) { const outcome = gitResult ? noteGitOutcome(gitResult) : undefined; + const message = outcome?.complete ? `Updated ${label}` : `Updated ${label}; ${outcome?.detail ?? "git status unavailable"}`; + if (outcome && !outcome.complete) this.showAcknowledgement(message); else this.statusBar.content = t`${fg(this.theme.green)(message)}`; } @@ -928,8 +997,10 @@ export class NotesView { private startCreateFlow(editorKind: NoteEditorKind): void { if (this.activeOperation) { this.showActiveOperation(); + return; } + this.createEditorKind = editorKind; this.noteList.setActive(false); this.bodyScroll.blur(); @@ -950,6 +1021,7 @@ export class NotesView { this.focusPane(this.activePane); let created: NoteCreateResult; + try { created = await this.callbacks.createNote( result.kind, @@ -961,11 +1033,13 @@ export class NotesView { this.creatingNote = false; this.endOperation(); this.statusBar.content = t`${fg(this.theme.red)(`Failed to create draft: ${errorMessage(error)}`)}`; + return; } const { draft, git } = created; this.selectedFilePath = draft.entry.filePath; + try { await this.refresh(); } finally { @@ -975,14 +1049,17 @@ export class NotesView { if (!created.created) { this.statusBar.content = t`${fg(this.theme.fgMuted)(`Create cancelled: ${draft.entry.filename}`)}`; + return; } const matchesActiveFilter = this.visibleEntries.some( (entry) => entry.filePath === draft.entry.filePath, ); + const outcome = noteGitOutcome(git); const message = `${outcome?.complete ? "Created" : "Created locally"} ${draft.entry.filename}${matchesActiveFilter ? "" : " (hidden by current filter)"}${outcome && !outcome.complete ? `; ${outcome.detail}` : ""}`; + if (!outcome.complete) this.showAcknowledgement(message); else this.statusBar.content = t`${fg(matchesActiveFilter ? this.theme.green : this.theme.yellow)(message)}`; @@ -991,13 +1068,18 @@ export class NotesView { private requestChangePriority(): void { if (this.activeOperation) { this.showActiveOperation(); + return; } + const entry = this.selectedEntry; + if (!entry) { this.statusBar.content = t`${fg(this.theme.yellow)("Select a note before changing priority")}`; + return; } + this.noteList.setActive(false); this.bodyScroll.blur(); this.priorityPopup.show( @@ -1013,10 +1095,13 @@ export class NotesView { private async executeSetPriority(priority: NotePriority): Promise { const entry = this.selectedEntry; + if (!entry) { this.focusPane(this.activePane); + return; } + if (!this.beginOperation(`setting ${notePathLabel(entry)} priority`)) return; this.settingPriorityPath = entry.filePath; @@ -1024,14 +1109,17 @@ export class NotesView { const label = notePathLabel(entry); this.statusBar.content = t`${fg(this.theme.yellow)(`Setting ${label} to ${priorityLabel(priority)}...`)}`; this.focusPane(this.activePane); + try { const result = await this.callbacks.onSetPriority( entry.filePath, priority, ); + await this.refresh(); const outcome = noteGitOutcome(result); const message = `Set ${label} priority to ${priorityLabel(priority)}${outcome.complete ? "" : `; ${outcome.detail}`}`; + if (!outcome.complete) this.showAcknowledgement(message); else this.statusBar.content = t`${fg(this.theme.green)(message)}`; } catch (error) { @@ -1045,13 +1133,18 @@ export class NotesView { private requestDeleteSelected(): void { if (this.activeOperation) { this.showActiveOperation(); + return; } + const entry = this.selectedEntry; + if (!entry) { this.statusBar.content = t`${fg(this.theme.yellow)("Select a note before deleting")}`; + return; } + this.deleteConfirmation = entry; this.showDeletePrompt(entry); } @@ -1059,22 +1152,31 @@ export class NotesView { private async requestMoveSelected(): Promise { if (this.activeOperation) { this.showActiveOperation(); + return; } + const entry = this.selectedEntry; + if (!entry) { this.statusBar.content = t`${fg(this.theme.yellow)("Select a note before moving")}`; + return; } + try { const currentRepoSlug = entry.repoSlug; + const targets = (await this.callbacks.listMoveTargets()).filter( (target) => target !== currentRepoSlug, ); + if (targets.length === 0) { this.statusBar.content = t`${fg(this.theme.yellow)("No other known move destinations")}`; + return; } + this.noteList.setActive(false); this.bodyScroll.blur(); this.movePopup.show(targets, notePathLabel(entry)); @@ -1090,18 +1192,23 @@ export class NotesView { private async executeMove(repoSlug: string): Promise { const entry = this.selectedEntry; + if (!entry || !this.beginOperation(`moving ${notePathLabel(entry)}`)) { this.focusPane(this.activePane); + return; } + const label = notePathLabel(entry); this.statusBar.content = t`${fg(this.theme.yellow)(`Moving ${label} to ${repoSlug}...`)}`; + try { const result = await this.callbacks.moveNote(entry.filePath, repoSlug); this.selectedFilePath = result.path; await this.refresh(); const outcome = noteGitOutcome(result); const message = `Moved ${entry.filename} to ${repoSlug}${outcome.complete ? "" : `; ${outcome.detail}`}`; + if (!outcome.complete) this.showAcknowledgement(message); else this.statusBar.content = t`${fg(this.theme.green)(message)}`; } catch (error) { @@ -1114,6 +1221,7 @@ export class NotesView { private async confirmDeleteSelected(): Promise { const entry = this.deleteConfirmation; + if (!entry || !this.beginOperation(`deleting ${notePathLabel(entry)}`)) return; this.deletingFilePath = entry.filePath; @@ -1121,12 +1229,15 @@ export class NotesView { this.loadVersion += 1; const label = notePathLabel(entry); this.statusBar.content = t`${fg(this.theme.yellow)(`Deleting ${label}...`)}`; + try { const nextSelectedFilePath = this.nextSelectedFilePathAfterDelete( entry.filePath, ); + const result = await this.callbacks.deleteNote(entry.filePath); this.clearDeletedSelection(entry.filePath, nextSelectedFilePath); + if (await this.refresh()) this.showDeleteSuccess(label, result); } catch (error) { this.statusBar.content = t`${fg(this.theme.red)(`Failed to delete ${label}: ${errorMessage(error)}`)}`; @@ -1140,7 +1251,9 @@ export class NotesView { const deletedIndex = this.visibleEntries.findIndex( (entry) => entry.filePath === filePath, ); + if (deletedIndex === -1) return null; + return ( this.visibleEntries[deletedIndex + 1]?.filePath ?? this.visibleEntries[deletedIndex - 1]?.filePath ?? @@ -1154,8 +1267,10 @@ export class NotesView { ): void { if (this.selectedFilePath === deletedFilePath) this.selectedFilePath = nextSelectedFilePath; + if (this.selectedEntry?.filePath === deletedFilePath) this.selectedEntry = null; + if (this.loadedNoteContentPath === deletedFilePath) { this.loadedNoteContent = null; this.loadedNoteContentPath = null; @@ -1165,6 +1280,7 @@ export class NotesView { private showDeleteSuccess(label: string, result: NoteDeleteResult): void { const outcome = noteGitOutcome(result); const message = `Deleted ${label}${outcome.complete ? "" : `; ${outcome.detail}`}`; + if (!outcome.complete) this.showAcknowledgement(message); else this.statusBar.content = t`${fg(this.theme.green)(message)}`; } @@ -1178,6 +1294,7 @@ export class NotesView { private cancelDeleteConfirmation(): void { const entry = this.deleteConfirmation; this.clearDeleteConfirmation(); + if (entry) this.statusBar.content = t`${fg(this.theme.fgMuted)(`Delete cancelled: ${notePathLabel(entry)}`)}`; } @@ -1185,41 +1302,54 @@ export class NotesView { private clearDeleteConfirmation(refocus = true): void { this.deleteConfirmation = null; this.deletePrompt.hide(); + if (refocus && this.isVisible) this.focusPane(this.activePane); } private handleKeyPress(key: KeyEvent): void { if (!this.isVisible) return; + if (this.agentPopup.visible) { if (["up", "down", "pageup", "pagedown", "return"].includes(key.name)) this.agentPopup.handleKeyPress(key); else Dialog.handleTopmostKey(key); + return; } + if (Dialog.handleTopmostKey(key)) return; + if (this.layout.mode === "minimum") { if ((key.ctrl && key.name === "c") || key.name === "escape") { key.preventDefault(); this.callbacks.onBack(); } + return; } + if (this.acknowledgement) { key.preventDefault(); this.acknowledgement = null; this.updateStatusBar(); + return; } + if (this.activeOperation) { key.preventDefault(); this.showActiveOperation(); + return; } + if (key.ctrl && key.name === "c") { key.preventDefault(); this.callbacks.onBack(); + return; } + if ( this.createPrompt.visible || this.agentPopup.visible || @@ -1229,36 +1359,46 @@ export class NotesView { this.helpDialog.visible ) return; + if (this.searchActive) { key.preventDefault(); this.handleSearchKey(key); + return; } + if ( this.activePane === "list" && ["up", "down", "pageup", "pagedown", "return"].includes(key.name) ) { key.preventDefault(); this.noteList.handleKeyPress(key); + return; } + if ( this.activePane === "content" && ["up", "down", "pageup", "pagedown", "home", "end"].includes(key.name) ) { key.preventDefault(); this.bodySurface.handleKeyPress(key); + return; } + this.keyHandlers[`${key.shift ? "shift+" : ""}${key.name}`]?.(); } private beginOperation(label: string): boolean { if (this.activeOperation) { this.showActiveOperation(); + return false; } + this.activeOperation = label; + return true; } @@ -1277,11 +1417,14 @@ export class NotesView { private focusPane(pane: NotesPane): void { this.activePane = pane; + if (this.layout.mode === "master-detail") { this.leftPane.visible = pane === "list"; this.rightPane.visible = pane === "content"; } + this.noteList.setActive(pane === "list"); + if (pane === "content") this.bodyScroll.focus(); else this.bodyScroll.blur(); this.updatePaneTitles(); @@ -1311,7 +1454,9 @@ export class NotesView { showSection: boolean, ): string | undefined { if (!showSection) return undefined; + if (this.groupingByPriority()) return priorityLabel(notePriority(entry)); + return this.groupingByRepo() ? entry.repoSlug : undefined; } @@ -1348,12 +1493,14 @@ export class NotesView { private updatePaneTitles(): void { const query = this.searchQuery.trim(); + const detail = this.searchActive || query.length > 0 ? `search "${query}"` : this.groupMode === "none" ? sortModeLabel(this.sortMode) : `group:${this.groupMode} | ${sortModeLabel(this.sortMode)}`; + this.listTitle.update( `${notesDisplayTitle(this.filter, this.showingAllRepos)} | ${detail}`, `${this.visibleEntries.length}`, @@ -1369,21 +1516,30 @@ export class NotesView { private updateStatusBar(): void { if (this.searchActive && this.searchQuery.trim().length === 0) { this.statusBar.content = t`${fg(this.theme.yellow)("Search:")}${fg(this.theme.fgMuted)(" type to filter")} ${fg(this.theme.fgSubtle)("Enter/Esc exit")}`; + return; } + if (this.visibleEntries.length === 0) { this.statusBar.content = t`${fg(this.theme.fgMuted)(this.emptyBody())}`; + return; } + const query = this.searchQuery.trim(); + if (query.length > 0) { const count = this.visibleEntries.length; + const hint = this.searchActive ? "type to filter | Enter/Esc exit" : "/ edit search"; + this.statusBar.content = t`${fg(this.theme.fgMuted)(`${count} ${matchLabel(count)} for "${query}"`)} ${fg(this.theme.fgSubtle)(hint)}`; + return; } + this.statusBar.content = t`${fg(this.theme.fgMuted)(formatStatusBarText(this.visibleEntries.length, this.selectedEntry, this.filter, this.showingAllRepos, this.usingAllReposFallback))}`; this.commandBar.update( this.currentStatusText(), @@ -1397,7 +1553,9 @@ export class NotesView { return this.searchQuery ? `Search: ${this.searchQuery}` : "Search: type to filter"; + if (!this.visibleEntries.length) return this.emptyBody(); + return formatStatusBarText( this.visibleEntries.length, this.selectedEntry, @@ -1409,17 +1567,21 @@ export class NotesView { private emptyTitle(): string { if (this.searchQuery.trim().length > 0) return "No matches"; + return `No ${notesDisplayTitle(this.filter, this.showingAllRepos)}`; } private emptyBody(): string { const query = this.searchQuery.trim(); + if (query.length > 0) return `No notes match "${query}".`; + if (this.showingAllRepos) { return this.filter?.tag ? `No notes tagged ${this.filter.tag} found in any repository.` : "No notes found in any repository."; } + return this.filter?.tag ? `No notes tagged ${this.filter.tag} found for this repository.` : "No notes found for this repository."; @@ -1457,6 +1619,7 @@ function matchesFilter( ): boolean { if (!filter?.tag) return true; const wanted = filter.tag.toLowerCase(); + return entry.tags.some((tag) => tag.toLowerCase() === wanted); } @@ -1506,6 +1669,7 @@ function flattenNoteSections( function splitNoteBody(content: string): string { const match = content.match(/^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/); + return match ? content.slice(match[0].length) : content; } @@ -1518,6 +1682,7 @@ function stripH1Headings(content: string): string { function noteBodyContent(content: string): string { const body = stripH1Headings(splitNoteBody(content)).trim(); + return body || "No content after frontmatter."; } @@ -1526,7 +1691,9 @@ function notesDisplayTitle( showingAllRepos: boolean, ): string { const title = filter?.title ?? "Notes"; + if (!showingAllRepos) return title; + return title.startsWith("All ") ? title : `All ${title}`; } @@ -1535,6 +1702,7 @@ function notesSubtitle( showingAllRepos: boolean, ): string { const scope = showingAllRepos ? "all repos" : "repo notes"; + return filter?.tag ? `tag:${filter.tag} | ${scope}` : scope; } @@ -1567,6 +1735,7 @@ function filterStatusText( ? [usingAllReposFallback ? "all repos fallback" : "all repos"] : []), ]; + return parts.length ? ` | ${parts.join(" | ")}` : ""; } @@ -1577,6 +1746,7 @@ function selectedStatusText(entry: NoteEntry | null): string { function formatListDescription(entry: NoteEntry): string { const description = entry.description ?? "No description"; const tags = entry.tags.length ? ` [${entry.tags.join(", ")}]` : ""; + return `${description}${tags} | ${formatLocalNoteDateTimeFromEpochSeconds(entry.mtime)}`; } @@ -1596,5 +1766,6 @@ function stripMarkdownExtension(filename: string): string { function errorMessage(error: Failure): string { if (error instanceof Error) return error.message; + return String(error); } diff --git a/src/notes/tui/OpenCodeNote.ts b/src/notes/tui/OpenCodeNote.ts index 969838c..d14c933 100644 --- a/src/notes/tui/OpenCodeNote.ts +++ b/src/notes/tui/OpenCodeNote.ts @@ -39,6 +39,7 @@ const OpenCodeConfig = Schema.Struct({ }), ), }); + const OpenCodeConfigJson = Schema.fromJsonString(OpenCodeConfig); /** Suspend the TUI, launch a full OpenCode session for a note, then resume. */ @@ -50,10 +51,12 @@ export async function openNoteInOpenCode( ): Promise { const mode = options.mode ?? "default"; const cwd = opencodeNoteDirectory(entry); + const planCommand = mode === "plan" ? await (options.loadPlanCommand ?? loadConfiguredPlanCommand)(cwd) : null; + await openOpenCodeSession(renderer, { mode, cwd, @@ -70,6 +73,7 @@ export function opencodeNotePrompt( planCommand: string | null = null, ): string { const displayPath = projectsDisplayPath(entry); + const notePrompt = [ `Use the repository note ${entry.filename} included below as loaded context for this OpenCode session, following the note-reference next-step flow.`, `The note file path is ${entry.filePath}.`, @@ -112,6 +116,7 @@ export function opencodeNotePrompt( if (mode !== "plan") return notePrompt; const instructions = planCommand?.trim() || DEFAULT_PLAN_INSTRUCTIONS; + return instructions.includes("${ARGUMENTS}") ? instructions.replaceAll("${ARGUMENTS}", notePrompt) : `${instructions}\n\n${notePrompt}`; @@ -128,12 +133,15 @@ export async function loadConfiguredPlanCommand( stdout: "pipe", stderr: "pipe", }); + const [stdout, , exitCode] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited, ]); + if (exitCode !== 0) return null; + return Option.match( Schema.decodeUnknownOption(OpenCodeConfigJson)(stdout), { @@ -154,7 +162,9 @@ export function opencodeNoteDirectory(entry: NoteEntry): string | undefined { `No source checkout is known for ${entry.repoSlug ?? entry.filename}. Run Notes from that repository once to record it.`, ); } + if (!entry.repoSlug?.startsWith("local/")) return entry.projectDir; + try { return statSync(entry.projectDir).isDirectory() ? entry.projectDir @@ -178,6 +188,8 @@ function projectsDisplayPath(entry: NoteEntry): string { const marker = "/projects/"; const normalized = entry.filePath.replaceAll("\\", "/"); const markerIndex = normalized.lastIndexOf(marker); + if (markerIndex === -1) return entry.filename; + return `projects/${normalized.slice(markerIndex + marker.length)}`; } diff --git a/src/notes/tui/dialogs/AgentDialog.ts b/src/notes/tui/dialogs/AgentDialog.ts index 44f547e..51e8a7c 100644 --- a/src/notes/tui/dialogs/AgentDialog.ts +++ b/src/notes/tui/dialogs/AgentDialog.ts @@ -45,18 +45,22 @@ export class AgentDialog { onOpen(item.value); }, }); + const cancel = new Button(renderer, { id: "agent-dialog-cancel", theme, label: "Cancel", onPress: () => this.dialog.dismiss(), }); + this.dialog.body.add(this.note); this.dialog.body.add(this.list); + const actions = new BoxRenderable(renderer, { height: 1, flexShrink: 0, }); + actions.add(cancel); this.dialog.body.add(actions); this.dialog.registerFocusable(this.list, true); diff --git a/src/notes/tui/dialogs/CreateNoteDialog.ts b/src/notes/tui/dialogs/CreateNoteDialog.ts index 417801b..ac19a4f 100644 --- a/src/notes/tui/dialogs/CreateNoteDialog.ts +++ b/src/notes/tui/dialogs/CreateNoteDialog.ts @@ -76,6 +76,7 @@ export class CreateNoteDialog { onValueChange: (value) => (this.kind = value), onActivate: () => this.showDetails(), }); + const next = new Button(renderer, { id: "create-template-next", theme, @@ -83,6 +84,7 @@ export class CreateNoteDialog { variant: "primary", onPress: () => this.showDetails(), }); + this.templateStage.add(this.templates); this.templateStage.add(next); this.name = new Input(renderer, { @@ -97,8 +99,10 @@ export class CreateNoteDialog { placeholder: "Description (optional)", onSubmit: () => submit(), }); + const submit = () => { const name = this.name.value.trim(); + if (!name) return this.name.focus(); this.dialog.hide(); onSubmit({ @@ -107,6 +111,7 @@ export class CreateNoteDialog { description: this.description.value.trim(), }); }; + const labels = [ new TextRenderable(renderer, { content: t`${fg(theme.fgMuted)("Name")}`, @@ -117,11 +122,13 @@ export class CreateNoteDialog { height: 1, }), ]; + const actions = new BoxRenderable(renderer, { flexDirection: "row", height: 1, gap: 1, }); + const create = new Button(renderer, { id: "create-note-submit", theme, @@ -129,12 +136,14 @@ export class CreateNoteDialog { variant: "primary", onPress: submit, }); + const cancel = new Button(renderer, { id: "create-note-cancel", theme, label: "Cancel", onPress: () => this.dialog.dismiss(), }); + actions.add(create); actions.add(cancel); this.detailsStage.add(labels[0]); @@ -161,6 +170,7 @@ export class CreateNoteDialog { ) this.showTemplates(); }; + renderer.keyInput.on("keypress", this.keyHandler); } @@ -172,9 +182,11 @@ export class CreateNoteDialog { this.description.value = ""; this.kind = preferHandoff ? "handoff" : "note"; this.templates.value = this.kind; + if (preferHandoff) this.showDetails(); else this.showTemplates(); this.dialog.show(); + if (preferHandoff) this.name.focus(); } destroy(): void { diff --git a/src/notes/tui/dialogs/DeleteNoteDialog.ts b/src/notes/tui/dialogs/DeleteNoteDialog.ts index 900cb21..aeca7f4 100644 --- a/src/notes/tui/dialogs/DeleteNoteDialog.ts +++ b/src/notes/tui/dialogs/DeleteNoteDialog.ts @@ -34,11 +34,13 @@ export class DeleteNoteDialog { truncate: true, content: "", }); + const actions = new BoxRenderable(renderer, { flexDirection: "row", height: 1, gap: 1, }); + const remove = new Button(renderer, { id: "delete-note-confirm", theme, @@ -49,12 +51,14 @@ export class DeleteNoteDialog { onConfirm(); }, }); + const cancel = new Button(renderer, { id: "delete-note-cancel", theme, label: "Cancel", onPress: () => this.dialog.dismiss(), }); + actions.add(remove); actions.add(cancel); this.dialog.body.add(this.file); diff --git a/src/notes/tui/dialogs/HelpDialog.ts b/src/notes/tui/dialogs/HelpDialog.ts index ebc503d..72c6b78 100644 --- a/src/notes/tui/dialogs/HelpDialog.ts +++ b/src/notes/tui/dialogs/HelpDialog.ts @@ -25,6 +25,7 @@ export class HelpDialog { height: 14, onDismiss, }); + for (const [heading, commands] of GROUPS) { this.dialog.body.add( new TextRenderable(renderer, { @@ -42,12 +43,14 @@ export class HelpDialog { }), ); } + const close = new Button(renderer, { id: "help-dialog-close", theme, label: "Close", onPress: () => this.dialog.dismiss(), }); + this.dialog.body.add(close); this.dialog.registerFocusable(close, true); } diff --git a/src/notes/tui/dialogs/MoveNoteDialog.ts b/src/notes/tui/dialogs/MoveNoteDialog.ts index 21110ba..d829ffe 100644 --- a/src/notes/tui/dialogs/MoveNoteDialog.ts +++ b/src/notes/tui/dialogs/MoveNoteDialog.ts @@ -50,12 +50,14 @@ export class MoveNoteDialog { }, onSelectionChanged: (item) => (this.selected = item.value), }); + const actions = new BoxRenderable(renderer, { flexDirection: "row", height: 1, flexShrink: 0, gap: 1, }); + const move = new Button(renderer, { id: "move-note-apply", theme, @@ -67,12 +69,14 @@ export class MoveNoteDialog { onMove(this.selected); }, }); + const cancel = new Button(renderer, { id: "move-note-cancel", theme, label: "Cancel", onPress: () => this.dialog.dismiss(), }); + actions.add(move); actions.add(cancel); this.dialog.body.add(this.note); @@ -88,6 +92,7 @@ export class MoveNoteDialog { ) this.list.handleKeyPress(key); }; + renderer.keyInput.on("keypress", this.keyHandler); } diff --git a/src/notes/tui/dialogs/PriorityDialog.ts b/src/notes/tui/dialogs/PriorityDialog.ts index 714d437..a90a93e 100644 --- a/src/notes/tui/dialogs/PriorityDialog.ts +++ b/src/notes/tui/dialogs/PriorityDialog.ts @@ -5,6 +5,7 @@ import { t, type CliRenderer, } from "@opentui/core"; +import { Match } from "effect"; import type { Theme } from "../../../theme.js"; import { Button } from "../../../tui/components/Button.js"; import { Dialog } from "../../../tui/components/Dialog.js"; @@ -23,13 +24,12 @@ const DESCRIPTIONS = { } satisfies Readonly>; export function priorityColor(theme: Theme, priority: NotePriority): string { - return priority === "critical" - ? theme.red - : priority === "high" - ? theme.yellow - : priority === "low" - ? theme.green - : theme.accent; + return Match.value(priority).pipe( + Match.when("critical", () => theme.red), + Match.when("high", () => theme.yellow), + Match.when("low", () => theme.green), + Match.orElse(() => theme.accent), + ); } export interface PriorityDialogOptions { @@ -74,12 +74,14 @@ export class PriorityDialog { })), onValueChange: (value) => (this.selected = value), }); + const actions = new BoxRenderable(renderer, { flexDirection: "row", height: 1, flexShrink: 0, gap: 1, }); + const apply = new Button(renderer, { id: "priority-dialog-apply", theme, @@ -90,12 +92,14 @@ export class PriorityDialog { options.onApply(this.selected); }, }); + const cancel = new Button(renderer, { id: "priority-dialog-cancel", theme, label: "Cancel", onPress: () => this.dialog.dismiss(), }); + actions.add(apply); actions.add(cancel); this.dialog.body.add(this.title); diff --git a/src/notes/tui/layout.ts b/src/notes/tui/layout.ts index 87b55d7..a1f89e7 100644 --- a/src/notes/tui/layout.ts +++ b/src/notes/tui/layout.ts @@ -1,8 +1,13 @@ export const NOTES_NAVIGATION_MIN = 30; + export const NOTES_PREVIEW_MIN = 40; + export const NOTES_SHELL_PADDING = 2; + export const NOTES_DIVIDER_WIDTH = 1; + export const NOTES_CONTENT_MIN = 30; + export const NOTES_REQUIRED_HEIGHT = 12; export type NotesLayout = @@ -21,6 +26,7 @@ export type NotesLayout = /** Measure the Notes workspace from pane and shell requirements. */ export function measureNotesLayout(width: number, height: number): NotesLayout { const requiredWidth = NOTES_CONTENT_MIN + NOTES_SHELL_PADDING; + if (width < requiredWidth || height < NOTES_REQUIRED_HEIGHT) { return { mode: "minimum", @@ -28,14 +34,18 @@ export function measureNotesLayout(width: number, height: number): NotesLayout { requiredHeight: NOTES_REQUIRED_HEIGHT, }; } + const available = width - NOTES_SHELL_PADDING - NOTES_DIVIDER_WIDTH; + if (available < NOTES_NAVIGATION_MIN + NOTES_PREVIEW_MIN) { return { mode: "master-detail" }; } + const navigationWidth = Math.max( NOTES_NAVIGATION_MIN, Math.floor(available * 0.35), ); + return { mode: "split", navigationWidth, diff --git a/src/notes/types.ts b/src/notes/types.ts index 942bbd1..352afd9 100644 --- a/src/notes/types.ts +++ b/src/notes/types.ts @@ -262,6 +262,7 @@ export function parseNotePriority(value: string): NotePriority | null { .trim() .replace(/^["']|["']$/g, "") .toLowerCase(); + switch (normalised) { case "low": case "medium": diff --git a/src/services/CommandExecutor.ts b/src/services/CommandExecutor.ts index c518bba..554a98d 100644 --- a/src/services/CommandExecutor.ts +++ b/src/services/CommandExecutor.ts @@ -36,11 +36,13 @@ export class CommandExecutor extends Context.Service< Effect.tryPromise({ try: async (signal) => { const fullCmd = [cmd, ...args]; + const proc = Bun.spawn(fullCmd, { stdout: "pipe", stderr: "pipe", cwd: opts?.cwd, }); + if (signal.aborted) proc.kill(); signal.addEventListener("abort", () => proc.kill(), { once: true }); @@ -48,7 +50,9 @@ export class CommandExecutor extends Context.Service< new Response(proc.stdout).text(), new Response(proc.stderr).text(), ]); + const exitCode = await proc.exited; + if (exitCode !== 0) { throw new CommandError({ command: fullCmd.join(" "), @@ -56,6 +60,7 @@ export class CommandExecutor extends Context.Service< stderr: stderr.trim(), }); } + return stdout; }, catch: (error) => @@ -75,8 +80,10 @@ export class CommandExecutor extends Context.Service< stderr: "ignore", cwd: opts?.cwd, }); + if (signal.aborted) proc.kill(); signal.addEventListener("abort", () => proc.kill(), { once: true }); + return await proc.exited; }, catch: () => 1, diff --git a/src/services/Renderer.ts b/src/services/Renderer.ts index d37da13..b8708a0 100644 --- a/src/services/Renderer.ts +++ b/src/services/Renderer.ts @@ -14,7 +14,9 @@ export class Renderer extends Context.Service()( Effect.promise(async () => { const { createCliRenderer, setRenderLibPath } = await import("@opentui/core"); + if (nativeLibPath) setRenderLibPath(nativeLibPath); + return createCliRenderer({ exitOnCtrlC: false, screenMode: "alternate-screen", diff --git a/src/theme.ts b/src/theme.ts index aa51ac4..9a06e1e 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -68,6 +68,7 @@ type RGB = [r: number, g: number, b: number]; function hexToRgb(hex: string): RGB { const h = hex.replace("#", ""); + return [ Number.parseInt(h.slice(0, 2), 16), Number.parseInt(h.slice(2, 4), 16), @@ -77,22 +78,27 @@ function hexToRgb(hex: string): RGB { function rgbToHex([r, g, b]: RGB): string { const clamp = (n: number) => Math.max(0, Math.min(255, Math.round(n))); + return `#${clamp(r).toString(16).padStart(2, "0")}${clamp(g).toString(16).padStart(2, "0")}${clamp(b).toString(16).padStart(2, "0")}`; } function mix(a: string, b: string, t: number): string { const [ar, ag, ab] = hexToRgb(a); const [br, bg, bb] = hexToRgb(b); + return rgbToHex([ar + (br - ar) * t, ag + (bg - ag) * t, ab + (bb - ab) * t]); } function luminance(hex: string): number { const channelLuminance = (c: number) => { const s = c / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; }; + const [red, green, blue] = hexToRgb(hex); const [r, g, b] = [red, green, blue].map(channelLuminance); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; } @@ -106,18 +112,23 @@ function pickAccentFg( const bl = luminance(bgColor); const fgRatio = (Math.max(al, fl) + 0.05) / (Math.min(al, fl) + 0.05); const bgRatio = (Math.max(al, bl) + 0.05) / (Math.min(al, bl) + 0.05); + return fgRatio >= bgRatio ? fgColor : bgColor; } function parseColorsToml(content: string) { const result: Record = {}; + for (const line of content.split("\n")) { const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("[")) continue; const match = trimmed.match(/^(\w+)\s*=\s*"([^"]+)"/); + if (match) result[match[1]] = match[2]; } + return result; } @@ -125,6 +136,7 @@ function deriveTheme(c: Record): Theme { const bg = c.background ?? FALLBACK.bg; const fgColor = c.foreground ?? FALLBACK.fg; const accent = c.accent ?? FALLBACK.accent; + return { bg, bgElevated: mix(bg, fgColor, 0.05), @@ -155,5 +167,6 @@ export const loadTheme: Effect.Effect = Effect.gen(function* () { try: () => readFileSync(COLORS_TOML_PATH, "utf-8"), catch: (error) => new ThemeLoadError({ message: String(error) }), }); + return deriveTheme(parseColorsToml(raw)); }).pipe(Effect.orElseSucceed(() => FALLBACK)); diff --git a/src/tui/CommandBar.ts b/src/tui/CommandBar.ts index 2e6220b..64734ec 100644 --- a/src/tui/CommandBar.ts +++ b/src/tui/CommandBar.ts @@ -70,25 +70,32 @@ export class CommandBar extends BoxRenderable { sync(): void { this.status.content = t`${fg(this.theme.fgMuted)(this.statusText)}`; const width = Math.max(0, this.width || this.renderer.width - 2); + const required = this.commands.filter((command) => command.contexts.includes(this.context), ); + const ordered = [...required].sort((a, b) => { const pinned = (command: CommandHint) => command.key === "Tab" || command.key === "Esc" ? -100 : command.priority; + return pinned(a) - pinned(b); }); + const visible: string[] = []; let used = 0; + for (const command of ordered) { const text = `${command.key} ${command.action}`; const cost = text.length + (visible.length ? 2 : 0); + if (used + cost > width) continue; visible.push(text); used += cost; } + this.hints.content = t`${fg(this.theme.fgSubtle)(visible.join(" "))}`; } } diff --git a/src/tui/StatusList.ts b/src/tui/StatusList.ts index 1b62d10..822ad79 100644 --- a/src/tui/StatusList.ts +++ b/src/tui/StatusList.ts @@ -76,6 +76,7 @@ export class StatusList extends ScrollSurface { preferredId?: string | null, ): void { const selectedId = preferredId ?? this.getSelectedItem()?.id; + for (const row of this.scrollBox.getChildren()) this.scrollBox.remove(row); this.items = items; this.selectedIndex = Math.max( @@ -91,6 +92,7 @@ export class StatusList extends ScrollSurface { sectionHeader = this.createSection(item.section, index); this.addContent(sectionHeader); } + const row = this.createRow(item, index, sectionHeader); this.rows.push(row); this.addContent(row.container); @@ -106,6 +108,7 @@ export class StatusList extends ScrollSurface { setActive(active: boolean, options?: { readonly focus?: boolean }): void { this.active = active; + if (active && (options?.focus ?? true)) this.focus(); else this.blur(); this.refresh(); @@ -132,29 +135,38 @@ export class StatusList extends ScrollSurface { override handleKeyPress(key: KeyEvent): boolean { if (key.name === "up" || key.name === "down") { this.moveSelection(key.name === "up" ? -1 : 1); + return true; } + if (key.name === "pageup" || key.name === "pagedown") { const page = this.completeItemsPerPage(); this.moveSelection(key.name === "pageup" ? -page : page, false); + return true; } + if (key.name === "return" && this.selectOnEnter) { const item = this.getSelectedItem(); + if (item) this.onSelectItem(item); + return true; } + return this.scrollBox.handleKeyPress(key); } private moveSelection(delta: number, wrap = true): void { if (!this.items.length) return; + const next = wrap ? (this.selectedIndex + delta + this.items.length) % this.items.length : Math.max( 0, Math.min(this.items.length - 1, this.selectedIndex + delta), ); + if (next === this.selectedIndex) return; this.selectedIndex = next; this.refresh(); @@ -169,7 +181,9 @@ export class StatusList extends ScrollSurface { this.ensureSelectionVisible(); this.emitSelection(); } + const item = this.items[index]; + if (item) this.onSelectItem(item); } @@ -181,6 +195,7 @@ export class StatusList extends ScrollSurface { flexShrink: 0, paddingLeft: 1, }); + header.add( new TextRenderable(this.renderer, { content: t`${bold(fg(this.listTheme.fgSubtle)(label))}`, @@ -189,6 +204,7 @@ export class StatusList extends ScrollSurface { truncate: true, }), ); + return header; } @@ -210,18 +226,21 @@ export class StatusList extends ScrollSurface { this.activate(index); }, }); + const marker = new TextRenderable(this.renderer, { width: 2, height: 2, flexShrink: 0, content: "", }); + const content = new BoxRenderable(this.renderer, { flexDirection: "column", flexGrow: 1, minWidth: 0, height: 2, }); + const title = new TextRenderable(this.renderer, { height: 1, flexShrink: 0, @@ -230,6 +249,7 @@ export class StatusList extends ScrollSurface { overflow: "hidden", content: "", }); + const description = new TextRenderable(this.renderer, { height: 1, flexShrink: 0, @@ -238,10 +258,12 @@ export class StatusList extends ScrollSurface { overflow: "hidden", content: "", }); + content.add(title); content.add(description); container.add(marker); container.add(content); + return { container, marker, title, description, item, sectionHeader }; } @@ -260,17 +282,23 @@ export class StatusList extends ScrollSurface { private ensureSelectionVisible(): void { const row = this.rows[this.selectedIndex]; + if (!row) return; const viewport = this.scrollBox.viewport.height; const rowStart = this.childStart(row.container); + const contextStart = row.sectionHeader ? this.childStart(row.sectionHeader) : rowStart; + const rowEnd = rowStart + row.container.height; + const targetStart = rowEnd - contextStart <= viewport ? contextStart : rowStart; + const current = this.scrollBox.scrollTop; let target = current; + if (targetStart < current) target = targetStart; else if (rowEnd > current + viewport) target = rowEnd - viewport; this.scrollBox.scrollTop = this.completeChildBoundary(target); @@ -279,7 +307,9 @@ export class StatusList extends ScrollSurface { private completeItemsPerPage(): number { const viewport = this.scrollBox.viewport.height; + if (viewport <= 0) return 1; + return Math.max(1, Math.floor(viewport / 2)); } @@ -288,27 +318,33 @@ export class StatusList extends ScrollSurface { 0, this.scrollBox.scrollHeight - this.scrollBox.viewport.height, ); + const bounded = Math.max(0, Math.min(offset, extent)); const starts: number[] = []; let start = 0; + for (const child of this.scrollBox.getChildren()) { if (start <= bounded) starts.push(start); start += child.height; } + return Math.max(0, ...starts); } private childStart(target: BoxRenderable): number { let start = 0; + for (const child of this.scrollBox.getChildren()) { if (child === target) return start; start += child.height; } + return start; } private emitSelection(): void { const item = this.getSelectedItem(); + if (item) this.onItemSelectionChanged?.(item); } } diff --git a/src/tui/SupervisedProcess.ts b/src/tui/SupervisedProcess.ts index 7b3784c..2872043 100644 --- a/src/tui/SupervisedProcess.ts +++ b/src/tui/SupervisedProcess.ts @@ -25,6 +25,8 @@ export async function runSupervisedProcess( stdout: options.stdout, stderr: options.stderr, }); + const exitCode = await proc.exited; + if (exitCode !== 0) throw new ProcessExitError(options.label, exitCode); } diff --git a/src/tui/components/Button.ts b/src/tui/components/Button.ts index f616136..1bdc659 100644 --- a/src/tui/components/Button.ts +++ b/src/tui/components/Button.ts @@ -1,5 +1,6 @@ import { TextRenderable, bold, fg, t, type CliRenderer } from "@opentui/core"; import { ButtonRenderable } from "@tuiparts/core/button"; +import { Match } from "effect"; import type { Theme } from "../../theme.js"; export type ButtonVariant = "neutral" | "primary" | "destructive"; @@ -15,12 +16,12 @@ export interface ButtonOptions { /** TUI Parts button with Notes-owned presentation. */ export class Button extends ButtonRenderable { constructor(renderer: CliRenderer, options: ButtonOptions) { - const color = - options.variant === "destructive" - ? options.theme.red - : options.variant === "primary" - ? options.theme.accent - : options.theme.fgMuted; + const color = Match.value(options.variant).pipe( + Match.when("destructive", () => options.theme.red), + Match.when("primary", () => options.theme.accent), + Match.orElse(() => options.theme.fgMuted), + ); + super(renderer, { id: options.id, height: 1, diff --git a/src/tui/components/Dialog.ts b/src/tui/components/Dialog.ts index cda32c6..a4ddd8d 100644 --- a/src/tui/components/Dialog.ts +++ b/src/tui/components/Dialog.ts @@ -69,6 +69,7 @@ export class Dialog { wrapMode: "none", }), ); + if (options.description) { this.popup.add( new TextRenderable(renderer, { @@ -81,6 +82,7 @@ export class Dialog { }), ); } + this.body = new BoxRenderable(renderer, { id: `${options.id}-body`, flexDirection: "column", @@ -96,6 +98,7 @@ export class Dialog { this.resizeHandler = () => { if (this.visible) this.measure(); }; + renderer.keyInput.prependListener("keypress", this.keyHandler); renderer.on(CliRenderEvents.RESIZE, this.resizeHandler); } @@ -106,8 +109,10 @@ export class Dialog { static handleTopmostKey(key: KeyEvent): boolean { const dialog = Dialog.stack.at(-1); + if (!dialog) return false; dialog.handleKey(key); + return true; } @@ -121,6 +126,7 @@ export class Dialog { this.focusables = initial ? [initial, ...targets.filter((target) => target !== initial)] : [...targets]; + if (this.visible) this.liveFocusables()[0]?.focus(); } @@ -135,6 +141,7 @@ export class Dialog { hide(restoreFocus = true): void { this.root.visible = false; Dialog.stack = Dialog.stack.filter((dialog) => dialog !== this); + if (restoreFocus) this.previousFocus?.focus(); } @@ -152,13 +159,17 @@ export class Dialog { private handleKey(key: KeyEvent): void { if (!this.visible || Dialog.stack.at(-1) !== this) return; + if (key.name === "escape") { key.preventDefault(); key.stopPropagation(); this.dismiss(); + return; } + const focusables = this.liveFocusables(); + if (key.name !== "tab" || focusables.length === 0) return; key.preventDefault(); key.stopPropagation(); @@ -176,6 +187,7 @@ export class Dialog { current = current.parent ) if (!current.visible) return false; + return true; }); } @@ -185,10 +197,12 @@ export class Dialog { 1, Math.min(this.options.width ?? 58, this.renderer.width - 4), ); + const height = Math.max( 1, Math.min(this.options.height ?? 14, this.renderer.height - 2), ); + this.popup.width = width; this.popup.height = height; this.popup.left = Math.max( diff --git a/src/tui/components/RadioGroup.ts b/src/tui/components/RadioGroup.ts index 4efa65c..bf80fc6 100644 --- a/src/tui/components/RadioGroup.ts +++ b/src/tui/components/RadioGroup.ts @@ -64,7 +64,9 @@ export class RadioGroup extends BoxRenderable { this.focus(); }, }); + this.add(row); + return row; }); this.refresh(); @@ -76,6 +78,7 @@ export class RadioGroup extends BoxRenderable { set value(value: T) { const index = this.choices.findIndex((choice) => choice.value === value); + if (index >= 0) this.select(index, false); } @@ -84,22 +87,29 @@ export class RadioGroup extends BoxRenderable { this.select( (this.selectedIndex - 1 + this.choices.length) % this.choices.length, ); + return true; } + if (key.name === "down" || key.name === "right") { this.select((this.selectedIndex + 1) % this.choices.length); + return true; } + if (key.name === "return" || key.name === "space") { this.onActivate?.(this.value); + return true; } + return false; } private select(index: number, emit = true): void { this.selectedIndex = index; this.refresh(); + if (emit) this.onValueChange?.(this.value); } diff --git a/src/tui/components/ScrollSurface.ts b/src/tui/components/ScrollSurface.ts index 2384527..e033092 100644 --- a/src/tui/components/ScrollSurface.ts +++ b/src/tui/components/ScrollSurface.ts @@ -76,18 +76,22 @@ export class ScrollSurface extends BoxRenderable { ): boolean { const handled = this.scrollBox.handleKeyPress(key); this.syncMarker(); + return handled; } syncMarker(): void { const viewport = this.scrollBox.viewport.height; + if (viewport <= 0) return; const extent = Math.max(0, this.scrollBox.scrollHeight - viewport); const offset = Math.max(0, Math.min(this.scrollBox.scrollTop, extent)); const metrics = `${viewport}:${extent}:${offset}`; + if (metrics === this.markerMetrics) return; this.markerMetrics = metrics; const marker = renderMarker(viewport, extent, offset); + if (marker === this.markerText) return; this.markerText = marker; this.marker.content = t`${fg(this.theme.fgSubtle)(marker)}`; @@ -95,6 +99,7 @@ export class ScrollSurface extends BoxRenderable { protected override onUpdate(deltaTime: number): void { super.onUpdate(deltaTime); + if (this.markerMetrics) this.syncMarker(); } } @@ -105,12 +110,16 @@ function renderMarker( offset: number, ): string { if (viewport <= 0) return ""; + if (extent === 0) return " ".repeat(viewport).split("").join("\n"); + const thumb = Math.max( 1, Math.floor((viewport * viewport) / (viewport + extent)), ); + const start = Math.round((offset / extent) * Math.max(0, viewport - thumb)); + return Array.from({ length: viewport }, (_, row) => row >= start && row < start + thumb ? "┃" : "│", ).join("\n"); diff --git a/src/tui/externalEditor.ts b/src/tui/externalEditor.ts index 8c2d478..079a9ad 100644 --- a/src/tui/externalEditor.ts +++ b/src/tui/externalEditor.ts @@ -22,6 +22,7 @@ export async function openPathInEditor( stdout: "ignore", stderr: "ignore", }); + return; } diff --git a/src/tui/openCodeSession.ts b/src/tui/openCodeSession.ts index 3205330..b23d089 100644 --- a/src/tui/openCodeSession.ts +++ b/src/tui/openCodeSession.ts @@ -44,6 +44,8 @@ export function openCodeSessionLabel(mode: OpenCodeSessionMode): string { function openCodeArgs(options: OpenCodeSessionOptions): string[] { const args = options.mode === "plan" ? ["opencode", "--agent", "plan"] : ["opencode"]; + if (options.prompt !== undefined) args.push("--prompt", options.prompt); + return args; } diff --git a/tests/capture/run.test.ts b/tests/capture/run.test.ts index 61aa832..e89b602 100644 --- a/tests/capture/run.test.ts +++ b/tests/capture/run.test.ts @@ -12,6 +12,7 @@ import { tmpdir } from "node:os"; import { captureStatus, processLocalCapture } from "../../src/capture/run.js"; const roots: string[] = []; + afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); @@ -24,6 +25,7 @@ describe("local capture", () => { { available: true }, ); expect(existsSync(join(root, "prompt"))).toBe(false); + const result = await Effect.runPromise( processLocalCapture(configPath, { version: 1, @@ -34,6 +36,7 @@ describe("local capture", () => { repository: "owner/repository", }), ); + expect(result).toEqual({ status: "success", requestId: "019c92df-71d2-7fb0-8c2e-d29f633a355b", @@ -54,6 +57,7 @@ describe("local capture", () => { test("rejects invalid input before starting OpenCode", async () => { const { root, configPath } = writeConfig(); + const result = await Effect.runPromiseExit( processLocalCapture(configPath, { version: 1, @@ -63,6 +67,7 @@ describe("local capture", () => { source: "text", }), ); + expect(result._tag).toBe("Failure"); expect(existsSync(join(root, "prompt"))).toBe(false); }); @@ -106,5 +111,6 @@ function writeConfig() { "pollIntervalSeconds: 10", ].join("\n"), ); + return { root, configPath }; } diff --git a/tests/daemon/coordinator.test.ts b/tests/daemon/coordinator.test.ts index 8f870b7..79b0939 100644 --- a/tests/daemon/coordinator.test.ts +++ b/tests/daemon/coordinator.test.ts @@ -32,6 +32,7 @@ describe("runProcessingPass", () => { test("comments, closes, and releases a claimed issue", async () => { let current = issue(); const released: string[] = []; + const layer = Layer.mergeAll( Layer.succeed(IssueQueue, { list: () => Effect.succeed([current]), @@ -63,6 +64,7 @@ describe("runProcessingPass", () => { const result = await Effect.runPromise( runProcessingPass("agent:ready", "worker").pipe(Effect.provide(layer)), ); + expect(result).toEqual({ observed: 1, completed: 1, @@ -76,6 +78,7 @@ describe("runProcessingPass", () => { test("skips when another daemon owns the issue", async () => { const current = issue(); + const layer = Layer.mergeAll( Layer.succeed(IssueQueue, { list: () => Effect.succeed([current]), @@ -95,6 +98,7 @@ describe("runProcessingPass", () => { const result = await Effect.runPromise( runProcessingPass("agent:ready", "worker").pipe(Effect.provide(layer)), ); + expect(result).toEqual({ observed: 1, completed: 0, @@ -107,7 +111,9 @@ describe("runProcessingPass", () => { let current = issue([ { author: "worker", body: `${COMPLETION_MARKER}\n\nAlready done` }, ]); + let opencodeCalls = 0; + const layer = Layer.mergeAll( Layer.succeed(IssueQueue, { list: () => Effect.succeed([current]), @@ -126,6 +132,7 @@ describe("runProcessingPass", () => { process: () => Effect.sync(() => { opencodeCalls += 1; + return "unexpected"; }), }), @@ -134,6 +141,7 @@ describe("runProcessingPass", () => { const result = await Effect.runPromise( runProcessingPass("agent:ready", "worker").pipe(Effect.provide(layer)), ); + expect(result.completed).toBe(1); expect(current.state).toBe("closed"); expect(opencodeCalls).toBe(0); @@ -142,6 +150,7 @@ describe("runProcessingPass", () => { test("does not close an issue dequeued after the result comment", async () => { let current = issue(); let closeCalls = 0; + const layer = Layer.mergeAll( Layer.succeed(IssueQueue, { list: () => Effect.succeed([current]), @@ -171,12 +180,14 @@ describe("runProcessingPass", () => { const result = await Effect.runPromise( runProcessingPass("agent:ready", "worker").pipe(Effect.provide(layer)), ); + expect(result.skipped).toBe(1); expect(closeCalls).toBe(0); }); test("escalates release failure to pass supervision", async () => { const issues = [issue(), { ...issue(), number: 2 }]; + const layer = Layer.mergeAll( Layer.succeed(IssueQueue, { list: () => Effect.succeed(issues), @@ -211,6 +222,7 @@ describe("runProcessingPass", () => { runProcessingPass("agent:ready", "worker").pipe(Effect.provide(layer)), ), ); + expect(result._tag).toBe("Failure"); }); @@ -219,6 +231,7 @@ describe("runProcessingPass", () => { const errors: unknown[][] = []; const originalError = console.error; console.error = (...args: unknown[]) => errors.push(args); + const layer = Layer.mergeAll( Layer.succeed(IssueQueue, { list: () => Effect.succeed([current]), @@ -251,6 +264,7 @@ describe("runProcessingPass", () => { const first = await Effect.runPromise( runProcessingPass("agent:ready", "worker").pipe(Effect.provide(layer)), ); + const second = await Effect.runPromise( runProcessingPass("agent:ready", "worker").pipe(Effect.provide(layer)), ); @@ -273,6 +287,7 @@ describe("runProcessingPass", () => { test("sanitizes and bounds a known processing error", async () => { let current = issue(); + const layer = Layer.mergeAll( Layer.succeed(IssueQueue, { list: () => Effect.succeed([current]), @@ -311,6 +326,7 @@ describe("runProcessingPass", () => { const originalError = console.error; console.error = () => {}; + try { await Effect.runPromise( runProcessingPass("agent:ready", "worker").pipe(Effect.provide(layer)), @@ -338,6 +354,7 @@ describe("runProcessingPass", () => { let current = issue(); const unexpected = new Error("private implementation detail"); const errors: unknown[][] = []; + const layer = Layer.mergeAll( Layer.succeed(IssueQueue, { list: () => Effect.succeed([current]), @@ -364,6 +381,7 @@ describe("runProcessingPass", () => { const originalError = console.error; console.error = (...args: unknown[]) => errors.push(args); + try { await Effect.runPromise( runProcessingPass("agent:ready", "worker").pipe(Effect.provide(layer)), diff --git a/tests/daemon/schema.test.ts b/tests/daemon/schema.test.ts index 1331590..34450ff 100644 --- a/tests/daemon/schema.test.ts +++ b/tests/daemon/schema.test.ts @@ -35,6 +35,7 @@ describe("daemon schema", () => { consecutiveFailureLimit: 3, pollIntervalSeconds: 30, }); + expect(config.repository).toBe("owner/repo"); expect(config.opencodeCommand).toBe("processor"); expect(config.opencodeArgs).toEqual(["--limit", "5m", "--"]); @@ -89,6 +90,7 @@ describe("daemon schema", () => { labels: ["agent:ready"], comments: [{ author: "daemon", body: COMPLETION_MARKER }], }); + expect(issueIsComplete(issue, "daemon")).toBe(true); expect(issueIsComplete(issue, "someone-else")).toBe(false); }); @@ -102,6 +104,7 @@ describe("daemon schema", () => { labels: ["agent:ready"], comments: [{ author: "daemon", body: "" }], }); + expect(issueHasFailure(issue, "daemon")).toBe(true); expect(issueHasFailure(issue, "someone-else")).toBe(false); }); @@ -115,6 +118,7 @@ describe("daemon schema", () => { labels: ["agent:ready"], comments: [], }); + const prompt = issuePrompt(issue.body); expect(prompt.match(/<\/captured-note-base64>/g)).toHaveLength(1); expect(prompt).not.toContain(""); diff --git a/tests/daemon/services/IssueQueue.test.ts b/tests/daemon/services/IssueQueue.test.ts index 577ee40..5ab864b 100644 --- a/tests/daemon/services/IssueQueue.test.ts +++ b/tests/daemon/services/IssueQueue.test.ts @@ -6,6 +6,7 @@ import { Exit, Fiber, Layer, + Match, PlatformError, Sink, Stream, @@ -33,6 +34,7 @@ const config = DaemonConfig.make({ consecutiveFailureLimit: 3, pollIntervalSeconds: 30, }); + const issue = { number: 42, title: "Captured note", @@ -41,7 +43,9 @@ const issue = { labels: [{ name: "agent:ready" }], comments: [{ author: { login: "worker" }, body: "Saved note" }], }; + const fields = "number,title,body,state,labels,comments"; + const text = (value: string) => Stream.succeed(new TextEncoder().encode(value)); const fixture = Effect.fn("test.issueQueueFixture")(function* ( @@ -52,14 +56,16 @@ const fixture = Effect.fn("test.issueQueueFixture")(function* ( const commands: ChildProcess.StandardCommand[] = []; const spawned = yield* Deferred.make(); let releases = 0; + const spawner = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => Effect.acquireRelease( Effect.sync(() => { - if (command._tag !== "StandardCommand") + if (!ChildProcess.isStandardCommand(command)) throw new Error("Expected a standard command"); commands.push(command); + return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), @@ -79,6 +85,7 @@ const fixture = Effect.fn("test.issueQueueFixture")(function* ( ), ), ); + const queue = yield* IssueQueue.pipe( Effect.provide( IssueQueue.layer(config).pipe( @@ -86,6 +93,7 @@ const fixture = Effect.fn("test.issueQueueFixture")(function* ( ), ), ); + return { queue, commands, spawned, releases: () => releases }; }); @@ -96,6 +104,7 @@ describe("IssueQueue SDK boundary", () => { const fake = yield* fixture((args) => ({ stdout: text(JSON.stringify(args[1] === "list" ? [issue] : issue)), })); + const issues = yield* fake.queue.list(); expect(issues).toEqual([ { @@ -123,6 +132,7 @@ describe("IssueQueue SDK boundary", () => { ], ["issue", "view", "42", "--repo", "owner/repo", "--json", fields], ]); + for (const command of fake.commands) { expect(command.command).toBe("gh"); expect(command.options).toMatchObject({ @@ -134,6 +144,7 @@ describe("IssueQueue SDK boundary", () => { }); expect(command.options.env).not.toHaveProperty("GH_TOKEN"); } + expect(fake.releases()).toBe(2); }), ); @@ -151,6 +162,7 @@ describe("IssueQueue SDK boundary", () => { ), ), })); + expect(yield* fake.queue.list()).toEqual([]); expect(yield* fake.queue.get(42)).toMatchObject({ state: "closed", @@ -179,11 +191,13 @@ describe("IssueQueue SDK boundary", () => { await Effect.runPromise( Effect.gen(function* () { const fake = yield* fixture(() => ({ stdout: text(stdout) })); + const error = yield* ( operation === "list" ? fake.queue.list().pipe(Effect.asVoid) : fake.queue.get(42).pipe(Effect.asVoid) ).pipe(Effect.flip); + expect(error).toBeInstanceOf(IssueQueueError); expect(error.operation).toBe(operation + suffix); expect(error.message.length).toBeGreaterThan(0); @@ -201,8 +215,10 @@ describe("IssueQueue SDK boundary", () => { Effect.gen(function* () { let label = ""; const otherLabel = "agent:processing:other:12345678"; + const fake = yield* fixture((args) => { if (args[0] === "label" && args[1] === "create") label = args[2]; + return args[1] === "view" ? { stdout: text( @@ -222,6 +238,7 @@ describe("IssueQueue SDK boundary", () => { } : {}; }); + const claimed = yield* fake.queue.claim(42); expect(label).toMatch(/^agent:processing:desktop:[a-f0-9]{8}$/); expect(claimed).toBe(competing ? null : label); @@ -271,6 +288,7 @@ describe("IssueQueue SDK boundary", () => { }), ), })); + expect(yield* fake.queue.claim(42)).toBeNull(); expect(fake.commands).toHaveLength(1); }), @@ -321,6 +339,7 @@ describe("IssueQueue SDK boundary", () => { ChildProcessSpawner.ExitCode(args[1] === step ? 1 : 0), ), })); + const error = yield* fake.queue.claim(42).pipe(Effect.flip); expect(error).toBeInstanceOf(IssueQueueError); expect(error.operation).toBe("claim"); @@ -344,6 +363,7 @@ describe("IssueQueue SDK boundary", () => { ChildProcessSpawner.ExitCode(args[1] === step ? 7 : 0), ), })); + const error = yield* fake.queue.complete(42).pipe(Effect.flip); expect(error).toBeInstanceOf(IssueQueueError); expect(error.operation).toBe("complete"); @@ -367,17 +387,21 @@ describe("IssueQueue SDK boundary", () => { PlatformError.systemError({ module: "ChildProcess", method: "read", + // Effect's systemError constructor requires this tag in its options. + // oxlint-disable-next-line anti-slop-effect/no-manual-tagged-construction _tag: "Unknown", }), ), })); - const error = yield* ( - operation === "list" - ? fake.queue.list() - : operation === "comment" - ? fake.queue.comment(42, "saved") - : fake.queue.release("claim") - ).pipe(Effect.flip); + + const error = yield* Match.value(operation).pipe( + Match.when("list", () => fake.queue.list()), + Match.when("comment", () => fake.queue.comment(42, "saved")), + Match.when("release", () => fake.queue.release("claim")), + Match.exhaustive, + Effect.flip, + ); + expect(error).toBeInstanceOf(IssueQueueError); expect(error.operation).toBe(operation); expect(error.message).toBe("GhPlatformError"); @@ -397,13 +421,16 @@ describe("IssueQueue SDK boundary", () => { stdout: Stream.never, exitCode: Effect.never, })); - const fiber = yield* ( - operation === "get" - ? fake.queue.get(42) - : operation === "comment" - ? fake.queue.comment(42, "saved") - : fake.queue.complete(42) - ).pipe(Effect.flip, Effect.forkChild); + + const fiber = yield* Match.value(operation).pipe( + Match.when("get", () => fake.queue.get(42)), + Match.when("comment", () => fake.queue.comment(42, "saved")), + Match.when("complete", () => fake.queue.complete(42)), + Match.exhaustive, + Effect.flip, + Effect.forkChild, + ); + yield* Deferred.await(fake.spawned); yield* TestClock.adjust("5 seconds"); const error = yield* Fiber.join(fiber); @@ -424,6 +451,7 @@ describe("IssueQueue SDK boundary", () => { stdout: Stream.never, exitCode: Effect.never, })); + const fiber = yield* fake.queue.complete(42).pipe(Effect.forkChild); yield* Deferred.await(fake.spawned); yield* Fiber.interrupt(fiber); diff --git a/tests/daemon/services/OpenCodeClient.test.ts b/tests/daemon/services/OpenCodeClient.test.ts index 3335509..f064197 100644 --- a/tests/daemon/services/OpenCodeClient.test.ts +++ b/tests/daemon/services/OpenCodeClient.test.ts @@ -23,8 +23,10 @@ const config = DaemonConfig.make({ consecutiveFailureLimit: 3, pollIntervalSeconds: 30, }); + const textEvent = (text: string, messageID = "msg_2") => JSON.stringify({ type: "text", part: { messageID, text } }) + "\n"; + const output = (value: string) => Stream.succeed(new TextEncoder().encode(value)); @@ -35,14 +37,16 @@ const fixture = Effect.fn("test.openCodeFixture")(function* ( const commands: ChildProcess.StandardCommand[] = []; const spawned = yield* Deferred.make(); let releases = 0; + const spawner = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => Effect.acquireRelease( Effect.sync(() => { - if (command._tag !== "StandardCommand") + if (!ChildProcess.isStandardCommand(command)) throw new Error("Expected a standard command"); commands.push(command); + return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), @@ -62,6 +66,7 @@ const fixture = Effect.fn("test.openCodeFixture")(function* ( ), ), ); + const client = yield* OpenCodeClient.pipe( Effect.provide( OpenCodeClient.layer({ ...config, ...overrides }).pipe( @@ -69,6 +74,7 @@ const fixture = Effect.fn("test.openCodeFixture")(function* ( ), ), ); + return { client, commands, spawned, releases: () => releases }; }); @@ -86,6 +92,7 @@ describe("OpenCodeClient command boundary", () => { opencodeArgs: ["--limit", "two words", "--"], }, ); + expect( yield* fake.client.process("--prompt 'quoted'\n$(literal)"), ).toBe("Saved note abc123"); @@ -133,6 +140,7 @@ describe("OpenCodeClient command boundary", () => { textEvent("abc123") + textEvent("older reconciliation", "msg_1"), ); + await Effect.runPromise( Effect.gen(function* () { const fake = yield* fixture(() => ({ @@ -140,6 +148,7 @@ describe("OpenCodeClient command boundary", () => { Array.from(encoded, (byte) => Uint8Array.of(byte)), ), })); + expect(yield* fake.client.process("prompt")).toBe("Saved café abc123"); expect(fake.commands[0]?.command).toBe("opencode2"); }), @@ -172,6 +181,7 @@ describe("OpenCodeClient command boundary", () => { } : {}, ); + expect(yield* fake.client.process("prompt")).toBe( "Saved note abc123", ); @@ -188,6 +198,7 @@ describe("OpenCodeClient command boundary", () => { const fake = yield* fixture(() => ({ stdout: output(textEvent("x".repeat(20_001))), })); + const error = yield* fake.client.process("prompt").pipe(Effect.flip); expect(error.operation).toBe("process.models"); expect(error.message).toContain("provider/primary, other/fallback#low"); @@ -203,9 +214,11 @@ describe("OpenCodeClient command boundary", () => { const fake = yield* fixture((attempt) => attempt === 1 ? { stdout: Stream.never, exitCode: Effect.never } : {}, ); + const fiber = yield* fake.client .process("prompt") .pipe(Effect.forkChild); + yield* Deferred.await(fake.spawned); yield* TestClock.adjust("30 seconds"); expect(yield* Fiber.join(fiber)).toBe("Saved note abc123"); @@ -222,9 +235,11 @@ describe("OpenCodeClient command boundary", () => { stdout: Stream.never, exitCode: Effect.never, })); + const fiber = yield* fake.client .process("prompt") .pipe(Effect.forkChild); + yield* Deferred.await(fake.spawned); yield* Fiber.interrupt(fiber); expect(Exit.hasInterrupts(yield* Fiber.await(fiber))).toBe(true); @@ -240,11 +255,14 @@ describe("OpenCodeClient command boundary", () => { const available = yield* fixture(() => ({}), { opencodeCommand: process.execPath, }); + yield* available.client.status; expect(available.commands).toHaveLength(0); + const missing = yield* fixture(() => ({}), { opencodeCommand: "/nonexistent/notes-processor", }); + expect((yield* missing.client.status.pipe(Effect.flip)).operation).toBe( "command.status", ); diff --git a/tests/git/committer.test.ts b/tests/git/committer.test.ts index 88061e3..1aceaa1 100644 --- a/tests/git/committer.test.ts +++ b/tests/git/committer.test.ts @@ -14,6 +14,7 @@ const temporaryDirectories: string[] = []; function git(cwd: string, ...args: string[]): void { const result = Bun.spawnSync(["git", ...args], { cwd }); + if (result.exitCode !== 0) throw new Error(result.stderr.toString()); } @@ -23,12 +24,15 @@ function temporaryRepository(): string { git(directory, "init"); git(directory, "config", "user.name", "Notes Test"); git(directory, "config", "user.email", "notes@example.invalid"); + return directory; } function gitOutput(cwd: string, ...args: string[]): string { const result = Bun.spawnSync(["git", ...args], { cwd }); + if (result.exitCode !== 0) throw new Error(result.stderr.toString()); + return result.stdout.toString().trim(); } @@ -36,6 +40,7 @@ function temporaryBareRepository(): string { const directory = mkdtempSync(join(tmpdir(), "notes-git-remote-")); temporaryDirectories.push(directory); git(directory, "init", "--bare"); + return directory; } @@ -49,18 +54,22 @@ describe("preflightMutation", () => { const directory = temporaryRepository(); writeFileSync(join(directory, "staged.txt"), "unfinished"); git(directory, "add", "staged.txt"); + const result = await Effect.runPromise( preflightMutation(directory).pipe(Effect.provide(CommandExecutor.layer)), ); + expect(result.ok).toBeFalse(); expect(result.error).toContain("staged changes"); }); test("allows a repository with an empty index", async () => { const directory = temporaryRepository(); + const result = await Effect.runPromise( preflightMutation(directory).pipe(Effect.provide(CommandExecutor.layer)), ); + expect(result.ok).toBeTrue(); }); @@ -70,21 +79,25 @@ describe("preflightMutation", () => { git(directory, "add", "tracked.txt"); git(directory, "commit", "-m", "Initial commit"); git(directory, "checkout", "--detach"); + const result = await Effect.runPromise( preflightMutation(directory).pipe(Effect.provide(CommandExecutor.layer)), ); + expect(result.ok).toBeFalse(); expect(result.error).toContain("detached HEAD"); }); test("refuses an in-progress Git operation", async () => { const directory = temporaryRepository(); + const marker = gitOutput( directory, "rev-parse", "--git-path", "MERGE_HEAD", ); + writeFileSync(join(directory, marker), "0".repeat(40)); const result = await Effect.runPromise( diff --git a/tests/mcp/tools/notes.test.ts b/tests/mcp/tools/notes.test.ts index 0be5850..57e2e5c 100644 --- a/tests/mcp/tools/notes.test.ts +++ b/tests/mcp/tools/notes.test.ts @@ -19,6 +19,7 @@ import { CommandExecutor } from "../../../src/services/CommandExecutor.js"; import { Config } from "../../../src/services/Config.js"; const temporaryDirectories: string[] = []; + const identity = { source: "remote" as const, owner: "timmo001", @@ -26,6 +27,7 @@ const identity = { remote: "origin", remoteUrl: "git@github.com:timmo001/notes.git", }; + const client = McpSchema.McpServerClient.of({ clientId: 1, protocolVersion: "2025-03-26", @@ -43,6 +45,7 @@ type ToolArgument = string | number | boolean | null; function git(cwd: string, ...args: string[]): void { const result = Bun.spawnSync(["git", ...args], { cwd }); + if (result.exitCode !== 0) throw new Error(result.stderr.toString()); } @@ -61,6 +64,7 @@ function fixture() { ); git(root, "add", "."); git(root, "commit", "-m", "Initial note"); + return { root, notesPath, path }; } @@ -87,9 +91,11 @@ async function callTool( Effect.sync(() => notifications.push(`${title}: ${message}`)), }), ); + return Effect.runPromise( Effect.gen(function* () { yield* registerNotesTools; + return yield* (yield* McpServer.McpServer) .callTool({ name, arguments: args }) .pipe(Effect.provideService(McpSchema.McpServerClient, client)); @@ -138,6 +144,7 @@ describe("notes MCP tools", () => { const { root, path } = fixture(); const notifications: string[] = []; const read = await callTool(root, "note_read", { path }); + const { content, hash } = Schema.decodeUnknownSync( Schema.Struct({ content: Schema.String, hash: Schema.String }), )(JSON.parse(resultText(read))); @@ -162,6 +169,7 @@ describe("notes MCP tools", () => { test("note_write adds a date when frontmatter omits it", async () => { const { root, notesPath } = fixture(); const path = join(notesPath, "without-date.md"); + const content = `--- repo: timmo001/notes name: Without Date @@ -183,11 +191,13 @@ tags: [test] test("note_write rejects malformed and stale revision hashes", async () => { const { root, path } = fixture(); + const malformed = await callTool(root, "note_write", { path, content: readFileSync(path, "utf8"), expectedHash: "invalid", }); + const stale = await callTool(root, "note_write", { path, content: readFileSync(path, "utf8"), diff --git a/tests/notes/activeCount.test.ts b/tests/notes/activeCount.test.ts index db75d39..22d8c3f 100644 --- a/tests/notes/activeCount.test.ts +++ b/tests/notes/activeCount.test.ts @@ -12,10 +12,12 @@ import { Config } from "../../src/services/Config.js"; import { herdrFixture } from "../support/herdr.js"; const directories: string[] = []; + const servers: Awaited>[] = []; afterEach(async () => { for (const server of servers.splice(0)) await server.close(); + for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }); }); @@ -32,9 +34,11 @@ test.each([ servers.push(server); const notesDir = mkdtempSync(join(tmpdir(), "notes-active-count-")); directories.push(notesDir); + for (const repo of ["active", "other"]) { const directory = join(notesDir, "projects/example", repo); mkdirSync(directory, { recursive: true }); + for (const kind of ["note", "handoff"] as const) writeFileSync( join(directory, `${kind}.md`), @@ -47,6 +51,7 @@ test.each([ ), ); } + const result = await Effect.runPromise( activeNoteCount().pipe( Effect.provide(Notes.layer), @@ -62,8 +67,11 @@ test.each([ run: (command, args, input) => { expect(command).toBe("git"); expect(input?.cwd).toBe(options.cwd); + if (args[0] === "rev-parse") return Effect.succeed("/repos/active"); + if (args.length === 1) return Effect.succeed("origin"); + return Effect.succeed("https://github.com/example/active.git"); }, exitCode: () => Effect.die("Unexpected command"), @@ -71,6 +79,7 @@ test.each([ ), ), ); + expect(result).toMatchObject({ workspaceId: herdrIds.workspace("w1"), paneId: herdrIds.pane("w1:p2"), @@ -90,6 +99,7 @@ test.each([ ])("returns no count without a focused pane directory: %j", async (options) => { const server = await herdrFixture(options); servers.push(server); + const result = await Effect.runPromise( activeNoteCount().pipe( Effect.provide(server.layer), @@ -107,5 +117,6 @@ test.each([ ), ), ); + expect(result).toBeNull(); }); diff --git a/tests/notes/agentTargets.test.ts b/tests/notes/agentTargets.test.ts index 9b03fd3..7b3ffc2 100644 --- a/tests/notes/agentTargets.test.ts +++ b/tests/notes/agentTargets.test.ts @@ -15,7 +15,9 @@ import { CommandExecutor } from "../../src/services/CommandExecutor.js"; import { herdrFixture } from "../support/herdr.js"; const temporaryDirectories: string[] = []; + const fixtures: Awaited>[] = []; + const entry: NoteEntry = { filename: "work.md", filePath: "/vault/projects/example/notes/work.md", @@ -27,19 +29,23 @@ const entry: NoteEntry = { priority: "high", mtime: 0, }; + const cursor = { command: "cursor", executable: "cursor-agent", label: "Cursor Agent", }; + const opencode2 = { command: "opencode2", executable: "/home/aidan/.local/bin/opencode2", label: "OpenCode 2", }; + const executor = CommandExecutor.of({ run: (command, args) => { expect([command, ...args]).toEqual(["mise", "which", "opencode2"]); + return Effect.succeed("/opt/opencode2\n"); }, exitCode: () => Effect.die("Unexpected subprocess"), @@ -48,11 +54,13 @@ const executor = CommandExecutor.of({ async function fixture(options?: Parameters[0]) { const server = await herdrFixture(options); fixtures.push(server); + return server; } afterEach(async () => { for (const server of fixtures.splice(0)) await server.close(); + for (const directory of temporaryDirectories.splice(0)) rmSync(directory, { recursive: true, force: true }); }); @@ -60,9 +68,11 @@ afterEach(async () => { describe("agent targets", () => { test("preserves installed target order, labels and executable overrides", async () => { const server = await fixture(); + const targets = await Effect.runPromise( detectAgentTargets(() => true).pipe(Effect.provide(server.layer)), ); + expect(targets).toEqual([ opencode2, { command: "opencode", executable: "opencode", label: "OpenCode 1" }, @@ -83,11 +93,13 @@ describe("agent targets", () => { writeFileSync(executable, "#!/bin/sh\n"); chmodSync(executable, 0o644); const server = await fixture(); + const targets = await Effect.runPromise( detectAgentTargets(() => isRegularExecutable(executable)).pipe( Effect.provide(server.layer), ), ); + expect(targets.map(({ command }) => command)).not.toContain("opencode2"); }); @@ -137,12 +149,14 @@ describe("agent targets", () => { workspaceLabel: "NOTES", detectionFailures: 1, }); + const result = await Effect.runPromise( openNoteAgent(entry, "# Full body", cursor).pipe( Effect.provide(server.layer), Effect.provideService(CommandExecutor, executor), ), ); + expect(result).toMatchObject({ workspaceId: "w1", tabId: "w1:t2", @@ -179,9 +193,11 @@ describe("agent targets", () => { expect( server.requests.find(({ method }) => method === "agent.wait")?.params, ).toEqual({ target: "w1:p2", timeout_ms: 30_000 }); + const prompt = server.requests.find( ({ method }) => method === "agent.prompt", )?.params; + expect(prompt).toMatchObject({ target: "w1:p2", wait: { timeout_ms: 120_000 }, diff --git a/tests/notes/files.test.ts b/tests/notes/files.test.ts index 6fcbaac..e2e5c09 100644 --- a/tests/notes/files.test.ts +++ b/tests/notes/files.test.ts @@ -21,6 +21,7 @@ const temporaryDirectories: string[] = []; function temporaryVault() { const root = mkdtempSync(join(tmpdir(), "notes-files-")); temporaryDirectories.push(root); + return { root, projects: join(root, "projects") }; } @@ -42,6 +43,7 @@ describe("note files", () => { test("creates unique draft names without overwriting", () => { const { projects } = temporaryVault(); + const first = createExclusiveNoteFile( projects, "owner", @@ -49,6 +51,7 @@ describe("note files", () => { "draft", "first", ); + const second = createExclusiveNoteFile( projects, "owner", @@ -56,6 +59,7 @@ describe("note files", () => { "draft", "second", ); + expect(first).toEndWith("draft.md"); expect(second).toEndWith("draft-2.md"); expect(readNoteFile(projects, first).content).toBe("first"); diff --git a/tests/notes/frontmatter.test.ts b/tests/notes/frontmatter.test.ts index 8879483..0937ca6 100644 --- a/tests/notes/frontmatter.test.ts +++ b/tests/notes/frontmatter.test.ts @@ -23,6 +23,7 @@ describe("note frontmatter", () => { "Review: paths #1", "Quotes ' and \" plus: values # stay data", ); + expect(readFrontmatter(content)).toEqual({ name: "Review: paths #1", description: "Quotes ' and \" plus: values # stay data", @@ -39,6 +40,7 @@ describe("note frontmatter", () => { "Handoff", "Description", ); + const updated = setFrontmatterField(content, "date", "new"); expect(updated).toContain("date: new"); expect(updated.slice(updated.indexOf("# Handoff"))).toBe( diff --git a/tests/notes/processLock.test.ts b/tests/notes/processLock.test.ts index d017ca4..b2a5291 100644 --- a/tests/notes/processLock.test.ts +++ b/tests/notes/processLock.test.ts @@ -17,6 +17,7 @@ describe("acquireVaultLock", () => { temporaryDirectories.push(root); const release = await acquireVaultLock(root); const startedAt = Date.now(); + const child = Bun.spawn( [ "bun", @@ -25,6 +26,7 @@ describe("acquireVaultLock", () => { ], { stdout: "ignore", stderr: "pipe" }, ); + await Bun.sleep(150); expect(child.exitCode).toBeNull(); await release(); diff --git a/tests/notes/services/Notes.test.ts b/tests/notes/services/Notes.test.ts index 8bcb65c..4494bb7 100644 --- a/tests/notes/services/Notes.test.ts +++ b/tests/notes/services/Notes.test.ts @@ -18,6 +18,7 @@ import { CommandExecutor } from "../../../src/services/CommandExecutor.js"; import { Config } from "../../../src/services/Config.js"; const temporaryDirectories: string[] = []; + const identity = { source: "remote" as const, owner: "timmo001", @@ -28,6 +29,7 @@ const identity = { function git(cwd: string, ...args: string[]): void { const result = Bun.spawnSync(["git", ...args], { cwd }); + if (result.exitCode !== 0) throw new Error(result.stderr.toString()); } @@ -51,6 +53,7 @@ function fixture(parent = tmpdir()) { ); git(root, "add", "."); git(root, "commit", "-m", "Initial note"); + const layer = Notes.layer.pipe( Layer.provideMerge(CommandExecutor.layer), Layer.provideMerge( @@ -61,6 +64,7 @@ function fixture(parent = tmpdir()) { }), ), ); + return { root, path, layer }; } @@ -145,12 +149,14 @@ describe("Notes service", () => { const { root } = fixture(); const projectDir = mkdtempSync(join(tmpdir(), "local-directory-")); temporaryDirectories.push(projectDir); + const localNotesPath = join( root, "projects", "local", basename(projectDir), ); + mkdirSync(localNotesPath, { recursive: true }); writeFileSync( join(localNotesPath, "local.md"), @@ -279,12 +285,14 @@ describe("Notes service", () => { const projectDir = mkdtempSync(join(tmpdir(), "local-directory-")); temporaryDirectories.push(projectDir); const repoSlug = `local/${basename(projectDir)}`; + const localNotesPath = join( root, "projects", "local", basename(projectDir), ); + mkdirSync(localNotesPath, { recursive: true }); writeFileSync( join(localNotesPath, "local.md"), @@ -304,6 +312,7 @@ describe("Notes service", () => { ); expect(scope).toMatchObject({ scope: "all", repoSlug }); + if (scope.scope !== "all") throw new Error("Expected all-repository scope"); expect(scope.sections.map((section) => section.repoSlug)).toEqual([ repoSlug, @@ -335,11 +344,13 @@ describe("Notes service", () => { test("retains known project directories when listing all repositories", async () => { const { layer } = fixture(); + const currentScope = await Effect.runPromise( Effect.gen(function* () { return yield* (yield* Notes).tuiScope(); }).pipe(Effect.provide(layer)), ); + if (currentScope.scope !== "current") throw new Error("Expected current repository scope"); @@ -430,6 +441,7 @@ describe("Notes service", () => { }); }).pipe(Effect.provide(layer)), ); + const ordinary = await Effect.runPromise( Effect.gen(function* () { return yield* (yield* Notes).contextPayload({ @@ -478,6 +490,7 @@ describe("Notes service", () => { ); }).pipe(Effect.provide(layer)), ); + const content = readFileSync(result.draft.entry.filePath, "utf8"); expect(result.draft.entry.filePath).toBe( @@ -541,6 +554,7 @@ describe("Notes service", () => { return yield* (yield* Notes).setPriority(path, "critical"); }).pipe(Effect.provide(layer)), ); + const content = readFileSync(path, "utf8"); expect(result.commit).toMatchObject({ ok: true, committed: true }); @@ -550,11 +564,13 @@ describe("Notes service", () => { test("returns a revision and rejects a stale write", async () => { const { path, layer } = fixture(); + const initial = await Effect.runPromise( Effect.gen(function* () { return yield* (yield* Notes).read(path); }).pipe(Effect.provide(layer)), ); + const updated = initial.content.replace("# Note", "# Updated"); await Effect.runPromise( Effect.gen(function* () { @@ -576,12 +592,15 @@ describe("Notes service", () => { test("accepts a tilde path for guarded writes", async () => { const { path, layer } = fixture(process.env.HOME); + const initial = await Effect.runPromise( Effect.gen(function* () { return yield* (yield* Notes).read(path); }).pipe(Effect.provide(layer)), ); + const homePath = path.replace(process.env.HOME ?? "", "~"); + const result = await Effect.runPromise( Effect.gen(function* () { return yield* (yield* Notes).write( @@ -591,16 +610,19 @@ describe("Notes service", () => { ); }).pipe(Effect.provide(layer)), ); + expect(result.commit).toMatchObject({ ok: true, committed: true }); }); test("refuses staged work before touching a note", async () => { const { root, path, layer } = fixture(); + const before = await Effect.runPromise( Effect.gen(function* () { return yield* (yield* Notes).read(path); }).pipe(Effect.provide(layer)), ); + writeFileSync(join(root, "unfinished.txt"), "unfinished"); git(root, "add", "unfinished.txt"); await expect( @@ -613,11 +635,13 @@ describe("Notes service", () => { }).pipe(Effect.provide(layer)), ), ).rejects.toThrow("not ready for a mutation"); + const after = await Effect.runPromise( Effect.gen(function* () { return yield* (yield* Notes).read(path); }).pipe(Effect.provide(layer)), ); + expect(after.content).toBe(before.content); }); @@ -640,6 +664,7 @@ describe("Notes service", () => { git(root, "config", "user.name", "Notes Test"); git(root, "config", "user.email", "notes@example.invalid"); + const retried = await Effect.runPromise( Effect.gen(function* () { return yield* (yield* Notes).write( @@ -648,6 +673,7 @@ describe("Notes service", () => { ); }).pipe(Effect.provide(layer)), ); + expect(retried.commit).toMatchObject({ ok: true, committed: true }); }); @@ -669,6 +695,7 @@ describe("Notes service", () => { test("allows malformed notes to be repaired in the editor", async () => { const { path, layer } = fixture(); writeFileSync(path, "malformed"); + const result = await Effect.runPromise( Effect.gen(function* () { return yield* (yield* Notes).edit( @@ -682,11 +709,13 @@ describe("Notes service", () => { ); }).pipe(Effect.provide(layer)), ); + expect(result.commit).toMatchObject({ ok: true, committed: true }); }); test("commits a note deleted by the editor", async () => { const { root, path, layer } = fixture(); + const result = await Effect.runPromise( Effect.gen(function* () { return yield* (yield* Notes).edit( @@ -696,12 +725,15 @@ describe("Notes service", () => { ); }).pipe(Effect.provide(layer)), ); + expect(result.commit).toMatchObject({ ok: true, committed: true }); expect(existsSync(path)).toBeFalse(); + const changed = Bun.spawnSync( ["git", "show", "--name-status", "--format=", "HEAD"], { cwd: root }, ).stdout.toString(); + expect(changed).toContain("projects/timmo001/notes/note.md"); }); @@ -714,6 +746,7 @@ describe("Notes service", () => { git(root, "init"); git(root, "config", "user.name", "Notes Test"); git(root, "config", "user.email", "notes@example.invalid"); + const result = await Effect.runPromise( Effect.gen(function* () { return yield* (yield* Notes).write( @@ -722,12 +755,14 @@ describe("Notes service", () => { ); }).pipe(Effect.provide(serviceLayer(root))), ); + expect(result.commit).toMatchObject({ ok: true, committed: true }); }); test("keeps draft creation and editing under one lock", async () => { const { root, layer } = fixture(); let competitor: ReturnType | undefined; + const created = Effect.runPromise( Effect.gen(function* () { return yield* (yield* Notes).create( @@ -749,12 +784,14 @@ describe("Notes service", () => { ); }).pipe(Effect.provide(layer)), ); + await created; expect(await competitor?.exited).toBe(0); }); test("treats deletion of a new draft as a cancelled create", async () => { const { layer } = fixture(); + const result = await Effect.runPromise( Effect.gen(function* () { return yield* (yield* Notes).create( @@ -765,6 +802,7 @@ describe("Notes service", () => { ); }).pipe(Effect.provide(layer)), ); + expect(result.created).toBeFalse(); expect(result.git.commit).toMatchObject({ ok: true, committed: false }); }); diff --git a/tests/notes/tui/NotesDialogs.test.ts b/tests/notes/tui/NotesDialogs.test.ts index 6825636..9349483 100644 --- a/tests/notes/tui/NotesDialogs.test.ts +++ b/tests/notes/tui/NotesDialogs.test.ts @@ -18,12 +18,14 @@ describe("Notes dialogs", () => { const setup = await createTestRenderer({ width: 80, height: 24 }); renderer = setup.renderer; let result: unknown; + const dialog = new CreateNoteDialog( renderer, TEST_THEME, (value) => (result = value), () => {}, ); + dialog.show(false); setup.mockInput.pressArrow("down"); setup.mockInput.pressEnter(); @@ -44,12 +46,14 @@ describe("Notes dialogs", () => { test("direct handoff create focuses the visible name input", async () => { const setup = await createTestRenderer({ width: 80, height: 24 }); renderer = setup.renderer; + const dialog = new CreateNoteDialog( renderer, TEST_THEME, () => {}, () => {}, ); + dialog.show(true); await setup.flush(); expect(renderer.currentFocusedRenderable?.id).toBe("create-note-name"); @@ -65,12 +69,14 @@ describe("Notes dialogs", () => { const setup = await createTestRenderer({ width: 80, height: 24 }); renderer = setup.renderer; let moved = ""; + const dialog = new MoveNoteDialog( renderer, TEST_THEME, (repo) => (moved = repo), () => {}, ); + dialog.show(["owner/one", "owner/two"], "note.md"); setup.mockInput.pressArrow("down"); setup.mockInput.pressEnter(); @@ -84,12 +90,14 @@ describe("Notes dialogs", () => { const setup = await createTestRenderer({ width: 80, height: 24 }); renderer = setup.renderer; let selected = ""; + const dialog = new AgentDialog( renderer, TEST_THEME, (target) => (selected = target.command), () => {}, ); + dialog.show( [ { command: "opencode", executable: "opencode", label: "OpenCode" }, @@ -109,10 +117,12 @@ describe("Notes dialogs", () => { const setup = await createTestRenderer({ width: 80, height: 24 }); renderer = setup.renderer; let selected = ""; + const dialog = new PriorityDialog(renderer, TEST_THEME, { onApply: (value) => (selected = value), onDismiss: () => {}, }); + dialog.show("high", "Note"); setup.mockInput.pressTab(); setup.mockInput.pressEnter(); @@ -124,22 +134,26 @@ describe("Notes dialogs", () => { test("Help groups commands and topmost Escape restores focus", async () => { const setup = await createTestRenderer({ width: 80, height: 24 }); renderer = setup.renderer; + const restoredTarget = new BoxRenderable(renderer, { id: "restore-target", focusable: true, width: 1, height: 1, }); + renderer.root.add(restoredTarget); restoredTarget.focus(); const help = new HelpDialog(renderer, TEST_THEME, () => {}); let deleted = false; + const remove = new DeleteNoteDialog( renderer, TEST_THEME, () => (deleted = true), () => {}, ); + help.show(); remove.show("owner/repo/note.md"); await setup.flush(); diff --git a/tests/notes/tui/NotesView.test.ts b/tests/notes/tui/NotesView.test.ts index 297c5df..ab19147 100644 --- a/tests/notes/tui/NotesView.test.ts +++ b/tests/notes/tui/NotesView.test.ts @@ -71,11 +71,13 @@ describe("NotesView", () => { const setup = await createTestRenderer({ width: 60, height: 20 }); renderer = setup.renderer; let back = 0; + const view = new NotesView( renderer, TEST_THEME, callbacks(() => back++), ); + view.setVisible(true); await setup.flush(); await Promise.resolve(); @@ -107,14 +109,17 @@ describe("NotesView", () => { const setup = await createTestRenderer({ width: 30, height: 10 }); renderer = setup.renderer; let back = 0; + const view = new NotesView( renderer, TEST_THEME, callbacks(() => back++), ); + view.setVisible(true); await settle(setup); expect(renderer.currentFocusedRenderable?.id).toBe("notes-minimum-size"); + for (const key of ["?", "a", "i", "return", "tab", "down"]) emitGlobalKey(renderer, key); await settle(setup); @@ -149,6 +154,7 @@ describe("NotesView", () => { let opened = ""; let openedMode = ""; let back = 0; + const view = new NotesView(renderer, TEST_THEME, { ...callbacks(() => back++), listAgentTargets: async () => [ @@ -160,6 +166,7 @@ describe("NotesView", () => { openedMode = mode; }, }); + view.setVisible(true); await settle(setup); setup.mockInput.pressKey("o"); @@ -227,6 +234,7 @@ async function settle(setup: Awaited>) { await Promise.resolve(); await setup.flush(); } + await setup.waitForVisualIdle({ quietFrames: 2, maxFrames: 100 }); } @@ -238,6 +246,7 @@ async function waitForDocument( await Bun.sleep(5); await setup.flush(); } + throw new Error("Markdown document did not render"); } diff --git a/tests/support/herdr.ts b/tests/support/herdr.ts index 19ed193..16a3df2 100644 --- a/tests/support/herdr.ts +++ b/tests/support/herdr.ts @@ -25,6 +25,7 @@ const workspace = { agent_status: "idle", focused: false, }; + const tab = { tab_id: "w1:t2", workspace_id: "w1", @@ -34,6 +35,7 @@ const tab = { agent_status: "idle", focused: false, }; + const pane = { pane_id: "w1:p2", tab_id: "w1:t2", @@ -63,6 +65,7 @@ export async function herdrFixture( const requests: HerdrRequest[] = []; const sockets = new Set(); let detectionFailures = options.detectionFailures ?? 0; + const server = createServer((socket) => { sockets.add(socket); socket.on("close", () => sockets.delete(socket)); @@ -71,13 +74,16 @@ export async function herdrFixture( socket.on("data", (chunk) => { buffered += chunk; let newline; + while ((newline = buffered.indexOf("\n")) >= 0) { const request = Schema.decodeSync(Request)(buffered.slice(0, newline)); buffered = buffered.slice(newline + 1); requests.push(request); + const fail = request.method === options.failMethod || (request.method === "agent.get" && detectionFailures-- > 0); + socket.write( JSON.stringify( fail @@ -94,6 +100,7 @@ export async function herdrFixture( } }); }); + function response(method: string) { switch (method) { case "ping": @@ -215,10 +222,12 @@ export async function herdrFixture( throw new Error(`Unexpected Herdr method: ${method}`); } } + await new Promise((resolve, reject) => { server.once("error", reject); server.listen(socketPath, resolve); }); + return { socketPath, requests, diff --git a/tests/tui/StatusList.test.ts b/tests/tui/StatusList.test.ts index 8c20a03..2038ff0 100644 --- a/tests/tui/StatusList.test.ts +++ b/tests/tui/StatusList.test.ts @@ -48,6 +48,7 @@ describe("StatusList", () => { renderer.root.add(list); list.setActive(true); await setup.flush(); + for (const [width, height] of [ [80, 24], [60, 20], @@ -70,6 +71,7 @@ describe("StatusList", () => { renderer.root.add(list); list.setActive(true); await setup.flush(); + for (let index = 0; index < 6; index++) list.selectNext(); await setup.flush(); expect(list.getSelectedItem()?.id).toBe("6"); diff --git a/tests/tui/SuspendedCommand.test.ts b/tests/tui/SuspendedCommand.test.ts index 92c2615..a605c83 100644 --- a/tests/tui/SuspendedCommand.test.ts +++ b/tests/tui/SuspendedCommand.test.ts @@ -7,15 +7,19 @@ async function rendererFixture(events: string[]) { renderer.suspend = () => { events.push("suspend"); }; + renderer.currentRenderBuffer.clear = () => { events.push("clear"); }; + renderer.resume = () => { events.push("resume"); }; + renderer.requestRender = () => { events.push("render"); }; + return renderer; } @@ -31,6 +35,7 @@ describe("runWithRendererSuspended", () => { }, async () => { events.push("work"); + return "result"; }, ); diff --git a/tests/tui/TuiPartsCompatibility.test.ts b/tests/tui/TuiPartsCompatibility.test.ts index b28bae7..8be3e17 100644 --- a/tests/tui/TuiPartsCompatibility.test.ts +++ b/tests/tui/TuiPartsCompatibility.test.ts @@ -35,23 +35,29 @@ describe("TUI Parts compatibility with OpenTUI 0.5", () => { before.focus(); const root = new DialogRootRenderable(renderer, { id: "dialog-root" }); + const portal = new DialogPortalRenderable(renderer, { id: "dialog-portal", store: root.store, }); + const backdrop = new DialogBackdropRenderable(renderer, { id: "dialog-backdrop", store: root.store, }); + const popup = new DialogPopupRenderable(renderer, { id: "dialog-popup", store: root.store, }); + const first = new ButtonRenderable(renderer, { id: "dialog-first" }); + const close = new DialogCloseRenderable(renderer, { id: "dialog-close", store: root.store, }); + first.add(new TextRenderable(renderer, { content: "First" })); close.add(new TextRenderable(renderer, { content: "Close" })); popup.add(first); @@ -83,6 +89,7 @@ describe("TUI Parts compatibility with OpenTUI 0.5", () => { const setup = await createTestRenderer({ width: 40, height: 12 }); renderer = setup.renderer; let open = false; + const root = new CollapsibleRootRenderable(renderer, { open, onOpenChange: (next) => { @@ -90,14 +97,17 @@ describe("TUI Parts compatibility with OpenTUI 0.5", () => { root.open = next; }, }); + const trigger = new CollapsibleTriggerRenderable(renderer, { id: "collapsible-trigger", store: root.store, }); + const panel = new CollapsiblePanelRenderable(renderer, { id: "collapsible-panel", store: root.store, }); + trigger.add(new TextRenderable(renderer, { content: "Details" })); panel.add(new TextRenderable(renderer, { content: "Metadata" })); root.add(trigger); @@ -114,10 +124,12 @@ describe("TUI Parts compatibility with OpenTUI 0.5", () => { const setup = await createTestRenderer({ width: 40, height: 8 }); renderer = setup.renderer; let submitted = ""; + const input = new InputRenderable(renderer, { id: "input", onSubmit: (value) => (submitted = value), }); + renderer.root.add(input); input.focus(); await setup.mockInput.typeText("note"); @@ -130,10 +142,12 @@ describe("TUI Parts compatibility with OpenTUI 0.5", () => { const setup = await createTestRenderer({ width: 40, height: 8 }); renderer = setup.renderer; let presses = 0; + const button = new ButtonRenderable(renderer, { id: "button", onPress: () => presses++, }); + button.add(new TextRenderable(renderer, { content: "Apply" })); renderer.root.add(button); button.focus(); @@ -146,6 +160,7 @@ describe("TUI Parts compatibility with OpenTUI 0.5", () => { const setup = await createTestRenderer({ width: 40, height: 8 }); renderer = setup.renderer; let selected = "one"; + const group = new RadioGroupRenderable(renderer, { id: "group", value: selected, @@ -154,18 +169,21 @@ describe("TUI Parts compatibility with OpenTUI 0.5", () => { group.value = value; }, }); + for (const value of ["one", "two"]) { const radio = new RadioRootRenderable(renderer, { id: `radio-${value}`, store: group.store, value, }); + const indicator = new RadioIndicatorRenderable(renderer, { radio }); indicator.add(new BoxRenderable(renderer, { width: 1, height: 1 })); radio.add(indicator); radio.add(new TextRenderable(renderer, { content: value })); group.add(radio); } + renderer.root.add(group); const first = renderer.root.getRenderable("radio-one"); first?.focus();