diff --git a/packages/cli/src/repl/application.ts b/packages/cli/src/repl/application.ts index 6e1156f8c..550f026d5 100644 --- a/packages/cli/src/repl/application.ts +++ b/packages/cli/src/repl/application.ts @@ -111,6 +111,8 @@ import type { ReplHistoryNavigation } from "./navigation.ts"; import { blankBand, EMPTY_BAND } from "./history-rail.ts"; import type { ReplPreparedRail } from "./history-rail.ts"; import { entryReading, NO_READING } from "./source-reading.ts"; +import { inertCommandText } from "./command-output.ts"; +import type { ReplCommandOutput } from "./command-output.ts"; import type { ReplReading } from "./source-reading.ts"; import { readingRuns, fitPlain } from "./fitting.ts"; import type { ReplPlainFitStore } from "./fitting.ts"; @@ -185,6 +187,7 @@ export type { ReplAction }; /** What this process holds that the Journal does not. */ export interface ReplLive { + readonly commandOutput?: readonly ReplCommandOutput[]; /** Output no recorded outcome has replaced yet. */ readonly output: string; /** The question waiting right now, or none. */ @@ -6147,6 +6150,7 @@ export function readingOf(view: ReplView): ReplReading { entry, lifecycle: view.live.lifecycle, live: livesHere(view.model, view.selection) ? view.live.output : "", + commandOutput: view.live.commandOutput?.find((one) => one.entry === entry.key)?.chunks ?? [], inspected: view.state.route.at !== undefined, }); } @@ -10975,7 +10979,7 @@ function workComposed(staging: ReplChatStaging, key: string): readonly ReplChatC }), ); // The record's own final line break ends its last line rather than adding one. - const recorded = entry.terminal?.output ?? ""; + const recorded = inertCommandText(entry.terminal?.output ?? ""); const output = recorded.endsWith("\n") ? recorded.slice(0, -1) : recorded; if (output !== "") { found.push( diff --git a/packages/cli/src/repl/command-output.ts b/packages/cli/src/repl/command-output.ts new file mode 100644 index 000000000..0759cd301 --- /dev/null +++ b/packages/cli/src/repl/command-output.ts @@ -0,0 +1,16 @@ +export interface ReplCommandChunk { + readonly channel: "stdout" | "stderr"; + readonly text: string; +} + +export interface ReplCommandOutput { + readonly entry: string; + readonly chunks: readonly ReplCommandChunk[]; +} + +export function inertCommandText(text: string): string { + return text.replace( + /[\x00-\x09\x0b-\x1f\x7f-\x9f]/g, + (control) => `\\x${control.charCodeAt(0).toString(16).padStart(2, "0")}`, + ); +} diff --git a/packages/cli/src/repl/program.ts b/packages/cli/src/repl/program.ts index c61c05d9a..f4f194711 100644 --- a/packages/cli/src/repl/program.ts +++ b/packages/cli/src/repl/program.ts @@ -2451,6 +2451,7 @@ function liveOf( ): ReplLive { return { output: session.overlay.output, + commandOutput: session.commandOutput, question: session.overlay.question, expansion: session.expansion.state, pausable: session.controller !== undefined, diff --git a/packages/cli/src/repl/session.ts b/packages/cli/src/repl/session.ts index 6e15c94ca..770de486e 100644 --- a/packages/cli/src/repl/session.ts +++ b/packages/cli/src/repl/session.ts @@ -80,6 +80,8 @@ import { INLINE_SOURCE_PATH, inlineSource, validateDocument } from "@executablem import type { DocumentValidationDiagnostic, Json, PermissionMode } from "@executablemd/core"; import type { GeneratedSelection } from "@executablemd/core/api"; import { API, Permissions } from "@executablemd/runtime"; +import { Stdio } from "@effectionx/process"; +import type { ReplCommandChunk, ReplCommandOutput } from "./command-output.ts"; import { executeInstalled } from "@executablemd/core/host"; import { useGeneratedFallback } from "../evaluation-profile.ts"; import type { @@ -391,6 +393,7 @@ export interface ReplSession { * {@link ReplPrinting} for why the overlay is not this. */ readonly printing: ReplPrinting | undefined; + readonly commandOutput: readonly ReplCommandOutput[]; /** * What this process is doing element by element, for the entry that is open. * @@ -791,6 +794,7 @@ function* start( /** Text the entry that is running has printed, which no record holds yet. */ let output = ""; + const commandOutput = new Map(); /** Whether an entry's execution is still running in this process. */ let live = false; /** @@ -1014,6 +1018,16 @@ function* start( // the record that outcome carries. return live && running !== undefined ? { entry: running.key, output } : undefined; }, + get commandOutput() { + return Object.freeze( + [...commandOutput].map(([entry, chunks]) => + Object.freeze({ + entry, + chunks: Object.freeze([...chunks]), + }), + ), + ); + }, get lifecycle() { return lifecycle.reading(); }, @@ -1780,6 +1794,42 @@ function* start( workspace?: true, ): Operation> { try { + const chunks: ReplCommandChunk[] = []; + const stdout = new TextDecoder(); + const stderr = new TextDecoder(); + function append(channel: ReplCommandChunk["channel"], text: string): void { + if (text.length === 0) { + return; + } + const last = chunks[chunks.length - 1]; + const chunk = Object.freeze({ + channel, + text: last?.channel === channel ? last.text + text : text, + }); + if (last?.channel === channel) { + chunks[chunks.length - 1] = chunk; + } else { + chunks.push(chunk); + } + commandOutput.set(key, chunks); + outputs.send(output); + } + // Registered before entry resources so their cleanup can still emit. + yield* ensure(() => { + append("stdout", stdout.decode()); + append("stderr", stderr.decode()); + }); + yield* Stdio.around( + { + *stdout([bytes]) { + append("stdout", stdout.decode(bytes, { stream: true })); + }, + *stderr([bytes]) { + append("stderr", stderr.decode(bytes, { stream: true })); + }, + }, + { at: "min" }, + ); const directory = workspace === true ? options.workspace : options.projectCwd; if (directory !== undefined) { yield* API.Env.around( diff --git a/packages/cli/src/repl/source-reading.ts b/packages/cli/src/repl/source-reading.ts index 00f4bc747..46483f41e 100644 --- a/packages/cli/src/repl/source-reading.ts +++ b/packages/cli/src/repl/source-reading.ts @@ -48,6 +48,8 @@ import type { ReplPresentationRole, ReplRowStyle } from "./presentation-style.ts import { tokenRuns } from "./description.ts"; import type { ReplTokenRun } from "./description.ts"; import { sourceRuns } from "./presentation-text.ts"; +import { inertCommandText } from "./command-output.ts"; +import type { ReplCommandChunk } from "./command-output.ts"; /** What this terminal draws for each phase, glyph and word together. */ const BADGES: Readonly< @@ -117,6 +119,7 @@ export interface ReplReadingSection { /** One entry's whole reading: what it produced, then what produced it. */ export interface ReplReading { readonly output: ReplReadingSection; + readonly commands?: readonly ReplReadingSection[]; readonly source: ReplReadingSection; } @@ -146,6 +149,10 @@ export function readingLines(reading: ReplReading): readonly ReplReadingLine[] { return Object.freeze([ caption("output", reading.output), ...reading.output.lines, + ...(reading.commands ?? []).flatMap((section, index) => [ + caption(`command:${index}`, section), + ...section.lines, + ]), caption("source", reading.source), ...reading.source.lines, ]); @@ -165,6 +172,7 @@ function caption(key: string, section: ReplReadingSection): ReplReadingLine { /** What a reading is being built for. */ export interface ReplReadingRequest { + readonly commandOutput?: readonly ReplCommandChunk[]; readonly entry: ReplEntry; /** This session's observations, or an empty reading for a retained prefix. */ readonly lifecycle: ReplLifecycleReading; @@ -184,6 +192,19 @@ export interface ReplReadingRequest { export function entryReading(request: ReplReadingRequest): ReplReading { return Object.freeze({ output: outputSection(request), + commands: Object.freeze( + (request.commandOutput ?? []).map((chunk, index) => + Object.freeze({ + caption: `Command ${chunk.channel}`, + captionStyle: styleOf("pane-heading", { inspected: request.inspected }), + lines: plain( + chunk.text, + `command:${index}`, + styleOf("output", { inspected: request.inspected }), + ), + }), + ), + ), source: sourceSection(request), }); } @@ -257,17 +278,19 @@ function outputSection(request: ReplReadingRequest): ReplReadingSection { /** Text as lines nothing has been observed in. */ function plain(text: string, prefix: string, style: ReplRowStyle): readonly ReplReadingLine[] { return Object.freeze( - text.split("\n").map((one, index) => - Object.freeze({ - key: `${prefix}:${index}`, - text: one, - runs: tokenRuns([{ text: one, token: style.role }]), - rail: "rail-pending" as const, - depth: 0, - badge: undefined, - style, - }), - ), + inertCommandText(text) + .split("\n") + .map((one, index) => + Object.freeze({ + key: `${prefix}:${index}`, + text: one, + runs: tokenRuns([{ text: one, token: style.role }]), + rail: "rail-pending" as const, + depth: 0, + badge: undefined, + style, + }), + ), ); } diff --git a/packages/cli/tests/repl-exec-output.test.ts b/packages/cli/tests/repl-exec-output.test.ts new file mode 100644 index 000000000..0a764992e --- /dev/null +++ b/packages/cli/tests/repl-exec-output.test.ts @@ -0,0 +1,521 @@ +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { useTempDirectory } from "@executablemd/test-support/temp"; +import { exists, writeTextFile } from "@effectionx/fs"; +import { Stdio } from "@effectionx/process"; +import { join } from "node:path"; +import { ensure, scoped, spawn, suspend, withResolvers } from "effection"; +import type { Operation } from "effection"; +import { API } from "@executablemd/runtime"; +import { useTempFileCompiler } from "@executablemd/core"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import { openReplSession } from "../src/repl/session.ts"; +import { useMountedHost } from "./support/mounted-repl.ts"; + +import { useReplPage } from "./support/repl-page.ts"; +import type { ReplPage } from "./support/repl-page.ts"; +import { awaitedFact, mountedReading } from "./support/mounted-repl.ts"; +import { intoComposer, pasteDraft, sidekickIdle } from "./support/screen-journeys.ts"; + +describe("E3 associated command Output", () => { + it("shows captured controls as readable escapes in both entry readings", function* () { + const received = "PROBE\x1b[2JTAIL\rEND"; + const displayed = "PROBE\\x1b[2JTAIL\\x0dEND"; + yield* API.Process.around({ + *exec() { + yield* Stdio.operations.stdout(new TextEncoder().encode(received)); + return { exitCode: 0, stdout: undefined, stderr: undefined }; + }, + }); + const page = yield* useReplPage({ size: { columns: 160, rows: 44 } }); + const program = '```sh exec as="result"\ncontrolled-bound\n```\n{result.stdout}'; + page.repl.fake.script({ + reply: + '\n````xmd\n' + + program + + "\n````\n\n{code}\n", + }); + page.repl.fake.script({ reply: "Probe completed." }); + yield* intoComposer(page); + yield* page.type("Run the probe"); + yield* page.key("Enter"); + const shown = yield* page.until( + "the completed associated entry", + (one) => one.shows("1 ✓") && sidekickIdle(one) && one.shows("Probe completed."), + ); + const centerColumn = shown.locate("Entries").column; + const sidekickColumn = shown.locate("BINDINGS").column; + const center = shown.rows.map((row) => row.slice(centerColumn, sidekickColumn)).join("\n"); + const associated = shown.rows.map((row) => row.slice(sidekickColumn)).join("\n"); + expect(center).toContain(displayed); + expect(associated).toContain(displayed); + expect(center).not.toContain("�"); + expect(associated).not.toContain("�"); + expect(shown.occurrences(displayed)).toBe(2); + const saved = yield* mountedReading(page.repl); + const entry = saved.model.entries[0]; + expect(entry.terminal?.output).toContain(received); + expect(entry.bindings.find((one) => one.name === "result")?.value).toEqual({ + stdout: received, + stderr: "", + exitCode: 0, + }); + expect(entry.records.flatMap((one) => (one.exec === undefined ? [] : [one.exec]))).toEqual([ + { stdout: received, stderr: "", exitCode: 0 }, + ]); + }); +}); + +function* submit(page: ReplPage, source: string, order = 1): Operation { + yield* pasteDraft(page, source); + yield* page.press("[send]", { onRow: " DRAFT" }); + yield* page.until(`entry ${order} admitted`, (one) => + ["●", "✓", "×"].some((status) => one.shows(`${order} ${status}`)), + ); +} + +describe("REPL foreground command presentation E1", () => { + it("E1: contains both real child streams in the producing entry before exit", function* () { + const directory = yield* useTempDirectory("repl-output-"); + const release = join(directory, "release"); + const ready = join(directory, "ready"); + const leaks: { channel: string; text: string }[] = []; + yield* ensure(() => { + if (leaks.length > 0) { + console.error(`foreground bypass evidence: ${JSON.stringify(leaks)}`); + } + }); + yield* Stdio.around( + { + *stdout([bytes]) { + leaks.push({ channel: "stdout", text: new TextDecoder().decode(bytes) }); + }, + *stderr([bytes]) { + leaks.push({ channel: "stderr", text: new TextDecoder().decode(bytes) }); + }, + }, + { at: "min" }, + ); + const page = yield* useReplPage({ size: { columns: 160, rows: 44 } }); + yield* submit( + page, + [ + "```sh exec", + "printf 'live-%s\\n' stdout", + "printf 'live-%s\\n' stderr >&2", + `touch '${ready}'`, + `while [ ! -f '${release}' ]; do sleep 0.02; done`, + "```", + ].join("\n"), + ); + yield* awaitedFact("the child holding after both writes", () => exists(ready)); + expect(yield* exists(release)).toBe(false); + yield* page.type("unsent entry draft"); + yield* page.until("the unsent draft", (one) => one.shows("unsent entry draft")); + const during = yield* page.until( + "both command channels inside the owning reading", + (one) => + one.shows("live-stdout") && + one.shows("live-stderr") && + one.shows("Command stdout") && + one.shows("Command stderr"), + ); + expect(leaks).toEqual([]); + expect(during.shows("unsent entry draft")).toBe(true); + expect(during.shows("No messages yet")).toBe(true); + yield* page.type(" still focused"); + yield* page.until("the draft retaining focus", (one) => one.shows("still focused")); + yield* writeTextFile(release, "release"); + yield* page.until("entry 1 settling", (one) => one.shows("1 ✓")); + expect(page.screen().shows("live-stdout")).toBe(true); + expect(page.screen().shows("live-stderr")).toBe(true); + const saved = yield* mountedReading(page.repl); + expect(saved.model.entries[0].terminal?.output.trim()).toBe(""); + expect( + saved.model.entries[0].records.flatMap((one) => (one.exec === undefined ? [] : [one.exec])), + ).toEqual([{ stdout: "live-stdout\n", stderr: "live-stderr\n", exitCode: 0 }]); + }); +}); + +describe("REPL command routing, containment and history E2–E5", () => { + it("E2: keeps routing separate from document output, bindings and retained streams", function* () { + const page = yield* useReplPage({ size: { columns: 160, rows: 52 } }); + yield* submit( + page, + [ + '```sh exec as="result"', + "printf 'bound-%s\\n' stdout", + "printf 'bound-%s\\n' stderr >&2", + "exit 7", + "```", + "{result.stdout}", + '', + "```sh exec", + "printf 'capture-%s\\n' stdout", + "printf 'capture-%s\\n' stderr >&2", + "```", + "", + "```sh silent exec", + "printf 'hidden-%s\\n' stdout", + "printf 'hidden-%s\\n' stderr >&2", + "```", + ].join("\n"), + ); + const shown = yield* page.until("the routing comparison settling", (one) => one.shows("1 ✓")); + expect(shown.occurrences("bound-stdout")).toBe(1); + expect(shown.shows("bound-stderr")).toBe(false); + expect(shown.shows("capture-stdout")).toBe(false); + expect(shown.shows("capture-stderr")).toBe(true); + expect(shown.shows("hidden-stdout")).toBe(false); + expect(shown.shows("hidden-stderr")).toBe(false); + const saved = yield* mountedReading(page.repl); + const entry = saved.model.entries[0]; + expect(entry.terminal?.output.trim()).toBe("bound-stdout"); + expect(entry.bindings.find((one) => one.name === "result")?.value).toEqual({ + stdout: "bound-stdout\n", + stderr: "bound-stderr\n", + exitCode: 7, + }); + expect(String(entry.bindings.find((one) => one.name === "captured")?.value)).toContain( + "capture-stdout", + ); + expect(entry.records.flatMap((one) => (one.exec === undefined ? [] : [one.exec]))).toEqual([ + { stdout: "bound-stdout\n", stderr: "bound-stderr\n", exitCode: 7 }, + { stdout: "capture-stdout\n", stderr: "capture-stderr\n", exitCode: 0 }, + { stdout: "hidden-stdout\n", stderr: "hidden-stderr\n", exitCode: 0 }, + ]); + }); + + it("E3: fits inert controls and independently decoded Unicode across resize", function* () { + let emitted = false; + const release = withResolvers(); + const start = withResolvers(); + const encoder = new TextEncoder(); + const controls = "\r\t\x1b[2J\x1b[H\x1b]0;owned\x07\x1b[?1049l\x9b"; + const long = "LONG".repeat(90) + "LONG-END"; + let calls = 0; + yield* API.Process.around({ + *exec() { + calls += 1; + if (calls === 2) { + yield* Stdio.operations.stdout(encoder.encode("captured\x1b[2J\r\tEND")); + return { exitCode: 0, stdout: undefined, stderr: undefined }; + } + yield* start.operation; + const wide = encoder.encode("unicode-🦊-end\n"); + yield* Stdio.operations.stdout(wide.subarray(0, 10)); + yield* Stdio.operations.stderr(encoder.encode("interleaved-stderr\n")); + yield* Stdio.operations.stdout(wide.subarray(10)); + for (const text of [controls.slice(0, 3), controls.slice(3), "\n", long, "\n"]) { + yield* Stdio.operations.stdout(encoder.encode(text)); + } + emitted = true; + yield* release.operation; + return { exitCode: 0, stdout: undefined, stderr: undefined }; + }, + }); + const page = yield* useReplPage({ size: { columns: 160, rows: 44 } }); + yield* ensure(() => { + start.resolve(); + release.resolve(); + }); + yield* submit( + page, + '```sh exec\ncontrolled-output\n```\n\n```sh exec as="captured"\ncontrolled-bound\n```\n{captured.stdout}', + ); + yield* page.until( + "the admitted draft clearing", + (one) => one.found("{captured.stdout}", { after: " DRAFT" }) === 0, + ); + yield* page.type("draft stays"); + yield* page.until("the preserved draft", (one) => one.shows("draft stays")); + yield* intoComposer(page); + yield* page.type("message stays"); + yield* page.until("the preserved message", (one) => one.shows("message stays")); + const paneColumn = page.screen().locate("BINDINGS").column; + const neighbor = page.screen().rows.map((row) => row.slice(paneColumn)); + start.resolve(); + yield* page.until("the controlled process emitting its chunks", () => emitted); + const shown = yield* page.until( + "inert controls and split Unicode", + (one) => one.shows("🦊-end") && one.shows("interleaved-stderr") && one.shows("\\x1b[2J"), + ); + expect(shown.shows("�")).toBe(false); + expect(shown.shows("\\x0d\\x09")).toBe(true); + expect(shown.shows("\\x07")).toBe(true); + expect(shown.shows("\\x9b")).toBe(true); + expect(shown.shows("draft stays")).toBe(true); + expect(shown.rows.map((row) => row.slice(paneColumn))).toEqual(neighbor); + yield* page.type(" focused"); + yield* page.until("the message retaining focus", (one) => one.shows("message stays focused")); + yield* page.resize({ columns: 72, rows: 24 }); + yield* page.resize({ columns: 160, rows: 44 }); + yield* page.until( + "the restored drafts", + (one) => one.shows("draft stays") && one.shows("message stays focused"), + ); + // Wheel movement reads the whole fitted line without moving the composer. + const at = page.screen().locate("interleaved-stderr"); + let tail = false; + for (let step = 0; step < 24 && !tail; step += 1) { + const before = page.screen().index; + yield* page.wheel("down", at); + const scrolled = yield* page.until("the reading scrolling", (one) => one.index > before); + tail = scrolled.shows("LONG-END"); + } + expect(tail).toBe(true); + release.resolve(); + yield* page.until("the controlled entry settling", (one) => one.shows("1 ✓")); + const saved = yield* mountedReading(page.repl); + const entry = saved.model.entries[0]; + const execs = entry.records.flatMap((one) => (one.exec === undefined ? [] : [one.exec])); + expect(execs[0]).toEqual({ + stdout: `unicode-🦊-end\n${controls}\n${long}\n`, + stderr: "interleaved-stderr\n", + exitCode: 0, + }); + expect(entry.terminal?.output).toContain("captured\x1b[2J\r\tEND"); + // The output reading starts above the command sections and escapes the copy. + for ( + let step = 0; + step < 24 && !page.screen().shows("captured\\x1b[2J\\x0d\\x09END"); + step += 1 + ) { + const before = page.screen().index; + yield* page.wheel("up", at); + yield* page.until("returning to rendered output", (one) => one.index > before); + } + expect(page.screen().shows("captured\\x1b[2J\\x0d\\x09END")).toBe(true); + }); + + for (const cancel of [false, true]) { + it(`E4: owns diagnostics through held ${cancel ? "cancelled" : "failed"} cleanup and join`, function* () { + yield* useTempFileCompiler(); + const release = withResolvers(); + let cleaning = false; + let working = false; + let installs = 0; + const encoder = new TextEncoder(); + const opened = yield* openReplSession({ + execution: { id: "cleanup-output", stream: new InMemoryStream() }, + installations: [ + { + *install() { + installs += 1; + const first = installs === 1; + if (first) { + yield* ensure(function* () { + yield* Stdio.operations.stderr(encoder.encode("cleanup-diagnostic\n")); + cleaning = true; + yield* release.operation; + yield* Stdio.operations.stderr(encoder.encode("cleanup-joined\n")); + }); + } + yield* API.Process.around({ + *exec() { + yield* Stdio.operations.stdout( + encoder.encode(first ? "producer-output\n" : "successor-output\n"), + ); + working = true; + if (first && cancel) { + yield* suspend(); + } + return { exitCode: first ? 3 : 0, stdout: undefined, stderr: undefined }; + }, + }); + }, + }, + ], + }); + if (!opened.ok) { + throw opened.error; + } + const session = opened.value; + yield* ensure(() => { + release.resolve(); + }); + const admitted = yield* session.submit("```sh exec\ncontrolled-cleanup\n```"); + expect(admitted.ok).toBe(true); + yield* awaitedFact("the process starting", function* () { + return working; + }); + const finished = yield* spawn(() => (cancel ? session.stopEntry("entry-1") : session.join())); + yield* awaitedFact("cleanup holding after emitting diagnostics", function* () { + return cleaning; + }); + const during = session.commandOutput; + expect(during[0].entry).toBe("entry-1"); + expect(during[0].chunks).toEqual([ + { channel: "stdout", text: "producer-output\n" }, + { channel: "stderr", text: "cleanup-diagnostic\n" }, + ]); + expect(session.live).toBe(true); + const refused = yield* session.submit("must not start"); + expect(refused.ok).toBe(false); + expect(session.model.entries).toHaveLength(1); + release.resolve(); + const outcome = yield* finished; + expect(outcome.ok).toBe(cancel); + expect(session.model.entries[0].terminal?.status).toBe(cancel ? "cancelled" : "err"); + expect(session.commandOutput[0].chunks[1].text).toBe("cleanup-diagnostic\ncleanup-joined\n"); + expect(during[0].chunks[1].text).toBe("cleanup-diagnostic\n"); + expect(Object.isFrozen(during[0].chunks)).toBe(true); + expect((yield* session.submit("```sh exec\nsuccessor\n```")).ok).toBe(true); + expect((yield* session.join()).ok).toBe(true); + expect(session.commandOutput.map((one) => [one.entry, one.chunks])).toEqual([ + [ + "entry-1", + [ + { channel: "stdout", text: "producer-output\n" }, + { channel: "stderr", text: "cleanup-diagnostic\ncleanup-joined\n" }, + ], + ], + ["entry-2", [{ channel: "stdout", text: "successor-output\n" }]], + ]); + }); + } + + it("E4: keeps late output with its producer while history and Sidekick are selected", function* () { + const more = withResolvers(); + const release = withResolvers(); + let entries = 0; + let cleaning = false; + let laterWritten = false; + const page = yield* useReplPage({ + size: { columns: 160, rows: 44 }, + entryInstallation: function* () { + entries += 1; + if (entries === 2) { + yield* ensure(function* () { + yield* Stdio.operations.stderr(new TextEncoder().encode("cleanup-before-history\n")); + cleaning = true; + yield* more.operation; + yield* Stdio.operations.stderr(new TextEncoder().encode("cleanup-after-history\n")); + laterWritten = true; + yield* release.operation; + }); + } + return {}; + }, + }); + yield* ensure(() => { + more.resolve(); + release.resolve(); + }); + yield* submit(page, "older answer"); + yield* page.until("the earlier entry settling", (one) => one.shows("1 ✓")); + yield* submit(page, "```sh exec\nprintf 'producer-%s\\n' diagnostic >&2\nexit 3\n```", 2); + yield* awaitedFact("the provider holding cleanup", function* () { + return cleaning; + }); + yield* page.until("cleanup displayed after root Close", (one) => + one.shows("cleanup-before-history"), + ); + yield* page.type("entry draft remains"); + yield* page.until("the entry draft", (one) => one.shows("entry draft remains")); + yield* page.press("[history]"); + yield* page.until("the history checkpoints", (one) => one.shows("Entry 1 admitted")); + yield* page.press("Entry 1 admitted", { rightOf: "[close]" }); + yield* page.until("the frozen earlier entry", (one) => one.shows("[live]")); + yield* page.press("[close]"); + yield* page.until("the checkpoint drawer closing", (one) => !one.shows("[close]")); + page.repl.fake.script({ reply: "background-answer" }); + yield* intoComposer(page); + yield* page.type("background-request"); + yield* page.until("the background request", (one) => one.shows("background-request")); + yield* page.key("Enter"); + yield* page.until( + "the scripted Sidekick exchange", + (one) => one.shows("background-answer") && sidekickIdle(one), + ); + yield* page.type("message draft"); + yield* page.until("the message draft", (one) => one.shows("message draft")); + more.resolve(); + yield* awaitedFact("late diagnostics arriving at the earlier selection", function* () { + return laterWritten; + }); + yield* page.type(" focused"); + const frozen = yield* page.until("focus and selection surviving late output", (one) => + one.shows("message draft focused"), + ); + expect(frozen.shows("[live]")).toBe(true); + expect(frozen.shows("Entry 1")).toBe(true); + expect(frozen.shows("entry draft remains")).toBe(true); + expect(frozen.shows("cleanup-after-history")).toBe(false); + expect(frozen.shows("Command stderr")).toBe(false); + yield* page.press("[live]"); + yield* page.until( + "the producer reading", + (one) => !one.shows("[live]") && one.shows("cleanup-after-history"), + ); + release.resolve(); + yield* page.until( + "the failed entry finishing cleanup", + (one) => one.shows("2 ×") && !one.shows("[stop]"), + ); + const saved = yield* mountedReading(page.repl); + expect(saved.model.entries).toHaveLength(2); + expect(saved.model.entries[1].terminal?.status).toBe("err"); + }); + + it("E5: cold inspection retains exact per-entry diagnostics without starting commands", function* () { + let starts = 0; + yield* API.Process.around({ + *exec([options], next) { + starts += 1; + return yield* next(options); + }, + }); + const host = yield* useMountedHost(); + const before = yield* scoped(function* () { + const page = yield* useReplPage({ host, size: { columns: 160, rows: 44 } }); + yield* submit( + page, + "```sh exec\nprintf 'first-%s\\n' output\nprintf 'first-%s\\n' diagnostic >&2\n```", + ); + yield* page.until("the first retained entry", (one) => one.shows("1 ✓")); + yield* submit( + page, + "```sh silent exec\nprintf 'hidden-%s\\n' output\n```\n```sh exec as=\"result\"\nprintf 'bound-%s\\n' output\nprintf 'bound-%s\\n' diagnostic >&2\nexit 7\n```", + 2, + ); + yield* page.until("the second retained entry", (one) => one.shows("2 ✓")); + const saved = yield* mountedReading(page.repl); + const opened = yield* host.repository.open(saved.execution); + return { ...saved, events: yield* opened.stream.readAll() }; + }); + expect(starts).toBe(3); + const at = before.model.entries[0].checkpoints.at(-1)?.marker; + if (at === undefined) { + throw new Error("the first entry retained no completed position"); + } + yield* scoped(function* () { + const page = yield* useReplPage({ + host, + location: `xmd://repl/${before.execution}/entries/entry-1?at=${encodeURIComponent(at)}`, + }); + expect(page.screen().shows("Command stdout")).toBe(false); + expect(page.screen().shows("hidden-output")).toBe(false); + expect(page.screen().shows("bound-output")).toBe(false); + yield* page.press("[live]"); + yield* page.until("the live head of saved history", (one) => !one.shows("[live]")); + const after = yield* mountedReading(page.repl); + expect(after.model.entries.map((entry) => entry.records)).toEqual( + before.model.entries.map((entry) => entry.records), + ); + expect( + after.model.entries[0].records.flatMap((one) => (one.exec === undefined ? [] : [one.exec])), + ).toEqual([{ stdout: "first-output\n", stderr: "first-diagnostic\n", exitCode: 0 }]); + expect( + after.model.entries[1].records.flatMap((one) => (one.exec === undefined ? [] : [one.exec])), + ).toEqual([ + { stdout: "hidden-output\n", stderr: "", exitCode: 0 }, + { stdout: "bound-output\n", stderr: "bound-diagnostic\n", exitCode: 7 }, + ]); + const opened = yield* host.repository.open(before.execution); + expect(yield* opened.stream.readAll()).toEqual(before.events); + expect(starts).toBe(3); + expect(host.fake.prompts).toEqual([]); + }); + }); +}); diff --git a/packages/cli/tests/repl-source-reading.test.ts b/packages/cli/tests/repl-source-reading.test.ts index e87a59562..393891781 100644 --- a/packages/cli/tests/repl-source-reading.test.ts +++ b/packages/cli/tests/repl-source-reading.test.ts @@ -705,7 +705,7 @@ describe("the reading one entry is shown as", () => { inspected: false, }); // One reading, two captioned sections, and the result is the first. - expect(Object.keys(reading)).toEqual(["output", "source"]); + expect(Object.keys(reading)).toEqual(["output", "commands", "source"]); expect(reading.output.caption).toBe("Output"); expect(reading.source.caption).toBe("Source"); }); diff --git a/specs/repl-spec.md b/specs/repl-spec.md index 92bac6fc0..10482e059 100644 --- a/specs/repl-spec.md +++ b/specs/repl-spec.md @@ -1598,6 +1598,21 @@ closed `err` is a failure whatever it rendered. Nothing is attributed to an `` or a ``, reconstructed by concatenating block results, or invented as a receipt for a bound value that emitted nothing. +Ordinary `exec` stdout and stderr stream into the producing entry's reading as +`Command stdout` and `Command stderr`, between Output and Source. The channel +labels follow received order. Long lines wrap in the same scrollable reading; +blank lines and partial lines remain readable. This presentation is separate +from rendered document Output: forwarding adds nothing to the root result. +Bound commands display only what the document explicitly renders, a `` +capture still hides stdout, and `silent exec` displays neither channel. + +Command text is inert before measurement and drawing. LF separates rows; other +C0 controls, DEL and C1 controls appear as ASCII `\xNN` escapes. Cursor motion, +clearing, OSC and terminal mode sequences therefore remain readable text. The +same conversion applies to rendered Output, including explicit interpolation of +captured diagnostics and saved output. It changes display copies only; captured +values, retained channel strings and source bytes remain intact. + Source is the exact text the entry admitted. Beside each row is a rail saying which region the row is in, and against the pane's right inner edge is the reading of the element that owns it — glyph and word together, so the reading @@ -1678,6 +1693,13 @@ model, no checkpoint file and no record type of the REPL's own. The file and the location are the whole of the state, which is what makes reconstruction from them possible at all. +Forwarded command presentation belongs to this process and the entry that +produced it. It remains available after that entry joins, but a frozen history +position shows no process-local command presentation. Cold viewing reads the +existing entry-owned exec records, including stdout, stderr and exit status; +it starts no command and reconstructs no visible output from hidden or bound +records. No additional output log is retained. + The location carries route state only: which surface, which entry and scopes, which drawers, which history position, the draft for the next entry, which reading is selected, which right-hand pane was being read and which saved @@ -2413,6 +2435,14 @@ is unavailable, rather than reconstructed from a saved preview or text. ## Who owns what +Only the REPL terminal renderer writes presentation bytes while the interface +owns the terminal. Each entry installs its downstream process display handlers +before acquiring execution resources and keeps them through joined teardown. +Cleanup output belongs to that entry even after root Close, while another +history position is selected, or while Sidekick is active. Arrival wakes the +screen without moving selection, focus, Follow or either editable draft. +Ordinary `xmd run` retains its existing foreground forwarding. + When feedback has no exact completed canonical input, `[review recovery]` shows the original request, saved failure, established entries and unfinished controls. Inspection, closing and resizing send nothing and record no decision. The person