Public: anyone can read this; the crew can change it.

`enter` on a run in the terminal opens its log, the last 200 lines, refreshing while it runs, as MCP `run_log` does #7306

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/7293-enter-on-a-run-in-the-terminal-opens-its → main 4 commits fork 7 threads · 7 unresolved

  • Merged. scout/7293-enter-on-a-run-in-the-terminal-opens-its was merged into main as be2c524.

Review

Sign in to review this change.
10 files changed +399 −45
README.md +2
@@ -289,6 +289,8 @@ one: the library of clones, then a lazygit-shaped working-tree screen —
289289 `space` stages, `enter` opens the hunks, `s` stages one, `c` commits, `P`
290290 pushes, `b` branches, `l` log, `z` stashes, `?` shows every binding.
291291
292 In the SSH terminal, `enter` on a run opens its log (the last 200 lines, following new ones while it runs; "Not found" where the web would say so), and `o` there prints the web address (`src/tui/screens/runlog.ts`).
293
292294 Build it once and put it on your PATH:
293295
294296 ```sh
docs/TOUR.md +2
@@ -34,6 +34,8 @@ to filter, `?` for the keys, `:` for the command palette, `t` for the theme.
3434
3535 **Secrets.** For a forge admin, `:` → *Secrets* lists sealed secret names (never values); `a` sets one, `d` removes one after a confirm, `s` changes the place. See [OPERATING](OPERATING.md#secrets-in-the-terminal).
3636
37 **Run logs.** In the terminal, `enter` on a run (in the runs list, a pull, an issue or the deploys) opens its log: the state line, then the last 200 lines, scrolled to the end and following new lines while the run is queued or running (`j`/`k` and the page keys scroll; scrolling up keeps your place). A log you may not read is "Not found", as `run_log` and the REST route say. `o` there prints the web address (`src/tui/screens/runlog.ts`).
38
3739 **Milestones.** `:` → *Milestones in a repository* → `owner/name` lists a
3840 repository's limbs in the order the crew takes ready work (`enter` opens a
3941 limb's own limbs). A person who administers the repository presses `r` to
src/actions/runs.ts +5 −2
@@ -32,7 +32,7 @@ import type { User } from "../core/users.ts";
3232 import { abortAgentRun } from "../crew/running.ts";
3333 import { queueAgentRun, taskOf } from "../crew/dispatch.ts";
3434 import { err, ok, type Result } from "../kernel/result.ts";
35 import { readRunLog, runLogPath } from "../runs/log.ts";
35 import { readRunLog, readRunLogTail, runLogPath } from "../runs/log.ts";
3636 import { firstErrorLine } from "../runs/error_line.ts";
3737 import { requireReader, requireWriter, type AccessError } from "./access.ts";
3838
@@ -219,13 +219,16 @@ export function runLog(
219219 actor: User | undefined,
220220 repo: Repo,
221221 runId: string,
222 tail?: number,
222223 ): Result<RunLogView, "not_found"> {
223224 const found = getRepoRun(app, actor, repo, runId);
224225 if (!found.ok) return found;
225226 const logHidden = !mayReadRunLog(app, actor, repo);
226227 const { lines, truncated } = logHidden
227228 ? { lines: [], truncated: false }
228 : readRunLog(app.config.dataDir, runId);
229 : tail === undefined
230 ? readRunLog(app.config.dataDir, runId)
231 : readRunLogTail(app.config.dataDir, runId, tail);
229232 return ok({
230233 run: found.value,
231234 lines,
src/tui/screens/common.ts +4
@@ -19,6 +19,7 @@ import { SIGNUP_CLOSED_MESSAGE } from "../../actions/users.ts";
1919 import { ORIGIN_ALREADY_CONNECTED } from "../../actions/origins.ts";
2020 import { NEW_ACCOUNT_GUARD_MESSAGE } from "../../core/capacity.ts";
2121 import { PRIVATE_ADDRESS_REFUSED } from "../../kernel/address_words.ts";
22 import type { ForgeEvent } from "../../core/events.ts";
2223 import type { Session } from "../session.ts";
2324 import { G, Screen as Surface, type Rect } from "../term/screen.ts";
2425 import { style } from "../term/style.ts";
@@ -662,6 +663,8 @@ export interface LiveOptions {
662663 readonly repoId?: string;
663664 /** Only these event kinds. Omit for all of them. */
664665 readonly kinds?: readonly string[];
666 /** Only events this accepts. Omit for all of them. */
667 readonly where?: (event: ForgeEvent) => boolean;
665668 }
666669
667670 /**
@@ -688,6 +691,7 @@ export function live(
688691 const off = f.app.bus.subscribe((event) => {
689692 if (o.repoId !== undefined && event.repoId !== o.repoId) return;
690693 if (o.kinds !== undefined && !o.kinds.includes(event.kind)) return;
694 if (o.where !== undefined && !o.where(event)) return;
691695 if (timer !== null) return;
692696 timer = setTimeout(() => {
693697 timer = null;
src/tui/screens/pull.ts +2 −1
@@ -116,7 +116,8 @@ import { FINISH_REFUSALS, finishConflicts } from "../../actions/finish-conflicts
116116 import { PROPOSE_WHY, proposalOf, proposeResolution } from "../../crew/proposal.ts";
117117 import { conflictScreen } from "./conflicts.ts";
118118 import { reviewScreen } from "./review.ts";
119 import { RunList, openRunLog, runTone } from "./runlist.ts";
119 import { RunList, openRunLog } from "./runlist.ts";
120 import { runTone } from "./runwords.ts";
120121 import { Composer, threadLines, type ThreadEntry } from "./thread.ts";
121122 import { local, localRepo, whereRows, whereStrip } from "./where.ts";
122123 import { openAppTerminal } from "./apptty.ts";
src/tui/screens/runlist.ts +5 −41
@@ -1,8 +1,7 @@
11 /**
22 * Runs, as rows: the vocabulary a screen borrows to show one — a pull's
33 * checks, an issue's crew activity. One place decides what "failed" looks
4 * like so it looks the same everywhere. A run's live log is a web page
5 * (`/:owner/:name/runs/:id`); `openRunLog` hands its address over.
4 * like so it looks the same everywhere. `openRunLog` opens a run's log screen.
65 *
76 * A run row says four things in order: its state (a dot in the tone and the
87 * word), what it was (`deploy production`, `agent robo`), why (the reason),
@@ -20,7 +19,6 @@ import type { Ctx } from "../app/app.ts";
2019 import type { Rect, Screen as Surface } from "../term/screen.ts";
2120 import type { Tokens } from "../ui/theme.ts";
2221 import { icon, type IconSet } from "../ui/icons.ts";
23 import type { Tone } from "../ui/widgets.ts";
2422 import type { Session } from "../session.ts";
2523 import {
2624 ListState,
@@ -29,24 +27,9 @@ import {
2927 metaOf,
3028 relTime,
3129 type ListRow,
32 openOnWeb,
3330 } from "./common.ts";
34
35 /** The tone a run's state wears — the same reading on every surface. */
36 export function runTone(state: RunState): Tone | undefined {
37 switch (state) {
38 case "ok":
39 return "success";
40 case "failed":
41 return "danger";
42 case "running":
43 return "attn";
44 case "cancelled":
45 return "secondary";
46 case "queued":
47 return undefined;
48 }
49 }
31 import { runLogScreen } from "./runlog.ts";
32 import { costText, envLabel, runTone } from "./runwords.ts";
5033
5134 /** Whether the dot is hollow: nothing has happened yet, or nothing will. */
5235 export const runHollow = (state: RunState): boolean =>
@@ -70,16 +53,6 @@ export function runTitle(
7053 return set === "nerd" ? `${icon("bot", set)} ${who}` : `agent ${who}`;
7154 }
7255
73 /** `production`, or `pull #4` — the environment as a person says it. */
74 export function envLabel(env: string): string {
75 return env.startsWith("pull-") ? `pull #${env.slice(5)}` : env || "—";
76 }
77
78 /** The dollars a run spent, or nothing when it spent none. */
79 export function costText(costUsd: number): string {
80 return costUsd > 0 ? `$${costUsd.toFixed(2)}` : "";
81 }
82
8356 /** The moment a run is best dated by: when it finished, started, or was made. */
8457 export function runWhen(run: Run): string {
8558 return run.finishedAt ?? run.startedAt ?? run.createdAt;
@@ -193,21 +166,12 @@ export class RunList {
193166 }
194167 }
195168
196 /**
197 * `enter` on a run: its log, cancel and steer live on the web page, so the
198 * terminal hands over the address. With no public URL configured the path
199 * is still the answer, said as one.
200 */
169 /** `enter` on a run: its log, in the terminal; `o` there prints the web address. */
201170 export function openRunLog(
202171 f: Session,
203172 ctx: Ctx,
204173 repo: Repo,
205174 run: Run,
206175 ): void {
207 openOnWeb(
208 f,
209 ctx,
210 `/${repo.ownerName}/${repo.name}/runs/${run.id}`,
211 "the run's log",
212 );
176 ctx.push(runLogScreen(f, ctx, repo, run.id));
213177 }
src/tui/screens/runlog.ts added +169
@@ -0,0 +1,169 @@
1 /**
2 * A run's log: its state line, then the last 200 lines, scrolled to the end.
3 * Read through `runLog` — the call MCP `run_log` and the run page make — so a
4 * hidden log or an unreadable repository says "not found" and draws nothing.
5 * It reads once, appends new lines from the log's own listener, and refreshes
outdated

Stale: nothing rereads every 2 s now. Rewrite this comment to match the listener-based design.

6 * the state line on this run's events; a viewer who scrolled up keeps their
7 * place, one at the end follows the new lines.
8 */
9
10 import { runLog, type RunLogView } from "../../actions/runs.ts";
11 import type { Repo } from "../../core/repos.ts";
12 import type { Ctx, Screen } from "../app/app.ts";
13 import { binding } from "../app/keys.ts";
14 import type { Session } from "../session.ts";
15 import { style } from "../term/style.ts";
16 import { truncate } from "../term/width.ts";
17 import { fill, len, split } from "../ui/layout.ts";
18 import { emptyState } from "../ui/widgets.ts";
19 import { subscribeRunLog } from "../../runs/log.ts";
20 import { Scroll, live, openOnWeb } from "./common.ts";
21 import { costText, envLabel, runTone } from "./runwords.ts";
22
23 const LOG_LINES = 200;
24
25 const isLive = (state: string): boolean => state === "queued" || state === "running";
26
27 export function runLogScreen(
28 f: Session,
29 ctx: Ctx,
30 repo: Repo,
31 runId: string,
32 ): Screen {
33 const scroll = new Scroll();
34 let view: RunLogView | undefined;
35 let lines: string[] = [];
36 let loaded = false;
37 let follow = true;
38
39 const read = (): void => {
40 const got = runLog(f.app, f.user, repo, runId, LOG_LINES);
41 // A hidden log is "not found", as the REST route says it.
42 view = got.ok && !got.value.logHidden ? got.value : undefined;
43 lines = // The tail window can hold a few more than asked for.
44 lines = view === undefined ? [] : view.lines.slice(-LOG_LINES);
45 loaded = true;
46 if (follow) scroll.top = Number.MAX_SAFE_INTEGER;

Non-blocking: lines = // comment followed by lines = ... is a stray double assignment. Drop the first lines = and put the comment on its own line above the second.

47 ctx.invalidate();
48 };
49
50 const web = (): void =>
51 openOnWeb(f, ctx, `/${repo.ownerName}/${repo.name}/runs/${runId}`, "the run's log");
52
53 const self: Screen = {
54 title: "Run log",
55 get keys() {
56 return [
57 binding(["j", "k"], "scroll"),
58 binding(["pgup", "pgdn"], "page"),
59 binding(["g", "G"], "top, end"),
60 binding(["o"], "open on the web"),
61 binding(["r"], "refresh"),
62 ];
63 },
outdated

Polling: each tick rereads up to 512 KB of the log file per open screen. Use subscribeRunLog for new lines and live() for state changes, as common.ts says every screen does.

64 load(signal) {
65 read();
66 // New lines arrive from the log's own listener, never from a reread.
67 subscribeRunLog(
68 runId,
69 (line) => {
70 if (view === undefined) return;
71 lines.push(line);
72 if (lines.length > LOG_LINES) lines.splice(0, lines.length - LOG_LINES);
73 if (follow) scroll.top = Number.MAX_SAFE_INTEGER;
74 ctx.invalidate();
75 },
76 signal,
77 );
78 // The run starting or ending, or the gate moving, re-reads the state
79 // line; a finished run has nothing left to follow.
outdated

Fires for every run event in the repo and rereads when view is undefined. Filter on the event's run id and skip when nothing was loaded.

80 live(
81 f,
82 signal,
83 {
84 repoId: repo.id,
85 kinds: ["run.queued", "run.started", "run.finished"],
86 where: (event) => event.payload.runId === runId,
87 },
88 () => {
89 // Nothing loaded (hidden, not found) or finished: nothing to follow.
90 if (view === undefined || !isLive(view.run.state)) return;
91 read();
92 },
93 );
94 return Promise.resolve();
95 },
96 render(area, s) {
97 const c = ctx.theme.c;
98 if (view === undefined) {
99 emptyState(s, area, c, {
100 title: loaded ? "Not found" : "Reading…",
101 body: loaded ? "There is no such run here." : "Reading the log",
102 });
103 return;
104 }
105 const [head, body] = split(area, "v", [len(1), fill(1)]);
106 const run = view.run;
107 const tone = runTone(run.state);
108 const state = [
109 run.state,
110 run.kind === "deploy" ? `deploy ${envLabel(run.env)}` : run.kind,
111 run.summary,
112 costText(run.costUsd),
113 isLive(run.state) ? "live" : "",
114 ]
115 .filter((p) => p !== "")
116 .join(" · ");
117 s.text(
118 head!.x,
119 head!.y,
120 truncate(state, head!.w),
121 style(tone === undefined ? c.mutedFg : c.fg, c.bg),
122 head!.x + head!.w,
123 );
124 if (lines.length === 0) {
125 emptyState(s, body!, c, {
126 title: "No log yet",
127 body: isLive(run.state)
128 ? "Waiting for the first line."
129 : "This run wrote nothing.",
130 });
131 return;
132 }
133 scroll.height = Math.max(1, body!.h);
134 const last = Math.max(0, lines.length - body!.h);
135 scroll.top = Math.min(scroll.top, last);
136 follow = scroll.top >= last;
137 for (let i = 0; i < body!.h; i++) {
138 const line = lines[scroll.top + i];
139 if (line === undefined) break;
140 s.text(
141 body!.x,
142 body!.y + i,
143 truncate(line.replace(/[\u0000-\u001f\u007f]/g, " "), body!.w),
144 style(c.fg, c.bg),
145 body!.x + body!.w,
146 );
147 }
148 },
149 key(e) {
150 if (e.key === "o" && !e.ctrl && !e.alt) {
151 web();
152 return true;
153 }
154 if (e.key === "r" && !e.ctrl && !e.alt) {
155 read();
156 return true;
157 }
158 if (view === undefined) return false;
159 const last = Math.max(0, lines.length - scroll.height);
160 scroll.top = Math.min(scroll.top, last);
161 const took = scroll.key(e, lines.length + scroll.height);
162 scroll.top = Math.min(scroll.top, last);
163 follow = scroll.top >= last;
164 if (took) ctx.invalidate();
165 return took;
166 },
167 };
168 return self;
169 }
src/tui/screens/runwords.ts added +34
@@ -0,0 +1,34 @@
1 /**
2 * How a run reads: the tone its state wears, the environment as a person says
3 * it, the dollars it spent. Shared by the runs list and the run's log so
4 * neither imports the other.
5 */
6
7 import type { RunState } from "../../core/runs.ts";
8 import type { Tone } from "../ui/widgets.ts";
9
10 /** The tone a run's state wears — the same reading on every surface. */
11 export function runTone(state: RunState): Tone | undefined {
12 switch (state) {
13 case "ok":
14 return "success";
15 case "failed":
16 return "danger";
17 case "running":
18 return "attn";
19 case "cancelled":
20 return "secondary";
21 case "queued":
22 return undefined;
23 }
24 }
25
26 /** `production`, or `pull #4` — the environment as a person says it. */
27 export function envLabel(env: string): string {
28 return env.startsWith("pull-") ? `pull #${env.slice(5)}` : env || "—";
29 }
30
31 /** The dollars a run spent, or nothing when it spent none. */
32 export function costText(costUsd: number): string {
33 return costUsd > 0 ? `$${costUsd.toFixed(2)}` : "";
34 }
test/tui-apps.test.ts +3 −1
@@ -303,7 +303,9 @@ describe("terminal: apps, runs and the crew", () => {
303303 expect(h.text()).toContain("opened pull #2 with the melody");
304304 expect(h.text()).toContain("$0.42");
305305 await h.press("tab", "enter");
306 await h.until(`/alice/site/runs/${run.id}`);
306 await h.until("cloning alice/site");
307 // The log screen's state line, not the web address.
308 expect(h.text()).toContain("ok · agent · opened pull #2 with the melody · $0.42");
307309 } finally {
308310 h.stop();
309311 }
test/tui-runlog.test.ts added +173
@@ -0,0 +1,173 @@
1 /** `enter` on a run opens its log in the terminal, as MCP `run_log` reads it (#7306). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { appendFileSync } from "node:fs";
7 import { announceRun } from "../src/actions/runs.ts";
8 import { createRepo } from "../src/actions/repos.ts";
9 import { createApp, type App as Forge } from "../src/app.ts";
10 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
11 import type { Repo } from "../src/core/repos.ts";
12 import { createUser, type User } from "../src/core/users.ts";
13 import { appendRunLog, runLogPath } from "../src/runs/log.ts";
14 import { createForgeApp } from "../src/tui/forge.ts";
15 import { runLogScreen } from "../src/tui/screens/runlog.ts";
16 import { headlessTerminal } from "../src/tui/term/terminal.ts";
17 import { seedAdmin } from "./helpers/admin.ts";
18 import { quietLogs } from "./helpers/globals.ts";
19 import { settleApp, settleUntil } from "./helpers/tui.ts";
20
21 quietLogs();
22
23 let dir: string;
24 let forge: Forge;
25 let repo: Repo;
26 let alice: User;
27 let bob: User;
28
29 beforeAll(async () => {
30 dir = await mkdtemp(join(tmpdir(), "forge-tui-runlog-"));
31 forge = createApp({ dataDir: dir, port: 0 });
32 const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" });
33 if (!a.ok) throw new Error("signup failed");
34 alice = a.value;
35 const b = await createUser(forge.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-long-passphrase" });
36 if (!b.ok) throw new Error("bob failed");
37 bob = b.value;
38 const r = await createRepo(forge, alice, { name: "lamp", visibility: "public" });
39 if (!r.ok) throw new Error("repo failed");
40 repo = r.value;
41 });
42 afterAll(async () => {
43 forge.close?.();
44 await rm(dir, { recursive: true, force: true });
45 });
46
47 const open = async (user: User, runId: string) => {
48 const { app, session } = createForgeApp(
49 forge,
50 { sshUsername: user.username, remoteAddress: "127.0.0.1", user },
51 { terminal: headlessTerminal(100, 20), exitHooks: false },
52 );
53 app.start();
54 await settleApp(app);
55 app.push(runLogScreen(session, app.ctx, repo, runId));
56 await settleApp(app);
57 return app;
58 };
59
60 test("a finished run shows its state and its last 200 lines, at the end", async () => {
61 const run = createRun(forge.db, { repoId: repo.id, kind: "agent" });
62 claimRun(forge.db, run.id);
63 for (let i = 1; i <= 300; i++) appendRunLog(dir, run.id, `line-${i}`);
64 finishRun(forge.db, run.id, { state: "failed", summary: "BOOM-SUMMARY" });
65 const app = await open(alice, run.id);
66 const text = app.screen.toText();
67 expect(text).toContain("failed");
68 expect(text).toContain("BOOM-SUMMARY");
69 expect(text).toContain("line-300");
70 expect(text).not.toContain("\nline-2\n");
71 app.dispatch({ type: "key", key: "g", ctrl: false, alt: false, shift: false });
72 await settleApp(app);
73 expect(app.screen.toText()).toContain("line-101");
74 expect(app.screen.toText()).not.toContain("\nline-100\n");
75 });
76
77 test("a log the viewer may not read is not found and nothing of it is drawn", async () => {
78 const run = createRun(forge.db, { repoId: repo.id, kind: "agent" });
79 claimRun(forge.db, run.id);
80 appendRunLog(dir, run.id, "SECRET-LINE");
81 finishRun(forge.db, run.id, { state: "ok", summary: "SECRET-SUMMARY" });
82 const app = await open(bob, run.id);
83 const text = app.screen.toText();
84 expect(text).toContain("Not found");
85 expect(text).not.toContain("SECRET-LINE");
86 expect(text).not.toContain("SECRET-SUMMARY");
87 });
88
89 const key = async (app: Awaited<ReturnType<typeof open>>, k: string) => {
90 app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false });
91 await settleApp(app);
92 };
93
94 test("a running run's new lines appear as they are written, with no reread", async () => {
95 const run = createRun(forge.db, { repoId: repo.id, kind: "agent" });
outdated

This 2.3 s wall-clock sleep is slow and flake-prone. Once the screen subscribes, the new line shows up after settleApp with no sleep.

96 claimRun(forge.db, run.id);
97 appendRunLog(dir, run.id, "first-line");
98 const app = await open(alice, run.id);
99 expect(app.screen.toText()).toContain("first-line");
100 appendRunLog(dir, run.id, "later-line");
101 await settleApp(app);
102 expect(app.screen.toText()).toContain("later-line");
103 });
104
105 test("a viewer at the end follows new lines; one who scrolled up keeps their place", async () => {
106 const run = createRun(forge.db, { repoId: repo.id, kind: "agent" });
107 claimRun(forge.db, run.id);
108 for (let i = 1; i <= 100; i++) appendRunLog(dir, run.id, `row-${i}`);
109 const app = await open(alice, run.id);
110 expect(app.screen.toText()).toContain("row-100");
111 appendRunLog(dir, run.id, "row-101");
112 await settleApp(app);
113 expect(app.screen.toText()).toContain("row-101");
114 await key(app, "g");
115 expect(app.screen.toText()).toContain("\nrow-1\n");
116 appendRunLog(dir, run.id, "row-102");
117 await settleApp(app);
118 expect(app.screen.toText()).toContain("\nrow-1\n");
119 expect(app.screen.toText()).not.toContain("row-102");
120 await key(app, "G");
121 expect(app.screen.toText()).toContain("row-102");
122 });
123
124 test("the end of the run shows on the state line, and a finished run is not reread", async () => {
125 const run = createRun(forge.db, { repoId: repo.id, kind: "agent" });
126 claimRun(forge.db, run.id);
127 appendRunLog(dir, run.id, "working");
128 const app = await open(alice, run.id);
129 expect(app.screen.toText()).toContain("running");
130 const done = finishRun(forge.db, run.id, { state: "ok", summary: "ALL-DONE" });
131 if (done === undefined) throw new Error("finish failed");

Non-blocking: the 80 ms setTimeout is still a timed wait. A second bus event for a different run, delivered after the sneak, would give a deterministic signal.

132 announceRun(forge, done);
133 await settleUntil(app, "ALL-DONE");
134 expect(app.screen.toText()).not.toContain("live");
135 // A reread would show a line written behind the listener's back. Another
136 // run's event after the sneak is a signal that the bus has been drained.
137 appendFileSync(runLogPath(dir, run.id), "SNEAKED\n");
138 announceRun(forge, done);
139 await new Promise<void>((resolve) => {
140 const off = forge.bus.subscribe(() => {
141 off();
142 setTimeout(resolve, 80);
143 });
144 announceRun(forge, done);
145 });
146 await settleApp(app);
147 expect(app.screen.toText()).not.toContain("SNEAKED");
148 });
149
150 test("another run's events do not reread this one", async () => {
151 const run = createRun(forge.db, { repoId: repo.id, kind: "agent" });
152 claimRun(forge.db, run.id);
153 appendRunLog(dir, run.id, "mine");
154 const other = createRun(forge.db, { repoId: repo.id, kind: "agent" });
155 const app = await open(alice, run.id);
156 appendFileSync(runLogPath(dir, run.id), "SNEAKED\n");
157 announceRun(forge, other);
158 await new Promise((resolve) => setTimeout(resolve, 120));
159 await settleApp(app);
160 expect(app.screen.toText()).not.toContain("SNEAKED");
161 });
162
163 test("r rereads the log, and o prints the web address", async () => {
164 const run = createRun(forge.db, { repoId: repo.id, kind: "agent" });
165 claimRun(forge.db, run.id);
166 appendRunLog(dir, run.id, "before");
167 const app = await open(alice, run.id);
168 appendFileSync(runLogPath(dir, run.id), "BEHIND-THE-LISTENER\n");
169 await key(app, "r");
170 expect(app.screen.toText()).toContain("BEHIND-THE-LISTENER");
171 await key(app, "o");
172 expect(app.screen.toText()).toContain(`/alice/lamp/runs/${run.id}`);
173 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs