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

Contraction: remove the TUI's apps, runs and crew screens #412

Merged opened this

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

Merged

mason/forge:mason/398-contraction-remove-the-tui-s-apps-runs-a → main 2 commits fork

  • Merged. mason/398-contraction-remove-the-tui-s-apps-runs-a was merged into main as 31b6158.

Review

Sign in to review this change.
17 files changed +203 −2195
docs/DESIGN.md +19 −35
@@ -603,10 +603,9 @@ something you can click" is the whole claim.
603603
604604 ### 6·0a. The dashboard's panes
605605
606 `Apps` (only when there are any; `a` opens the apps screen regardless),
607606 `Repositories`, `Recent activity`, `On you`, `On your machine`,
608 `Organizations`. Each is
609 a pane with its own cursor: `tab` / `shift+tab` cycles, the focused one wears
607 `Organizations`. What is running is the studio's question, on the web; the
608 terminal's dashboard is the code and the conversations. Each is a pane with its own cursor: `tab` / `shift+tab` cycles, the focused one wears
610609 the accent frame, `j`/`k` move inside it and `enter` opens what it is pointing
611610 at — an activity row opens **the issue, pull or repository it is about**, a
612611 pin opens its repository, an organization opens its page. `d` on a pinned row
@@ -1050,50 +1049,35 @@ there is no lifecycle to get wrong. A burst is coalesced into one refresh.
10501049
10511050 ---
10521051
1053 ### 6g. Apps, runs and the crew
1052 ### 6g. Apps, runs and the crew, where the terminal still shows them
10541053
1055 The terminal's half of §5c. `:apps`, `:new app`, `:runs` and `:app` (on a
1056 repository) and `:crew` (admins) reach everything from the palette.
1054 The terminal's half of §5c is deliberately small: running an app and
1055 managing the crew are the studio's (`docs/STUDIO.md`), and the terminal has
1056 no apps, app, runs, run or crew screen and no palette entry for one. What
1057 remains is where the work is reviewed:
10571058
1058 - **Apps** — every app you can see: a state dot and word, the production URL,
1059 the preview count. `enter` opens the app; `n` is New app as a flow of
1060 prompts (name, a sentence, visibility, an agent when any exist) that lands on
1061 the repository with a toast naming the URL and the agent.
1062 - **App** — environments production-first, then previews by pull number, each
1063 with state, commit, since, and on failure the error in words. `o` opens the
1064 environment through `openEnvUrl` (an OSC 8 link plus an OSC 52 copy, with the
1065 honest toast over SSH); `enter` opens its run's log; `r` redeploys, `s` stops
1066 behind a confirm; `l` lists the runs. Live on the repository's `run.*` and
1067 `app.*` events.
1068 - **Runs** and **Run** — the list names each run's conversation (`#n title`)
1069 through one grouped read; the run screen subscribes to the log before it
1070 reads it (both synchronous, so no line is missed or doubled), follows the
1071 tail until you scroll, `G` re-follows, `x` cancels for writers.
10721059 - **Pull** — a preview line when the repository is an app (`O` opens it,
10731060 `P` opens production beside it: comparing the two is the review), a
10741061 checks row in the merge box and a _waiting on checks_ state with the refusal
10751062 in words on `m`, `a` to assign (nobody, people, then the crew with the bot
10761063 glyph on a Nerd Font), and a Crew tab of the agent runs about it.
1077 - **Dashboard** — `o` on the Apps pane opens the app under the cursor in a
1078 browser, through the same grant a private app needs; `t` opens its
1079 terminal right here.
1080 - **`t` anywhere an app is** (the dashboard's Apps pane, the apps list, an
1081 environment on the app screen, `T` for a pull's preview) suspends the UI
1082 and runs the app's `tty` on the same pty — one connection, two programs in
1083 turn, exactly as `$FORGE_EDITOR` runs — and the UI returns when it exits.
1084 Every refusal (not running, no `tty`, private to a guest) is a toast in
1085 the same words the ssh front door uses.
1064 - **`T` on a pull's preview** suspends the UI and runs the app's `tty` on
1065 the same pty — one connection, two programs in turn, exactly as
1066 `$FORGE_EDITOR` runs — and the UI returns when it exits. Every refusal
1067 (not running, no `tty`, private to a guest) is a toast in the same words
1068 the ssh front door uses.
10861069 - **Issue** — `a` to assign; a Crew card when an agent has runs, `tab` moves
1087 focus to it and `enter` opens the run.
1070 focus to it.
1071 - **A run** is a row (`screens/runlist.ts`): state dot and word, who, why,
1072 the summary. `enter` on one hands over its web page,
1073 `/:owner/:name/runs/:id`, where the live log, cancel and steer are — as a
1074 link when the forge knows its public URL, as the path otherwise.
10881075 - **Settings** — the App tab (enable with slug, access and preview data; edit;
10891076 `D` to stop running as an app behind a confirm), `C` for require checks on
10901077 General, the Crew tab (`a` adds an agent from the forge's, `d` removes).
1091 - **Crew** (admins, from Account with `c`) — the roster with model and budget,
1092 and the first line says whether Claude Code can run at all. `n` creates
1093 through five prompts, `enter` edits a field, `d` retires behind a confirm.
10941078
1095 A guest, or a non-admin at the crew screen, is told so in words; nothing here
1096 is a way around the actions' answers.
1079 A guest is told so in words; nothing here is a way around the actions'
1080 answers.
10971081
10981082 ## 7. Two rules that are security boundaries
10991083
src/tui/forge.ts −35
@@ -19,8 +19,6 @@ import { fileIcon, icon, type IconName } from "./ui/icons.ts";
1919 import type { SessionOpts, Terminal } from "./term/terminal.ts";
2020 import { Session, type TuiIdentity } from "./session.ts";
2121 import { accountScreen } from "./screens/account.ts";
22 import { appScreen } from "./screens/app.ts";
23 import { appsScreen, newAppFlow } from "./screens/apps.ts";
2422 import {
2523 ISSUE_MARK,
2624 ORIGIN_MARK,
@@ -28,9 +26,7 @@ import {
2826 errorText,
2927 promptScreen,
3028 } from "./screens/common.ts";
31 import { crewScreen } from "./screens/crew.ts";
3229 import { issueScreen } from "./screens/issue.ts";
33 import { runsScreen } from "./screens/runs.ts";
3430 import { pullScreen } from "./screens/pull.ts";
3531 import { blobScreen } from "./screens/repo.ts";
3632 import { dashboardScreen } from "./screens/dashboard.ts";
@@ -73,16 +69,6 @@ const REPO_VERBS: readonly {
7369 },
7470 { id: "new-issue", title: "New issue", open: newIssueScreen },
7571 { id: "new-pull", title: "New pull request", open: newPullScreen },
76 {
77 id: "runs",
78 title: "Runs in a repository",
79 open: (f, ctx, repo) => runsScreen(f, ctx, repo),
80 },
81 {
82 id: "app",
83 title: "Open an app",
84 open: (f, ctx, repo) => appScreen(f, ctx, repo),
85 },
8672 { id: "settings", title: "Repository settings", open: settingsScreen },
8773 ];
8874
@@ -105,12 +91,6 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
10591 title: "Explore repositories",
10692 run: () => ctx.push(exploreScreen(session, ctx)),
10793 },
108 {
109 id: "apps",
110 title: "Apps",
111 hint: "what is running",
112 run: () => ctx.push(appsScreen(session, ctx)),
113 },
11494 {
11595 id: "profile",
11696 title: "Open a profile",
@@ -163,27 +143,12 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
163143 );
164144 if (session.signedIn)
165145 commands.push(
166 {
167 id: "new-app",
168 title: "New app",
169 hint: "a repository that runs",
170 // The flow is modals over whatever is on the stack; the palette
171 // closed already, so they draw over the screen beneath it.
172 run: () => newAppFlow(session, ctx, blank("New app")),
173 },
174146 {
175147 id: "account",
176148 title: "Account settings",
177149 run: () => ctx.push(accountScreen(session, ctx)),
178150 },
179151 );
180 if (session.user?.isAdmin === true)
181 commands.push({
182 id: "crew",
183 title: "Crew",
184 hint: "the agents on this forge",
185 run: () => ctx.push(crewScreen(session, ctx)),
186 });
187152 commands.push({
188153 id: "quit",
189154 title: "Quit",
src/tui/screens/account.ts +1 −12
@@ -38,7 +38,6 @@ import {
3838 ago,
3939 runAsync,
4040 } from "./common.ts";
41 import { crewScreen } from "./crew.ts";
4241
4342 const TABS = ["Profile", "Tokens", "SSH keys"] as const;
4443
@@ -58,29 +57,23 @@ export function accountScreen(f: Session, ctx: Ctx): Screen {
5857 const screen: Screen = {
5958 title: "Account",
6059 get keys() {
61 // The crew is a forge admin's to manage, so the key is only there for
62 // one — a non-admin would land on a screen that says no to everything.
63 const crew = f.user?.isAdmin === true ? [binding(["c"], "crew")] : [];
6460 if (tab === 0)
6561 return [
6662 binding(["tab"], "next tab"),
6763 binding(["d"], "display name"),
6864 binding(["e"], "email"),
6965 binding(["p"], "password"),
70 ...crew,
7166 ];
7267 if (tab === 1)
7368 return [
7469 binding(["tab"], "next tab"),
7570 binding(["n"], "new token"),
7671 binding(["D"], "revoke"),
77 ...crew,
7872 ];
7973 return [
8074 binding(["tab"], "next tab"),
8175 binding(["n"], "add key"),
8276 binding(["D"], "delete key"),
83 ...crew,
8477 ];
8578 },
8679 load() {
@@ -100,7 +93,7 @@ export function accountScreen(f: Session, ctx: Ctx): Screen {
10093 ["username", f.user.username],
10194 ["display name", f.user.displayName || "(none)"],
10295 ["email", f.user.email],
103 ["role", f.user.isAdmin ? "site admin — c manages the crew" : "user"],
96 ["role", f.user.isAdmin ? "site admin" : "user"],
10497 ["joined", ago(f.user.createdAt)],
10598 ]);
10699 return;
@@ -144,10 +137,6 @@ export function accountScreen(f: Session, ctx: Ctx): Screen {
144137 state.index = 0;
145138 return true;
146139 }
147 if (e.key === "c" && f.user?.isAdmin === true) {
148 ctx.push(crewScreen(f, ctx));
149 return true;
150 }
151140 if (tab === 0) return profileKey(e);
152141 if (tab === 1) return tokenKey(e);
153142 return keyKey(e);
src/tui/screens/app.ts deleted −378
@@ -1,378 +0,0 @@
1 /**
2 * One app: its environments, what each is doing, and the four things a
3 * person does about one — open it, read its log, deploy it again, stop it.
4 *
5 * Production is the first row and a preview per open pull follows, in pull
6 * order. Every state is a word beside a dot, and a failed environment
7 * carries its error on the row and in full in the panel beneath, because
8 * "failed" alone is the one thing this screen must never say.
9 *
10 * Opening a private app goes through `openEnvUrl`, which returns a URL
11 * carrying a signed grant; the screen hands that to `openLink`, so over SSH
12 * it lands on the reader's clipboard with words that say so.
13 */
14
15 import {
16 appView,
17 openEnvUrl,
18 redeployEnv,
19 sshCommandFor,
20 stopEnv,
21 type AppView,
22 } from "../../actions/apps.ts";
23 import { tellCrew } from "../../actions/crew.ts";
24 import type { EnvStatus } from "../../apps/runtime.ts";
25 import { envPull } from "../../apps/data.ts";
26 import { getIssuesByRef } from "../../core/issues.ts";
27 import { canWrite, type Repo } from "../../core/repos.ts";
28 import type { Ctx, Screen } from "../app/app.ts";
29 import { binding } from "../app/keys.ts";
30 import { ITALIC, style } from "../term/style.ts";
31 import { truncate } from "../term/width.ts";
32 import { fill, len, split } from "../ui/layout.ts";
33 import { card, emptyState } from "../ui/widgets.ts";
34 import type { Session } from "../session.ts";
35 import {
36 ListState,
37 ago,
38 confirmScreen,
39 drawFacts,
40 drawList,
41 errorText,
42 hitList,
43 live,
44 metaOf,
45 openLink,
46 promptScreen,
47 runAsync,
48 type ListRow,
49 } from "./common.ts";
50 import { APP_EVENTS, envTone } from "./apps.ts";
51 import { runScreen } from "./run.ts";
52 import { envLabel, runsScreen } from "./runs.ts";
53 import { local, whereRows, whereStrip } from "./where.ts";
54 import { openAppTerminal } from "./apptty.ts";
55
56 /** The panel under the list: state, commit, url, since, run, error. */
57 const PANEL = 8;
58
59 export function appScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
60 let view: AppView | undefined;
61 let titles = new Map<string, { title: string }>();
62 let failed: string | undefined;
63 const state = new ListState();
64 const src = local(f, repo);
65 const mayWrite = (): boolean => canWrite(f.db, repo, f.user);
66
67 const reload = (): void => {
68 const read = appView(f.app, f.user, repo);
69 if (!read.ok) {
70 failed =
71 read.error === "not_found"
72 ? "this repository is not an app"
73 : errorText(read.error);
74 ctx.invalidate();
75 return;
76 }
77 failed = undefined;
78 view = read.value;
79 // The pull behind each preview, named — one grouped read for the page.
80 titles = getIssuesByRef(
81 f.db,
82 view.environments.flatMap((env) => {
83 const number = envPull(env.env);
84 return number === undefined ? [] : [{ repoId: repo.id, number }];
85 }),
86 );
87 ctx.invalidate();
88 };
89
90 const envs = (): EnvStatus[] => view?.environments ?? [];
91 const selected = (): EnvStatus | undefined => envs()[state.index];
92
93 const nameOf = (env: EnvStatus): string => {
94 const number = envPull(env.env);
95 if (number === undefined) return "production";
96 const title = titles.get(`${repo.id}#${number}`)?.title;
97 return title === undefined ? `pull #${number}` : `pull #${number} ${title}`;
98 };
99
100 /** The one line that says what an environment is doing, in words. */
101 const stateText = (env: EnvStatus): string => {
102 switch (env.state) {
103 case "running":
104 return env.since === undefined
105 ? "running"
106 : `running since ${ago(env.since)}`;
107 case "deploying":
108 return `deploying ${env.commitOid.slice(0, 7)}`;
109 case "failed":
110 return `failed: ${env.error ?? "the deploy did not finish"}`;
111 case "asleep":
112 return env.since === undefined
113 ? "asleep; wakes on a request"
114 : `asleep since ${ago(env.since)}; wakes on a request`;
115 case "stopped":
116 return env.dormant === undefined
117 ? "stopped"
118 : `asleep since ${ago(env.dormant.since)}; nobody touched the change since ${ago(env.dormant.idleSince)}`;
119 }
120 };
121
122 const open = (env: EnvStatus | undefined): void => {
123 if (env === undefined) return;
124 const url = openEnvUrl(f.app, f.user, repo, env.env);
125 if (!url.ok) {
126 ctx.toast(errorText(url.error), "danger");
127 return;
128 }
129 if (env.state !== "running")
130 ctx.toast(
131 `${envLabel(env.env)} is ${env.state}; the page will say so`,
132 "attn",
133 );
134 openLink(ctx, url.value);
135 };
136
137 const openLog = (env: EnvStatus | undefined): void => {
138 if (env === undefined) return;
139 if (env.runId === undefined) {
140 ctx.toast(`${envLabel(env.env)} has no run yet`, "attn");
141 return;
142 }
143 ctx.push(runScreen(f, ctx, repo, env.runId));
144 };
145
146 const redeploy = (env: EnvStatus | undefined): void => {
147 if (env === undefined) return;
148 const label = envLabel(env.env);
149 runAsync(ctx, async () => {
150 ctx.toast(`redeploying ${label}…`);
151 const queued = await redeployEnv(f.app, f.user, repo, env.env);
152 if (!queued.ok) {
153 ctx.toast(errorText(queued.error), "danger");
154 return;
155 }
156 ctx.toast(
157 queued.value === undefined
158 ? `${label}: nothing to do`
159 : `${label}: deploy queued — enter opens its log`,
160 "success",
161 );
162 reload();
163 });
164 };
165
166 const stop = (env: EnvStatus | undefined): void => {
167 if (env === undefined) return;
168 const label = envLabel(env.env);
169 ctx.push(
170 confirmScreen(ctx, screen, {
171 title: `Stop ${label}`,
172 body: [
173 `${label} goes offline until it is deployed again.`,
174 "Its data is kept; r brings it back.",
175 ],
176 actions: [
177 { label: "Keep it running", variant: "ghost" },
178 { label: "Stop", variant: "danger" },
179 ],
180 onChoose: (choice) => {
181 if (choice !== 1) return;
182 runAsync(ctx, async () => {
183 ctx.toast(`stopping ${label}…`);
184 const stopped = await stopEnv(f.app, f.user, repo, env.env);
185 if (!stopped.ok) ctx.toast(errorText(stopped.error), "danger");
186 else ctx.toast(`${label} stopped`, "success");
187 reload();
188 });
189 },
190 }),
191 );
192 };
193
194 /** `T`: a sentence becomes an issue, handed to whichever builder is free. */
195 const tell = (): void => {
196 ctx.push(
197 promptScreen(ctx, screen, {
198 title: "Tell the crew",
199 label: "What should change?",
200 hint: "Becomes an issue, and starts whichever builder on the crew is free.",
201 onSubmit: (text) => {
202 if (text === "") return "say something first";
203 return (async () => {
204 const told = await tellCrew(f.app, f.user, repo, text);
205 if (!told.ok) {
206 ctx.toast(errorText(told.error), "danger");
207 return;
208 }
209 const { routed } = told.value;
210 ctx.toast(
211 routed === undefined
212 ? "told, but nobody is on the crew to take it yet"
213 : `${routed.builder.user.username} is on it`,
214 "success",
215 );
216 })();
217 },
218 }),
219 );
220 };
221
222 const screen: Screen = {
223 title: `${repo.ownerName}/${repo.name} · app`,
224 get keys() {
225 const writer = mayWrite();
226 return [
227 binding(["j", "k"], "move"),
228 binding(["o"], "open"),
229 binding(["t"], "terminal"),
230 binding(["T"], "tell the crew", { disabled: !writer }),
231 binding(["enter"], "log"),
232 binding(["r"], "redeploy", { disabled: !writer }),
233 binding(["s"], "stop", { disabled: !writer }),
234 binding(["l"], "runs"),
235 binding(["R"], "refresh"),
236 ];
237 },
238 load(signal) {
239 live(f, signal, { repoId: repo.id, kinds: APP_EVENTS }, reload);
240 reload();
241 return Promise.resolve();
242 },
243 render(area, s) {
244 const c = ctx.theme.c;
245 const [strip, head, list, panel] = split(area, "v", [
246 len(whereRows(src)),
247 len(2),
248 fill(1),
249 len(Math.min(PANEL, Math.max(0, area.h - whereRows(src) - 6))),
250 ]);
251 whereStrip(s, strip!, c, f, src, ctx.icons);
252 if (failed !== undefined) {
253 emptyState(s, list!, c, { title: failed });
254 return;
255 }
256 if (view === undefined) return;
257 const right = head!.x + head!.w;
258 s.text(
259 head!.x,
260 head!.y,
261 truncate(
262 metaOf(
263 `slug ${view.app.slug}`,
264 `${view.app.access} app`,
265 view.app.previewData === "copy"
266 ? "previews start from a copy of production's data"
267 : "previews start empty",
268 ),
269 head!.w,
270 ),
271 style(c.mutedFg, c.bg),
272 right,
273 );
274 if (!view.serving)
275 s.text(
276 head!.x,
277 head!.y + 1,
278 truncate(errorText("no_runtime"), head!.w),
279 style(c.attn, c.bg, ITALIC),
280 right,
281 );
282 const rows = envs();
283 if (rows.length === 0) {
284 emptyState(s, list!, c, {
285 title: view.serving ? "Nothing running yet" : "Nothing runs",
286 body: view.serving
287 ? "Production starts from the default branch once it has a commit."
288 : errorText("no_runtime"),
289 });
290 return;
291 }
292 drawList(
293 s,
294 list!,
295 c,
296 state,
297 rows.map((env): ListRow => {
298 const tone = envTone(env.state);
299 return {
300 title: nameOf(env),
301 ...(tone === undefined ? { hollow: true } : { tone }),
302 secondary: stateText(env),
303 meta: metaOf(
304 env.commitOid === "" ? "" : env.commitOid.slice(0, 7),
305 env.since === undefined ? "" : ago(env.since),
306 ),
307 };
308 }),
309 );
310 hitList(ctx, list!, state, rows.length, {
311 open: (index) => openLog(rows[index]),
312 });
313 const env = selected();
314 if (env === undefined || panel!.h < 3) return;
315 const inner = card(s, panel!, c, {
316 title: nameOf(env),
317 tone: env.state === "failed" ? "danger" : "default",
318 });
319 drawFacts(
320 s,
321 inner,
322 c,
323 [
324 ["state", stateText(env)],
325 ["commit", env.commitOid === "" ? "—" : env.commitOid],
326 ["url", env.url],
327 ...(sshCommandFor(f.app, view.app, env.env) === undefined
328 ? []
329 : [
330 ["ssh", sshCommandFor(f.app, view.app, env.env) ?? ""] as const,
331 ]),
332 ["since", env.since === undefined ? "—" : ago(env.since)],
333 ["run", env.runId === undefined ? "none yet" : "enter opens the log"],
334 ...(env.error === undefined ? [] : [["error", env.error] as const]),
335 ],
336 c.card,
337 );
338 },
339 key(e) {
340 if (state.key(e, envs().length)) return true;
341 if (e.key === "enter") {
342 openLog(selected());
343 return true;
344 }
345 if (e.key === "o") {
346 open(selected());
347 return true;
348 }
349 if (e.key === "t") {
350 const env = selected();
351 if (env) void openAppTerminal(f, ctx, repo, env.env);
352 return true;
353 }
354 if (e.key === "T" && mayWrite()) {
355 tell();
356 return true;
357 }
358 if (e.key === "r" && mayWrite()) {
359 redeploy(selected());
360 return true;
361 }
362 if (e.key === "s" && mayWrite()) {
363 stop(selected());
364 return true;
365 }
366 if (e.key === "l") {
367 ctx.push(runsScreen(f, ctx, repo));
368 return true;
369 }
370 if (e.key === "R") {
371 reload();
372 return true;
373 }
374 return false;
375 },
376 };
377 return screen;
378 }
src/tui/screens/apps.ts deleted −382
@@ -1,382 +0,0 @@
1 /**
2 * Every app this session can see, and the way to make a new one.
3 *
4 * An app row is a repository row with one more fact: whether it is running.
5 * That fact is read from the runtime's in-memory picture, never from a row
6 * (a process is not state — docs/APPS.md), and the app records for the
7 * whole page come from one grouped query, so the list costs the same at
8 * two apps as at two hundred.
9 *
10 * `n` is the golden path's first step as a flow of small modals: a name, an
11 * optional sentence, who may see it, who works on it. Each is one screen
12 * that does one thing, and the whole is a single `newApp` call at the end.
13 */
14
15 import { newApp } from "../../actions/apps.ts";
16 import { listAllAgents } from "../../actions/crew.ts";
17 import { planningAvailable } from "../../crew/plan.ts";
18 import type { Agent } from "../../core/agents.ts";
19 import { appsForRepos, type AppRecord } from "../../core/apps.ts";
20 import { listReposVisibleTo, type Repo } from "../../core/repos.ts";
21 import {
22 appsRuntime,
23 type EnvState,
24 type EnvStatus,
25 } from "../../apps/runtime.ts";
26 import type { Ctx, Screen } from "../app/app.ts";
27 import { binding } from "../app/keys.ts";
28 import { fill, len, split } from "../ui/layout.ts";
29 import { icon, type IconSet } from "../ui/icons.ts";
30 import { emptyState, type Tone } from "../ui/widgets.ts";
31 import type { Session } from "../session.ts";
32 import {
33 Filter,
34 ListState,
35 drawList,
36 errorText,
37 hitList,
38 later,
39 live,
40 metaOf,
41 pickerScreen,
42 plural,
43 promptScreen,
44 runAsync,
45 type ListRow,
46 } from "./common.ts";
47 import { appScreen } from "./app.ts";
48 import { repoScreen } from "./repo.ts";
49 import { RUN_EVENTS } from "./runs.ts";
50 import { local } from "./where.ts";
51 import { openAppTerminal } from "./apptty.ts";
52
53 /** The event kinds that change what an app list or an app screen shows. */
54 export const APP_EVENTS: readonly string[] = [
55 ...RUN_EVENTS,
56 "app.created",
57 "app.updated",
58 "app.deleted",
59 "app.stopped",
60 ];
61
62 /** The tone an environment's state wears; `stopped` wears none. */
63 export function envTone(state: EnvState): Tone | undefined {
64 switch (state) {
65 case "running":
66 return "success";
67 case "deploying":
68 return "attn";
69 case "failed":
70 return "danger";
71 case "stopped":
72 return undefined;
73 }
74 }
75
76 export interface AppItem {
77 readonly repo: Repo;
78 readonly app: AppRecord;
79 /** Production's state, or undefined before the runtime has looked at it. */
80 readonly production: EnvStatus | undefined;
81 readonly previews: number;
82 readonly url: string;
83 /** False when apps are off on this forge: nothing runs, rows still exist. */
84 readonly serving: boolean;
85 }
86
87 /**
88 * Every app among the repositories the session may read. Visibility is the
89 * repository's, decided by `listReposVisibleTo`, exactly as the dashboard's
90 * repo list decides it — an app is never more visible than its code. A
91 * caller that already listed the repositories hands them in, so a page
92 * that shows both never asks for the same rows (and their counts) twice.
93 */
94 export function listVisibleApps(
95 f: Session,
96 repos: readonly Repo[] = listReposVisibleTo(f.db, f.user, { limit: 200 }),
97 ): AppItem[] {
98 const records = appsForRepos(
99 f.db,
100 repos.map((repo) => repo.id),
101 );
102 const runtime = appsRuntime(f.app);
103 const out: AppItem[] = [];
104 for (const repo of repos) {
105 const app = records.get(repo.id);
106 if (app === undefined) continue;
107 const envs = runtime === undefined ? [] : runtime.status(app);
108 const production = envs.find((env) => env.env === "production");
109 out.push({
110 repo,
111 app,
112 production,
113 previews: envs.length - (production === undefined ? 0 : 1),
114 url: runtime === undefined ? "" : runtime.urlFor(app, "production"),
115 serving: runtime !== undefined,
116 });
117 }
118 return out;
119 }
120
121 /** The word an app row wears for its production state. */
122 export function appStateWord(item: AppItem): string {
123 if (!item.serving) return "off";
124 if (item.production === undefined) return "not started";
125 return item.production.state;
126 }
127
128 /** An app as a list row — the dashboard pane and the apps screen share it. */
129 export function appRow(item: AppItem, set: IconSet): ListRow {
130 const tone =
131 item.serving && item.production !== undefined
132 ? envTone(item.production.state)
133 : undefined;
134 return {
135 title: `${set === "nerd" ? `${icon("repo", set)} ` : ""}${item.repo.ownerName}/${item.repo.name}`,
136 ...(tone === undefined ? { hollow: true } : { tone }),
137 secondary: item.url,
138 meta: metaOf(
139 appStateWord(item),
140 item.previews > 0 ? plural(item.previews, "preview") : "",
141 ),
142 };
143 }
144
145 export function appsScreen(f: Session, ctx: Ctx): Screen {
146 let apps: AppItem[] = [];
147 const state = new ListState();
148 const filter = new Filter();
149
150 const reload = (): void => {
151 apps = listVisibleApps(f);
152 ctx.invalidate();
153 };
154
155 const shown = (): AppItem[] =>
156 apps.filter((item) =>
157 filter.matches(`${item.repo.ownerName}/${item.repo.name}`),
158 );
159
160 const open = (item: AppItem | undefined): void => {
161 if (item !== undefined) ctx.push(appScreen(f, ctx, item.repo));
162 };
163
164 const screen: Screen = {
165 title: "Apps",
166 get keys() {
167 return [
168 binding(["j", "k"], "move"),
169 binding(["enter"], "open"),
170 binding(["t"], "terminal"),
171 binding(["n"], "new app", { disabled: !f.signedIn }),
172 binding(["/"], "filter"),
173 binding(["r"], "refresh"),
174 ];
175 },
176 load(signal) {
177 live(f, signal, { kinds: APP_EVENTS }, reload);
178 reload();
179 return Promise.resolve();
180 },
181 render(area, s) {
182 const c = ctx.theme.c;
183 const [box, list] = split(area, "v", [len(1), fill(1)]);
184 ctx.setCursor(filter.render(s, box!, c, "filter apps"));
185 const rows = shown();
186 if (rows.length === 0) {
187 emptyState(s, list!, c, {
188 title: filter.on ? "Nothing matches" : "No apps yet",
189 body: filter.on
190 ? ""
191 : f.signedIn
192 ? "Press n to make one — a repository that runs."
193 : "Sign in to make one — a repository that runs.",
194 });
195 return;
196 }
197 drawList(
198 s,
199 list!,
200 c,
201 state,
202 rows.map((item) => appRow(item, ctx.icons)),
203 );
204 hitList(ctx, list!, state, rows.length, {
205 open: (index) => open(rows[index]),
206 });
207 },
208 key(e) {
209 if (filter.key(e)) {
210 state.clamp(shown().length);
211 return true;
212 }
213 if (state.key(e, shown().length)) return true;
214 if (e.key === "enter") {
215 open(shown()[state.index]);
216 return true;
217 }
218 if (e.key === "t") {
219 const item = shown()[state.index];
220 if (item) void openAppTerminal(f, ctx, item.repo, "production");
221 return true;
222 }
223 if (e.key === "n" && f.signedIn) {
224 newAppFlow(f, ctx, screen);
225 return true;
226 }
227 if (e.key === "r") {
228 reload();
229 return true;
230 }
231 return false;
232 },
233 };
234 return screen;
235 }
236
237 /** An agent as a picker row: the bot mark where the font has one. */
238 export function agentPick(
239 agent: Agent,
240 set: IconSet,
241 ): { id: string; label: string; hint: string } {
242 return {
243 id: agent.user.username,
244 label:
245 set === "nerd"
246 ? `${icon("bot", set)} ${agent.user.username}`
247 : agent.user.username,
248 hint: metaOf("agent", agent.model === "" ? "" : agent.model),
249 };
250 }
251
252 /**
253 * New app, as four modals and one call. Each prompt hands its answer to the
254 * next through the closure; nothing is created until the last one answers,
255 * so Esc anywhere leaves the forge exactly as it was.
256 */
257 export function newAppFlow(f: Session, ctx: Ctx, parent: Screen): void {
258 const create = (input: {
259 name: string;
260 description: string;
261 visibility: "public" | "private";
262 agent: string | undefined;
263 pickAgent?: boolean;
264 }): void => {
265 runAsync(ctx, async () => {
266 ctx.toast(`creating ${input.name}…`);
267 const made = await newApp(f.app, f.user, {
268 name: input.name,
269 visibility: input.visibility,
270 ...(input.description === "" ? {} : { description: input.description }),
271 ...(input.agent === undefined ? {} : { agent: input.agent }),
272 ...(input.pickAgent ? { pickAgent: true } : {}),
273 });
274 if (!made.ok) {
275 ctx.toast(errorText(made.error), "danger");
276 return;
277 }
278 const runtime = appsRuntime(f.app);
279 const url =
280 runtime === undefined
281 ? undefined
282 : runtime.urlFor(made.value.app, "production");
283 const where =
284 url === undefined
285 ? "apps are off on this forge, so nothing is running"
286 : `starting at ${url}`;
287 const who = made.value.plan
288 ? ` · ${made.value.plan.agent} was picked — ${made.value.plan.summary}`
289 : input.agent === undefined
290 ? made.value.issue === undefined
291 ? ""
292 : ` · #${made.value.issue.number} is waiting for someone`
293 : made.value.issue === undefined
294 ? ` · ${input.agent} is on the crew`
295 : ` · ${input.agent} is on #${made.value.issue.number}`;
296 ctx.toast(`${input.name}: ${where}${who}`, "success");
297 ctx.push(repoScreen(f, ctx, local(f, made.value.repo)));
298 });
299 };
300
301 const askAgent = (
302 input: Parameters<typeof create>[0],
303 agents: readonly Agent[],
304 ): void => {
305 if (agents.length === 0) {
306 create(input);
307 return;
308 }
309 const canPick =
310 input.description !== "" &&
311 planningAvailable(process.env["FORGE_CLAUDE"] ?? "claude");
312 ctx.push(
313 pickerScreen(ctx, parent, {
314 title: "Who works on it?",
315 items: [
316 ...(canPick
317 ? [
318 {
319 id: "__pick__",
320 label: "Pick for me",
321 hint: "turns the sentence into a checklist and picks",
322 },
323 ]
324 : []),
325 { id: "", label: "nobody yet", hint: "you can add crew later" },
326 ...agents.map((agent) => agentPick(agent, ctx.icons)),
327 ],
328 onPick: ([chosen]) =>
329 chosen === "__pick__"
330 ? create({ ...input, agent: undefined, pickAgent: true })
331 : create({
332 ...input,
333 agent: chosen === undefined || chosen === "" ? undefined : chosen,
334 }),
335 }),
336 );
337 };
338
339 const askVisibility = (name: string, description: string): Screen =>
340 pickerScreen(ctx, parent, {
341 title: "Who may see it?",
342 items: [
343 { id: "private", label: "private", hint: "you and the crew" },
344 { id: "public", label: "public", hint: "anyone" },
345 ],
346 onPick: ([chosen]) => {
347 const listed = listAllAgents(f.app, f.user);
348 askAgent(
349 {
350 name,
351 description,
352 visibility: chosen === "public" ? "public" : "private",
353 agent: undefined,
354 },
355 listed.ok ? listed.value : [],
356 );
357 },
358 });
359
360 const askDescription = (name: string): Screen =>
361 promptScreen(ctx, parent, {
362 title: `What should ${name} do?`,
363 label: "One sentence — optional",
364 placeholder: "A place for notes",
365 hint: "Becomes the first issue, handed to an agent on the crew.",
366 onSubmit: (description) =>
367 later(ctx, () => askVisibility(name, description)),
368 });
369
370 ctx.push(
371 promptScreen(ctx, parent, {
372 title: "New app",
373 label: "Name",
374 placeholder: "notes",
375 hint: "Letters, digits, dot, dash, underscore. Its URL is built from this.",
376 onSubmit: (name) => {
377 if (name === "") return "a name is required";
378 return later(ctx, () => askDescription(name));
379 },
380 }),
381 );
382 }
src/tui/screens/assign.ts +22 −2
@@ -13,6 +13,7 @@
1313 */
1414
1515 import { listCrew } from "../../actions/crew.ts";
16 import type { Agent } from "../../core/agents.ts";
1617 import { assignIssue } from "../../actions/issues.ts";
1718 import type { Issue } from "../../core/issues.ts";
1819 import { canRead, type Repo } from "../../core/repos.ts";
@@ -20,8 +21,27 @@ import { getUserById, listPeople, type User } from "../../core/users.ts";
2021 import type { Ctx, Screen } from "../app/app.ts";
2122 import { icon, type IconSet } from "../ui/icons.ts";
2223 import type { Session } from "../session.ts";
23 import { agentPick } from "./apps.ts";
24 import { errorText, pickerScreen, type PickItem } from "./common.ts";
24 import {
25 errorText,
26 metaOf,
27 pickerScreen,
28 type PickItem,
29 } from "./common.ts";
30
31 /** An agent as a picker row: the bot mark where the font has one. */
32 export function agentPick(
33 agent: Agent,
34 set: IconSet,
35 ): { id: string; label: string; hint: string } {
36 return {
37 id: agent.user.username,
38 label:
39 set === "nerd"
40 ? `${icon("bot", set)} ${agent.user.username}`
41 : agent.user.username,
42 hint: metaOf("agent", agent.model === "" ? "" : agent.model),
43 };
44 }
2545
2646 /**
2747 * ` · assigned to robo` after the opener. An agent wears the bot mark where
src/tui/screens/crew.ts deleted −389
@@ -1,389 +0,0 @@
1 /**
2 * The crew: every agent on this forge, and the admin's three verbs over
3 * them — make one, change one, retire one.
4 *
5 * An agent is an account that acts, so the screen is a forge admin's; the
6 * actions refuse anyone else and this screen says so rather than hiding
7 * the keys and leaving a person wondering. The first line says whether the
8 * crew can work at all — a Claude Code was found, or it was not and here is
9 * why — because assigning an issue to an agent that cannot run is the one
10 * mistake worth preventing before it is made (docs/APPS.md).
11 */
12
13 import { MODELS } from "../../crew/models.ts";
14 import {
15 createAgent,
16 deleteAgent,
17 listAllAgents,
18 updateAgent,
19 } from "../../actions/crew.ts";
20 import { crewFor } from "../../crew/dispatch.ts";
21 import type { Agent } from "../../core/agents.ts";
22 import type { Ctx, Screen } from "../app/app.ts";
23 import { binding } from "../app/keys.ts";
24 import { ITALIC, style } from "../term/style.ts";
25 import { truncate } from "../term/width.ts";
26 import { fill, len, split } from "../ui/layout.ts";
27 import { icon } from "../ui/icons.ts";
28 import { dot, emptyState } from "../ui/widgets.ts";
29 import type { Session } from "../session.ts";
30 import {
31 ListState,
32 confirmScreen,
33 drawList,
34 errorText,
35 hitList,
36 later,
37 live,
38 metaOf,
39 pickerScreen,
40 promptScreen,
41 runAsync,
42 type ListRow,
43 } from "./common.ts";
44
45 const CREW_EVENTS: readonly string[] = [
46 "agent.created",
47 "agent.updated",
48 "agent.deleted",
49 ];
50
51 /** What `n` asks, in order. */
52 const NEW_AGENT: readonly { key: string; label: string; hint: string }[] = [
53 {
54 key: "username",
55 label: "Username",
56 hint: "How it signs its pulls and comments.",
57 },
58 {
59 key: "displayName",
60 label: "Display name",
61 hint: "Optional; the username otherwise.",
62 },
63 {
64 key: "model",
65 label: "Model",
66 hint: "A Claude Code model alias or id. Empty means the runner's default.",
67 },
68 {
69 key: "brief",
70 label: "Brief",
71 hint: "A sentence or two in its own voice: who it is, how it works.",
72 },
73 {
74 key: "budget",
75 label: "Budget per run (USD)",
76 hint: "Empty means the forge's default (FORGE_CREW_BUDGET_USD).",
77 },
78 ];
79
80 /** `$5.00/run`, or the forge's default said as such. */
81 export function budgetText(budgetUsd: number): string {
82 return budgetUsd > 0 ? `$${budgetUsd.toFixed(2)}/run` : "default budget";
83 }
84
85 export function crewScreen(f: Session, ctx: Ctx): Screen {
86 let agents: Agent[] = [];
87 let failed: string | undefined;
88 const state = new ListState();
89 const admin = (): boolean => f.user?.isAdmin === true;
90
91 const reload = (): void => {
92 const read = listAllAgents(f.app, f.user);
93 if (!read.ok) {
94 failed = errorText(read.error);
95 ctx.invalidate();
96 return;
97 }
98 failed = undefined;
99 agents = read.value;
100 state.clamp(agents.length);
101 ctx.invalidate();
102 };
103
104 const selected = (): Agent | undefined => agents[state.index];
105
106 /**
107 * Five prompts, one call. The prompts are a list walked by index, each
108 * handing the next one on after the previous has popped; Esc anywhere
109 * makes nothing.
110 */
111 const create = (): void => {
112 const answers: Record<string, string> = {};
113 const step = (i: number): Screen => {
114 const field = NEW_AGENT[i]!;
115 return promptScreen(ctx, screen, {
116 title: "New agent",
117 label: field.label,
118 hint: field.hint,
119 // The display name defaults to the username just typed.
120 value: field.key === "displayName" ? (answers["username"] ?? "") : "",
121 onSubmit: (value) => {
122 if (field.key === "username" && value === "")
123 return "a username is required";
124 if (
125 field.key === "budget" &&
126 value !== "" &&
127 !Number.isFinite(Number(value))
128 )
129 return errorText("invalid_budget");
130 answers[field.key] = value;
131 if (i + 1 < NEW_AGENT.length) return later(ctx, () => step(i + 1));
132 return finish();
133 },
134 });
135 };
136 const finish = async (): Promise<void> => {
137 const username = answers["username"] ?? "";
138 const displayName = answers["displayName"] ?? "";
139 const model = answers["model"] ?? "";
140 const brief = answers["brief"] ?? "";
141 const budget = answers["budget"] ?? "";
142 ctx.toast(`creating ${username}…`);
143 const made = await createAgent(f.app, f.user, {
144 username,
145 ...(displayName === "" ? {} : { displayName }),
146 ...(model === "" ? {} : { model }),
147 ...(brief === "" ? {} : { brief }),
148 ...(budget === "" ? {} : { budgetUsd: Number(budget) }),
149 });
150 if (!made.ok) {
151 ctx.toast(errorText(made.error), "danger");
152 return;
153 }
154 ctx.toast(
155 `${made.value.user.username} is on the crew — add it to a repository to give it work`,
156 "success",
157 );
158 reload();
159 };
160 ctx.push(step(0));
161 };
162
163 /** `enter`: which fact to change, then the new value. */
164 const edit = (agent: Agent | undefined): void => {
165 if (agent === undefined) return;
166 const name = agent.user.username;
167 ctx.push(
168 pickerScreen(ctx, screen, {
169 title: `Edit ${name}`,
170 items: [
171 { id: "model", label: "model", hint: agent.model || "default" },
172 {
173 id: "brief",
174 label: "brief",
175 hint: firstLine(agent.brief) || "none",
176 },
177 {
178 id: "budget",
179 label: "budget per run",
180 hint: budgetText(agent.budgetUsd),
181 },
182 ],
183 onPick: ([field]) => {
184 if (field === undefined) return;
185 if (field === "model") {
186 ctx.push(
187 pickerScreen(ctx, screen, {
188 title: `${name} · model`,
189 items: MODELS.map((m) => ({
190 id: m.id === "" ? "default" : m.id,
191 label: m.label,
192 hint: m.id === agent.model ? "current" : "",
193 })),
194 onPick: ([id]) => {
195 if (id === undefined) return;
196 const done = updateAgent(f.app, f.user, name, {
197 model: id === "default" ? "" : id,
198 });
199 if (!done.ok) {
200 ctx.toast(errorText(done.error), "danger");
201 return;
202 }
203 ctx.toast(`${name} updated`, "success");
204 reload();
205 },
206 }),
207 );
208 return;
209 }
210 ctx.push(
211 promptScreen(ctx, screen, {
212 title: `${name} · ${field}`,
213 label:
214 field === "model"
215 ? "Model (empty for the default)"
216 : field === "brief"
217 ? "Brief"
218 : "Budget per run (USD; 0 for the default)",
219 value:
220 field === "model"
221 ? agent.model
222 : field === "brief"
223 ? agent.brief
224 : agent.budgetUsd === 0
225 ? ""
226 : String(agent.budgetUsd),
227 onSubmit: (value) => {
228 const patch =
229 field === "model"
230 ? { model: value }
231 : field === "brief"
232 ? { brief: value }
233 : { budgetUsd: value === "" ? 0 : Number(value) };
234 if ("budgetUsd" in patch && !Number.isFinite(patch.budgetUsd))
235 return errorText("invalid_budget");
236 const done = updateAgent(f.app, f.user, name, patch);
237 if (!done.ok) return errorText(done.error);
238 ctx.toast(`${name} updated`, "success");
239 reload();
240 return undefined;
241 },
242 }),
243 );
244 },
245 }),
246 );
247 };
248
249 const retire = (agent: Agent | undefined): void => {
250 if (agent === undefined) return;
251 const name = agent.user.username;
252 ctx.push(
253 confirmScreen(ctx, screen, {
254 title: `Retire ${name}`,
255 body: [
256 "Its tokens are revoked and it leaves every crew.",
257 "Pulls and comments it wrote stay, in its name.",
258 ],
259 actions: [
260 { label: "Keep it", variant: "ghost" },
261 { label: "Retire", variant: "danger" },
262 ],
263 onChoose: (choice) => {
264 if (choice !== 1) return;
265 const gone = deleteAgent(f.app, f.user, name);
266 if (!gone.ok) ctx.toast(errorText(gone.error), "danger");
267 else {
268 ctx.toast(`${name} retired`, "success");
269 reload();
270 }
271 },
272 }),
273 );
274 };
275
276 const screen: Screen = {
277 title: "Crew",
278 get keys() {
279 return [
280 binding(["j", "k"], "move"),
281 binding(["n"], "new agent", { disabled: !admin() }),
282 binding(["enter"], "edit", { disabled: !admin() }),
283 binding(["d"], "retire", { disabled: !admin() }),
284 binding(["r"], "refresh"),
285 ];
286 },
287 load(signal) {
288 live(f, signal, { kinds: CREW_EVENTS }, reload);
289 reload();
290 return Promise.resolve();
291 },
292 render(area, s) {
293 const c = ctx.theme.c;
294 const [head, list] = split(area, "v", [len(2), fill(1)]);
295 const right = head!.x + head!.w;
296 // The first line is whether the crew can work at all.
297 const handle = crewFor(f.app);
298 const ready = handle?.ready === true;
299 let x = dot(s, head!.x, head!.y, c, {
300 ...(handle === undefined
301 ? { hollow: true }
302 : { tone: ready ? "success" : "danger" }),
303 });
304 s.text(
305 x + 1,
306 head!.y,
307 truncate(
308 handle === undefined
309 ? "the crew is not running on this forge — agents can be made, but nothing will work until it is"
310 : ready
311 ? `${handle.detail} — agents can work`
312 : `Claude Code is not usable: ${handle.detail}`,
313 Math.max(0, right - x - 1),
314 ),
315 style(handle === undefined ? c.mutedFg : ready ? c.fg : c.danger, c.bg),
316 right,
317 );
318 if (!admin())
319 s.text(
320 head!.x,
321 head!.y + 1,
322 truncate("only a forge admin can change the crew", head!.w),
323 style(c.faint, c.bg, ITALIC),
324 right,
325 );
326 if (failed !== undefined) {
327 emptyState(s, list!, c, { title: failed });
328 return;
329 }
330 if (agents.length === 0) {
331 emptyState(s, list!, c, {
332 title: "No agents yet",
333 body: admin()
334 ? "Press n to make one: a name, a model, a brief."
335 : "A forge admin makes them.",
336 });
337 return;
338 }
339 const set = ctx.icons;
340 drawList(
341 s,
342 list!,
343 c,
344 state,
345 agents.map(
346 (agent): ListRow => ({
347 title:
348 set === "nerd"
349 ? `${icon("bot", set)} ${agent.user.username}`
350 : agent.user.username,
351 secondary: firstLine(agent.brief),
352 meta: metaOf(
353 agent.model === "" ? "default model" : agent.model,
354 budgetText(agent.budgetUsd),
355 ),
356 }),
357 ),
358 );
359 hitList(ctx, list!, state, agents.length, {
360 open: (index) => edit(agents[index]),
361 });
362 },
363 key(e) {
364 if (state.key(e, agents.length)) return true;
365 if (e.key === "n" && admin()) {
366 create();
367 return true;
368 }
369 if (e.key === "enter" && admin()) {
370 edit(selected());
371 return true;
372 }
373 if (e.key === "d" && admin()) {
374 retire(selected());
375 return true;
376 }
377 if (e.key === "r") {
378 runAsync(ctx, async () => reload());
379 return true;
380 }
381 return false;
382 },
383 };
384 return screen;
385 }
386
387 function firstLine(text: string): string {
388 return text.split("\n", 1)[0]?.trim() ?? "";
389 }
src/tui/screens/dashboard.ts +19 −117
@@ -56,23 +56,12 @@ import {
5656 errorText,
5757 hitList,
5858 live,
59 openLink,
6059 relTime,
6160 runAsync,
6261 type ListRow,
6362 } from "./common.ts";
64 import { openEnvUrl } from "../../actions/apps.ts";
65 import { openAppTerminal } from "./apptty.ts";
6663 import { describeEvent, type FeedLine } from "./activity.ts";
6764 import { accountScreen } from "./account.ts";
68 import { appScreen } from "./app.ts";
69 import {
70 appRow,
71 appsScreen,
72 listVisibleApps,
73 newAppFlow,
74 type AppItem,
75 } from "./apps.ts";
7665 import { exploreScreen } from "./explore.ts";
7766 import { issueScreen } from "./issue.ts";
7867 import { newRepoScreen } from "./newrepo.ts";
@@ -83,14 +72,13 @@ import { local } from "./where.ts";
8372 import { signInScreen } from "./signin.ts";
8473
8574 /**
86 * The six things a dashboard is about. Order is the `tab` order: apps first
87 * because "what is running" is the question a person drops in to answer,
88 * then the repositories, then what happened, then what is waiting on you.
75 * The five things a dashboard is about. Order is the `tab` order: the
76 * repositories, then what happened, then what is waiting on you. What is
77 * running is the studio's question, on the web.
8978 */
90 type PaneId = "apps" | "repos" | "activity" | "onyou" | "machine" | "orgs";
79 type PaneId = "repos" | "activity" | "onyou" | "machine" | "orgs";
9180
9281 const TITLES: Readonly<Record<PaneId, string>> = {
93 apps: "Apps",
9482 repos: "Repositories",
9583 activity: "Recent activity",
9684 onyou: "On you",
@@ -104,14 +92,8 @@ interface OnYouItem {
10492 readonly repo: Repo;
10593 }
10694
107 /** The apps card shows this many before it scrolls. */
108 const APPS_ROWS = 5;
109 /** The apps card is drawn only when the repositories keep at least this. */
110 const REPOS_MIN_ROWS = 6;
111
11295 /** The `tab` order, whichever of these the frame had room for. */
11396 const ORDER: readonly PaneId[] = [
114 "apps",
11597 "repos",
11698 "activity",
11799 "onyou",
@@ -125,12 +107,10 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
125107 let orgs: string[] = [];
126108 let pins: Pin[] = [];
127109 let pinned = new Set<string>();
128 let apps: AppItem[] = [];
129110 let onYou: OnYouItem[] = [];
130111 const filter = new Filter();
131112 /** One cursor per pane: moving in one must never move another. */
132113 const cursors: Record<PaneId, ListState> = {
133 apps: new ListState(),
134114 repos: new ListState(),
135115 activity: new ListState(),
136116 onyou: new ListState(),
@@ -145,10 +125,6 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
145125 pins = f.user ? listPins(f.db, f.user.id) : [];
146126 pinned = f.user ? pinnedRepoIds(f.db, f.user.id) : new Set<string>();
147127 const all = listReposVisibleTo(f.db, f.user, { limit: 200 });
148 // The apps are among the rows just listed: one grouped read, no second
149 // listing. Every visible app is shown, not only "yours" — what is
150 // running is the question, whoever owns it.
151 apps = listVisibleApps(f, all);
152128 onYou = assignedToMe();
153129 // "Yours" is ownership plus the orgs you are on a team of; everything
154130 // else lives in Explore, one key away.
@@ -204,26 +180,22 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
204180 * frame decides, not a rule repeated here: at two columns everything is
205181 * up; narrower, the activity feed stacks under the repositories and the
206182 * three small cards wait for a wider window; shorter still, the
207 * repositories have the whole body. The apps card is only there when there
208 * is an app and the repositories keep their rows — an empty card saying so
209 * would take the rows they need, and `a` opens the apps screen regardless.
183 * repositories have the whole body.
210184 */
211185 let drawn: ReadonlySet<PaneId> = new Set<PaneId>(["repos"]);
212186 const panes = (): PaneId[] => ORDER.filter((pane) => drawn.has(pane));
213187
214188 const at = (pane: PaneId): ListState => cursors[pane];
215189 const size = (pane: PaneId): number =>
216 pane === "apps"
217 ? apps.length
218 : pane === "repos"
219 ? shown().length
220 : pane === "activity"
221 ? feed.length
222 : pane === "onyou"
223 ? onYou.length
224 : pane === "machine"
225 ? pins.length
226 : orgs.length;
190 pane === "repos"
191 ? shown().length
192 : pane === "activity"
193 ? feed.length
194 : pane === "onyou"
195 ? onYou.length
196 : pane === "machine"
197 ? pins.length
198 : orgs.length;
227199
228200 const cycle = (step: number): void => {
229201 const list = panes();
@@ -294,10 +266,6 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
294266 );
295267 };
296268
297 const openApp = (item: AppItem | undefined): void => {
298 if (item !== undefined) ctx.push(appScreen(f, ctx, item.repo));
299 };
300
301269 const openAssigned = (item: OnYouItem | undefined): void => {
302270 if (item === undefined) return;
303271 const src = local(f, item.repo);
@@ -310,8 +278,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
310278
311279 /** `enter` on whatever the focused pane's cursor is on. */
312280 const openHere = (): void => {
313 if (focus === "apps") openApp(apps[at("apps").index]);
314 else if (focus === "repos") openRepo(shown()[at("repos").index]);
281 if (focus === "repos") openRepo(shown()[at("repos").index]);
315282 else if (focus === "activity") openEvent(feed[at("activity").index]);
316283 else if (focus === "onyou") openAssigned(onYou[at("onyou").index]);
317284 else if (focus === "machine") openPin(pins[at("machine").index]);
@@ -329,16 +296,12 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
329296 binding(["j", "k"], "move"),
330297 binding(["enter"], "open"),
331298 binding(["/"], "filter"),
332 binding(["a"], "apps"),
333 binding(["o"], "open in browser", { disabled: focus !== "apps" }),
334 binding(["t"], "terminal", { disabled: focus !== "apps" }),
335299 binding(["e"], "explore"),
336300 binding(["p"], "pin", { disabled: !f.signedIn }),
337301 binding(["d"], "unpin", {
338302 disabled: focus !== "machine" || pins.length === 0,
339303 }),
340304 binding(["n"], "new repo", { disabled: !f.signedIn }),
341 binding(["N"], "new app", { disabled: !f.signedIn }),
342305 binding(["A"], "account", { disabled: !f.signedIn }),
343306 binding(["s"], "sign in", { disabled: f.signedIn }),
344307 binding(["r"], "refresh"),
@@ -346,8 +309,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
346309 },
347310 load(signal) {
348311 // Anything anyone does anywhere the viewer can see lands here: the
349 // repo list, the counts beside it, the activity feed, the apps and
350 // what is on you are all functions of the outbox, so one subscription
312 // repo list, the counts beside it, the activity feed and what is on
313 // you are all functions of the outbox, so one subscription
351314 // refreshes all of them. (Run events carry a repo id, so they pass the
352315 // same filter as everything else.)
353316 live(f, signal, {}, reload);
@@ -361,29 +324,18 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
361324 main: fill(3),
362325 aside: fill(2),
363326 });
364 const appsCard = Math.min(apps.length, APPS_ROWS) + 2;
365 const appsH =
366 apps.length > 0 && main.h - appsCard - 1 >= REPOS_MIN_ROWS
367 ? appsCard
368 : 0;
369327 // Decide what is on screen before drawing any of it: the focus ring is
370328 // painted by the draws, and a window that just shrank may have taken
371329 // the focused pane away, in which case the cursor lands on the
372330 // repositories, which are always there.
373331 const here = new Set<PaneId>(["repos"]);
374 if (appsH > 0) here.add("apps");
375332 if (aside !== undefined) here.add("activity");
376333 if (aside !== undefined && !stacked && f.signedIn)
377334 here.add("onyou").add("machine").add("orgs");
378335 drawn = here;
379336 if (!here.has(focus)) focus = "repos";
380337
381 const [appsArea, box, list] = split(main, "v", [
382 len(appsH),
383 len(1),
384 fill(1),
385 ]);
386 if (appsH > 0) drawApps(s, appsArea!, c, set);
338 const [box, list] = split(main, "v", [len(1), fill(1)]);
387339 ctx.setCursor(filter.render(s, box!, c, "filter repositories"));
388340 drawRepos(s, list!, c, set);
389341 if (aside === undefined) return;
@@ -421,26 +373,6 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
421373 ctx.push(exploreScreen(f, ctx));
422374 return true;
423375 }
424 if (e.key === "a") {
425 ctx.push(appsScreen(f, ctx));
426 return true;
427 }
428 // Production, one key from the dashboard: through `openEnvUrl`, so a
429 // private app's grant rides along.
430 if (e.key === "o" && focus === "apps") {
431 const item = apps[at("apps").index];
432 if (!item) return true;
433 const url = openEnvUrl(f.app, f.user, item.repo, "production");
434 if (url.ok) openLink(ctx, url.value);
435 else ctx.toast(errorText(url.error), "danger");
436 return true;
437 }
438 // The app's own terminal, on this very pty: the UI steps aside.
439 if (e.key === "t" && focus === "apps") {
440 const item = apps[at("apps").index];
441 if (item) void openAppTerminal(f, ctx, item.repo, "production");
442 return true;
443 }
444376 if (e.key === "d" && focus === "machine") {
445377 unpin(pins[at("machine").index]);
446378 return true;
@@ -463,10 +395,6 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
463395 ctx.push(newRepoScreen(f, ctx));
464396 return true;
465397 }
466 if (e.key === "N" && f.signedIn) {
467 newAppFlow(f, ctx, screen);
468 return true;
469 }
470398 if (e.key === "A" && f.user) {
471399 ctx.push(accountScreen(f, ctx));
472400 return true;
@@ -489,32 +417,6 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
489417
490418 // ---- the panes ----
491419
492 /** What is running: every app, its production state, and its URL. */
493 function drawApps(s: Surface, r: Rect, c: Tokens, set: IconSet): void {
494 const on = focus === "apps";
495 const inner = card(s, r, c, {
496 title: `${icon("terminal", set)} ${TITLES.apps}`,
497 focused: on,
498 });
499 drawList(
500 s,
501 inner,
502 c,
503 at("apps"),
504 apps.map((item) => appRow(item, set)),
505 on,
506 );
507 hitList(ctx, inner, at("apps"), apps.length, {
508 select: () => {
509 focus = "apps";
510 },
511 open: (index) => {
512 focus = "apps";
513 openApp(apps[index]);
514 },
515 });
516 }
517
518420 /** The inbox: what is assigned to this account, across every repository. */
519421 function drawOnYou(s: Surface, r: Rect, c: Tokens, set: IconSet): void {
520422 const on = focus === "onyou";
@@ -563,7 +465,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
563465 emptyState(s, r, c, {
564466 title: f.signedIn ? "No repositories yet" : "Nothing public yet",
565467 body: f.signedIn
566 ? "Press N for a new app, n for a plain repository, or e to explore."
468 ? "Press n for a new repository, or e to explore."
567469 : "Press s to sign in, or e to explore.",
568470 });
569471 return;
src/tui/screens/issue.ts +3 −4
@@ -45,8 +45,7 @@ import {
4545 } from "./common.ts";
4646 import { assignPicker, assignedText } from "./assign.ts";
4747 import { liveBindings, liveKey } from "./mirror.ts";
48 import { runScreen } from "./run.ts";
49 import { RunList } from "./runs.ts";
48 import { RunList, openRunLog } from "./runlist.ts";
5049 import { Composer, threadLines, type ThreadEntry } from "./thread.ts";
5150 import { local, localRepo, whereRows, whereStrip } from "./where.ts";
5251
@@ -110,7 +109,7 @@ export function issueScreen(
110109 const openRun = (): void => {
111110 const run = crew.selected;
112111 if (run !== undefined && repo !== undefined)
113 ctx.push(runScreen(f, ctx, repo, run.id));
112 openRunLog(f, ctx, repo, run);
114113 };
115114
116115 /** The core row a write needs. Only ever reached on a local source. */
@@ -282,7 +281,7 @@ export function issueScreen(
282281 });
283282 crew.render(s, inner, c, ctx, focus === "crew", (run) => {
284283 focus = "crew";
285 if (repo !== undefined) ctx.push(runScreen(f, ctx, repo, run.id));
284 if (repo !== undefined) openRunLog(f, ctx, repo, run);
286285 });
287286 }
288287
src/tui/screens/pull.ts +28 −7
@@ -40,7 +40,11 @@ import {
4040 import { openEnvUrl } from "../../actions/apps.ts";
4141 import { listRepoRuns } from "../../actions/runs.ts";
4242 import { previewEnv } from "../../apps/data.ts";
43 import { appsRuntime, type EnvStatus } from "../../apps/runtime.ts";
43 import {
44 appsRuntime,
45 type EnvState,
46 type EnvStatus,
47 } from "../../apps/runtime.ts";
4448 import { getAppByRepo } from "../../core/apps.ts";
4549 import { getIssue, type Issue } from "../../core/issues.ts";
4650 import { getPull, type Pull } from "../../core/pulls.ts";
@@ -92,13 +96,11 @@ import {
9296 relTime,
9397 runAsync,
9498 } from "./common.ts";
95 import { envTone } from "./apps.ts";
9699 import { assignPicker, assignedText } from "./assign.ts";
97100 import { DiffView, type DiffAnchor, type DiffNote } from "./commits.ts";
98101 import { liveBindings, liveKey } from "./mirror.ts";
99102 import { reviewScreen } from "./review.ts";
100 import { runScreen } from "./run.ts";
101 import { RunList, runTone } from "./runs.ts";
103 import { RunList, openRunLog, runTone } from "./runlist.ts";
102104 import { Composer, threadLines, type ThreadEntry } from "./thread.ts";
103105 import { local, localRepo, whereRows, whereStrip } from "./where.ts";
104106 import { openAppTerminal } from "./apptty.ts";
@@ -716,7 +718,7 @@ export function pullScreen(
716718 });
717719 if (tab === 2) {
718720 crew.render(s, body!, c, ctx, true, (run) => {
719 if (repo !== undefined) ctx.push(runScreen(f, ctx, repo, run.id));
721 if (repo !== undefined) openRunLog(f, ctx, repo, run);
720722 });
721723 } else if (tab === 1) {
722724 // The thread count is a section header, not a badge on the tab: how
@@ -795,7 +797,7 @@ export function pullScreen(
795797 if (e.key === "enter") {
796798 const run = crew.selected;
797799 if (run !== undefined && repo !== undefined)
798 ctx.push(runScreen(f, ctx, repo, run.id));
800 openRunLog(f, ctx, repo, run);
799801 return true;
800802 }
801803 }
@@ -949,6 +951,21 @@ export type MergeState =
949951 | "ready"
950952 | "unknown";
951953
954 /** The tone an environment's state wears; `stopped` and `asleep` wear none. */
955 function envTone(state: EnvState): Tone | undefined {
956 switch (state) {
957 case "running":
958 return "success";
959 case "deploying":
960 return "attn";
961 case "failed":
962 return "danger";
963 case "stopped":
964 case "asleep":
965 return undefined;
966 }
967 }
968
952969 /**
953970 * The preview, in one line: a dot in the environment's tone, the word, the
954971 * URL as a link, and the error when there is one — never "failed" alone.
@@ -982,7 +999,11 @@ function drawPreviewLine(
982999 ? `running${preview.since === undefined ? "" : ` since ${ago(preview.since)}`}`
9831000 : preview.state === "deploying"
9841001 ? `deploying ${preview.commitOid.slice(0, 7)}`
985 : "stopped";
1002 : preview.state === "asleep"
1003 ? `asleep${preview.since === undefined ? "" : ` since ${ago(preview.since)}`}; wakes on a request`
1004 : preview.dormant === undefined
1005 ? "stopped"
1006 : `asleep since ${ago(preview.dormant.since)}; nobody touched the change since ${ago(preview.dormant.idleSince)}`;
9861007 x = s.text(
9871008 x,
9881009 r.y,
src/tui/screens/run.ts deleted −340
@@ -1,340 +0,0 @@
1 /**
2 * One run: what it is, how it ended (or that it has not), and its log,
3 * tailed live for as long as the screen is on the stack.
4 *
5 * The order of the two reads on load is the whole correctness story. The
6 * subscription is taken *first* and the file read *second*; both are
7 * synchronous, so no line can land between them — every line is either in
8 * the file we read or delivered to the listener we registered, never both
9 * and never neither. The listener is released by the screen's own abort
10 * signal, so popping the screen is what stops the tail.
11 *
12 * The log follows its end the way `tail -f` does until the reader scrolls
13 * up to look at something, and `G` puts it back on the end.
14 *
15 * Lines wrap to the width rather than clip at it: a log is read for the
16 * words at the end of a line — the path, the exit code, the URL — and a
17 * terminal that cut them off would be showing the least useful half. The
18 * wrapped rows are built once per width and extended a line at a time as
19 * the tail arrives, so a resize is the only thing that rewraps.
20 */
21
22 import { cancelRepoRun, getRepoRun, runLog } from "../../actions/runs.ts";
23 import { canWrite, type Repo } from "../../core/repos.ts";
24 import { isTerminal, type Run } from "../../core/runs.ts";
25 import { subscribeRunLog } from "../../runs/log.ts";
26 import type { Ctx, Screen } from "../app/app.ts";
27 import { binding } from "../app/keys.ts";
28 import type { Span } from "../term/screen.ts";
29 import { BOLD, ITALIC, style, type Color, type Style } from "../term/style.ts";
30 import { expandTabs, truncate, wrap } from "../term/width.ts";
31 import { fill, len, split } from "../ui/layout.ts";
32 import type { Tokens } from "../ui/theme.ts";
33 import { dot, spinner, type Tone } from "../ui/widgets.ts";
34 import type { Session } from "../session.ts";
35 import {
36 Scroll,
37 ago,
38 confirmScreen,
39 drawLines,
40 errorText,
41 hitScroll,
42 live,
43 metaOf,
44 runAsync,
45 } from "./common.ts";
46 import {
47 costText,
48 envLabel,
49 runActors,
50 runHollow,
51 runTitle,
52 runTone,
53 } from "./runs.ts";
54
55 /** Head rows: the state line, the summary, and the times. */
56 const HEAD = 4;
57
58 /** The log laid out for one width and one theme. */
59 interface View {
60 readonly width: number;
61 readonly c: Tokens;
62 readonly rows: Span[][];
63 /** The line each row came from; -1 for the "earlier lines" notice. */
64 readonly from: number[];
65 }
66
67 export function runScreen(
68 f: Session,
69 ctx: Ctx,
70 repo: Repo,
71 runId: string,
72 ): Screen {
73 let run: Run | undefined;
74 let names = new Map<string, { username: string }>();
75 let lines: string[] = [];
76 let truncated = false;
77 let view: View | undefined;
78 let follow = true;
79 let failed: string | undefined;
80 const scroll = new Scroll();
81
82 // The action decides; this only shapes the help bar. A cancel the action
83 // refuses is a toast, never a silent keypress.
84 const mayCancel = (): boolean =>
85 run !== undefined && !isTerminal(run.state) && canWrite(f.db, repo, f.user);
86
87 const addLine = (v: View, line: string, index: number): void => {
88 const st = lineStyle(line, v.c);
89 for (const piece of wrap(expandTabs(line), v.width)) {
90 v.rows.push([{ text: piece, style: st }]);
91 v.from.push(index);
92 }
93 };
94
95 /**
96 * The rows for this width. A new width rewraps everything and keeps the
97 * reader's place by *line*, not by row: the line that was at the top stays
98 * at the top, whatever number of rows it now takes.
99 */
100 const viewFor = (width: number): View => {
101 const c = ctx.theme.c;
102 if (view !== undefined && view.width === width && view.c === c) return view;
103 const next: View = { width, c, rows: [], from: [] };
104 if (truncated) {
105 next.rows.push([
106 {
107 text: "… earlier lines are not shown; the log is large",
108 style: style(c.faint, c.bg, ITALIC),
109 },
110 ]);
111 next.from.push(-1);
112 }
113 lines.forEach((line, i) => addLine(next, line, i));
114 if (view !== undefined && !follow) {
115 const wasAt = view.from[scroll.top] ?? -1;
116 scroll.top = Math.max(0, next.from.indexOf(wasAt));
117 }
118 view = next;
119 return next;
120 };
121
122 /** Re-read the row: the state, the summary and the cost move as it runs. */
123 const refreshRun = (): void => {
124 const read = getRepoRun(f.app, f.user, repo, runId);
125 if (!read.ok) {
126 failed = errorText(read.error);
127 ctx.invalidate();
128 return;
129 }
130 run = read.value;
131 names = runActors(f, [run]);
132 ctx.invalidate();
133 };
134
135 const cancel = (): void => {
136 if (run === undefined) return;
137 ctx.push(
138 confirmScreen(ctx, screen, {
139 title: "Cancel this run",
140 body: [
141 run.kind === "agent"
142 ? "The agent is stopped where it is; what it pushed stays."
143 : "The deploy is abandoned; whatever was serving keeps serving.",
144 ],
145 actions: [
146 { label: "Keep going", variant: "ghost" },
147 { label: "Cancel the run", variant: "danger" },
148 ],
149 onChoose: (choice) => {
150 if (choice !== 1) return;
151 const done = cancelRepoRun(f.app, f.user, repo, runId);
152 if (!done.ok) ctx.toast(errorText(done.error), "danger");
153 else {
154 run = done.value;
155 ctx.toast("run cancelled", "success");
156 ctx.invalidate();
157 }
158 },
159 }),
160 );
161 };
162
163 const screen: Screen = {
164 title: "Run",
165 get keys() {
166 return [
167 binding(["j", "k"], "scroll"),
168 binding(["G"], "follow the end"),
169 binding(["x"], "cancel", { disabled: !mayCancel() }),
170 ];
171 },
172 load(signal) {
173 // Subscribe, then read — see the file comment.
174 subscribeRunLog(
175 runId,
176 (line) => {
177 if (signal.aborted) return;
178 lines.push(line);
179 if (view !== undefined) addLine(view, line, lines.length - 1);
180 ctx.invalidate();
181 },
182 signal,
183 );
184 const read = runLog(f.app, f.user, repo, runId);
185 if (!read.ok) {
186 failed = errorText(read.error);
187 ctx.invalidate();
188 return Promise.resolve();
189 }
190 run = read.value.run;
191 names = runActors(f, [run]);
192 lines = read.value.lines;
193 truncated = read.value.truncated;
194 view = undefined;
195 live(
196 f,
197 signal,
198 { repoId: repo.id, kinds: ["run.started", "run.finished"] },
199 refreshRun,
200 );
201 ctx.invalidate();
202 return Promise.resolve();
203 },
204 render(area, s) {
205 const c = ctx.theme.c;
206 if (failed !== undefined) {
207 s.text(area.x, area.y, failed, style(c.danger, c.bg), area.x + area.w);
208 return;
209 }
210 if (run === undefined) return;
211 const [head, body] = split(area, "v", [len(HEAD), fill(1)]);
212 const right = head!.x + head!.w;
213 const tone = runTone(run.state);
214 let x = head!.x;
215 if (run.state === "running") x = spinner(s, x, head!.y, c, ctx.tick);
216 else
217 x = dot(s, x, head!.y, c, {
218 ...(tone === undefined ? {} : { tone }),
219 ...(runHollow(run.state) ? { hollow: true } : {}),
220 });
221 x = s.text(
222 x + 1,
223 head!.y,
224 run.state,
225 style(tone === undefined ? c.mutedFg : toneOf(c, tone), c.bg, BOLD),
226 right,
227 );
228 s.text(
229 x + 2,
230 head!.y,
231 truncate(
232 metaOf(
233 runTitle(run, names, ctx.icons),
234 run.reason === "" ? "" : run.reason,
235 run.commitOid === "" ? "" : run.commitOid.slice(0, 7),
236 run.branch === "" ? "" : run.branch,
237 costText(run.costUsd),
238 ),
239 Math.max(0, right - x - 2),
240 ),
241 style(c.fg, c.bg),
242 right,
243 );
244 s.text(
245 head!.x,
246 head!.y + 1,
247 truncate(
248 run.summary !== ""
249 ? run.summary
250 : run.state === "queued"
251 ? "waiting for a worker"
252 : run.state === "running"
253 ? "no summary yet — the log below is live"
254 : "no summary",
255 head!.w,
256 ),
257 style(
258 run.state === "failed" ? c.danger : c.mutedFg,
259 c.bg,
260 run.summary === "" ? ITALIC : 0,
261 ),
262 right,
263 );
264 s.text(
265 head!.x,
266 head!.y + 2,
267 truncate(
268 metaOf(
269 run.kind === "deploy" ? envLabel(run.env) : "",
270 `queued ${ago(run.createdAt)}`,
271 run.startedAt === null ? "" : `started ${ago(run.startedAt)}`,
272 run.finishedAt === null ? "" : `finished ${ago(run.finishedAt)}`,
273 ),
274 head!.w,
275 ),
276 style(c.faint, c.bg),
277 right,
278 );
279 // One column for the scrollbar, one so a full row never touches it.
280 const { rows } = viewFor(Math.max(1, body!.w - 2));
281 if (rows.length === 0) {
282 s.text(
283 body!.x,
284 body!.y,
285 run.state === "queued"
286 ? "Nothing logged yet."
287 : "This run wrote no log.",
288 style(c.faint, c.bg, ITALIC),
289 body!.x + body!.w,
290 );
291 return;
292 }
293 // Following means "the last row is the bottom row", and the bottom
294 // row is only known here, where the height is.
295 if (follow) scroll.to(rows.length - body!.h, rows.length);
296 drawLines(s, body!, c, scroll, rows);
297 hitScroll(ctx, body!, scroll, rows.length);
298 },
299 key(e) {
300 if (e.key === "x" && mayCancel()) {
301 cancel();
302 return true;
303 }
304 if (e.key === "G" || e.key === "end") {
305 follow = true;
306 return true;
307 }
308 const total = view?.rows.length ?? 0;
309 if (scroll.key(e, total)) {
310 // Scrolling away from the end is a choice to read; new lines stop
311 // moving the view until `G` asks for the end again.
312 follow = scroll.top + scroll.height >= total;
313 return true;
314 }
315 if (e.key === "r") {
316 runAsync(ctx, async () => refreshRun());
317 return true;
318 }
319 return false;
320 },
321 };
322 return screen;
323 }
324
325 /** The words a worker writes at the edges of a run stand out; the rest is log. */
326 function lineStyle(line: string, c: Tokens): Style {
327 if (/^failed[: ]/.test(line)) return style(c.danger, c.bg);
328 if (/^done[: ]/.test(line)) return style(c.success, c.bg);
329 return style(c.fg, c.bg);
330 }
331
332 function toneOf(c: Tokens, tone: Tone): Color {
333 return tone === "success"
334 ? c.success
335 : tone === "danger"
336 ? c.danger
337 : tone === "attn"
338 ? c.attn
339 : c.mutedFg;
340 }
src/tui/screens/runs.ts → src/tui/screens/runlist.ts renamed +25 −80
@@ -1,8 +1,8 @@
11 /**
2 * Runs, as rows: the list a repository keeps, and the vocabulary every other
3 * screen borrows to show one — a pull's checks, an issue's crew activity, an
4 * environment's last deploy. One place decides what "failed" looks like so
5 * it looks the same everywhere.
2 * Runs, as rows: the vocabulary a screen borrows to show one — a pull's
3 * 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.
66 *
77 * A run row says four things in order: its state (a dot in the tone and the
88 * word), what it was (`deploy production`, `agent robo`), why (the reason),
@@ -11,29 +11,25 @@
1111 * the summary that says what failed.
1212 */
1313
14 import { listRepoRuns } from "../../actions/runs.ts";
1514 import { getIssuesByIds, type Issue } from "../../core/issues.ts";
1615 import type { Repo } from "../../core/repos.ts";
1716 import type { Run, RunState } from "../../core/runs.ts";
1817 import { getUsersByIds } from "../../core/users.ts";
19 import type { Ctx, Screen } from "../app/app.ts";
20 import { binding } from "../app/keys.ts";
18 import type { Ctx } from "../app/app.ts";
2119 import type { Rect, Screen as Surface } from "../term/screen.ts";
2220 import type { Tokens } from "../ui/theme.ts";
2321 import { icon, type IconSet } from "../ui/icons.ts";
24 import { emptyState, type Tone } from "../ui/widgets.ts";
22 import type { Tone } from "../ui/widgets.ts";
2523 import type { Session } from "../session.ts";
2624 import {
2725 ListState,
2826 drawList,
29 errorText,
3027 hitList,
31 live,
3228 metaOf,
29 openLink,
3330 relTime,
3431 type ListRow,
3532 } from "./common.ts";
36 import { runScreen } from "./run.ts";
3733
3834 /** The tone a run's state wears — the same reading on every surface. */
3935 export function runTone(state: RunState): Tone | undefined {
@@ -55,13 +51,6 @@ export function runTone(state: RunState): Tone | undefined {
5551 export const runHollow = (state: RunState): boolean =>
5652 state === "queued" || state === "cancelled";
5753
58 /** The event kinds a run passes through; what a live run list listens for. */
59 export const RUN_EVENTS: readonly string[] = [
60 "run.queued",
61 "run.started",
62 "run.finished",
63 ];
64
6554 /** `deploy production`, `deploy pull #4`, `agent robo`. */
6655 export function runTitle(
6756 run: Run,
@@ -198,66 +187,22 @@ export class RunList {
198187 }
199188 }
200189
201 /** Every run of one repository, newest first, live. */
202 export function runsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
203 const list = new RunList(f, { nameIssues: true });
204 let failed: string | undefined;
205
206 const reload = (): void => {
207 const read = listRepoRuns(f.app, f.user, repo, { limit: 100 });
208 if (!read.ok) {
209 failed = errorText(read.error);
210 ctx.invalidate();
211 return;
212 }
213 failed = undefined;
214 list.set(read.value);
215 ctx.invalidate();
216 };
217
218 const open = (run: Run | undefined): void => {
219 if (run !== undefined) ctx.push(runScreen(f, ctx, repo, run.id));
220 };
221
222 const screen: Screen = {
223 title: `${repo.ownerName}/${repo.name} · runs`,
224 keys: [
225 binding(["j", "k"], "move"),
226 binding(["enter"], "open the log"),
227 binding(["r"], "refresh"),
228 ],
229 load(signal) {
230 live(f, signal, { repoId: repo.id, kinds: RUN_EVENTS }, reload);
231 reload();
232 return Promise.resolve();
233 },
234 render(area, s) {
235 const c = ctx.theme.c;
236 if (failed !== undefined) {
237 emptyState(s, area, c, { title: failed });
238 return;
239 }
240 if (list.runs.length === 0) {
241 emptyState(s, area, c, {
242 title: "No runs yet",
243 body: "A deploy or an agent working here will appear as a run.",
244 });
245 return;
246 }
247 list.render(s, area, c, ctx, true, open);
248 },
249 key(e) {
250 if (list.state.key(e, list.runs.length)) return true;
251 if (e.key === "enter") {
252 open(list.selected);
253 return true;
254 }
255 if (e.key === "r") {
256 reload();
257 return true;
258 }
259 return false;
260 },
261 };
262 return screen;
190 /**
191 * `enter` on a run: its log, cancel and steer live on the web page, so the
192 * terminal hands over the address. With no public URL configured the path
193 * is still the answer, said as one.
194 */
195 export function openRunLog(
196 f: Session,
197 ctx: Ctx,
198 repo: Repo,
199 run: Run,
200 ): void {
201 const path = `/${repo.ownerName}/${repo.name}/runs/${run.id}`;
202 const base = f.app.config.url;
203 if (base === undefined) {
204 ctx.toast(`the run's log is on the web at ${path}`, "attn");
205 return;
206 }
207 openLink(ctx, `${base.replace(/\/+$/, "")}${path}`);
263208 }
src/tui/screens/settings.ts +6 −2
@@ -51,7 +51,7 @@ import { truncate } from "../term/width.ts";
5151 import { fill, len, split } from "../ui/layout.ts";
5252 import { labelChip, tabs } from "../ui/widgets.ts";
5353 import type { Session } from "../session.ts";
54 import { agentPick } from "./apps.ts";
54 import { agentPick } from "./assign.ts";
5555 import {
5656 ListState,
5757 confirmScreen,
@@ -65,7 +65,6 @@ import {
6565 promptScreen,
6666 runAsync,
6767 } from "./common.ts";
68 import { budgetText } from "./crew.ts";
6968 import { refPickerScreen } from "./refs.ts";
7069 import { local } from "./where.ts";
7170
@@ -78,6 +77,11 @@ const TABS = [
7877 "Danger",
7978 ] as const;
8079
80 /** `$5.00/run`, or the forge's default said as such. */
81 function budgetText(budgetUsd: number): string {
82 return budgetUsd > 0 ? `$${budgetUsd.toFixed(2)}/run` : "default budget";
83 }
84
8185 export function settingsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
8286 let current = repo;
8387 let labels: Label[] = [];
test/apps-ssh.test.ts +13 −18
@@ -18,10 +18,11 @@ import { parseAppLogin } from "../src/apps/tty.ts";
1818 import { newApp } from "../src/actions/apps.ts";
1919 import { listRuns } from "../src/core/runs.ts";
2020 import { createUser, type User } from "../src/core/users.ts";
21 import { getRepo } from "../src/core/repos.ts";
2122 import { serve, type Servers } from "../src/http/server.ts";
2223 import type { SshServer } from "../src/ssh/server.ts";
2324 import { createForgeApp } from "../src/tui/forge.ts";
24 import { appsScreen } from "../src/tui/screens/apps.ts";
25 import { openAppTerminal } from "../src/tui/screens/apptty.ts";
2526 import { serveForgeSsh } from "../src/tui/ssh.ts";
2627 import { headlessTerminal } from "../src/tui/term/terminal.ts";
2728 import { getAppBySlug } from "../src/core/apps.ts";
@@ -216,7 +217,7 @@ describe.skipIf(NO_OPENSSH)("a terminal for every app, over ssh", () => {
216217 expect(code).toBe(1);
217218 }, 30_000);
218219
219 test("t in the forge's own terminal runs the app's terminal on the same pty, then comes back", async () => {
220 test("the forge's own terminal runs an app's terminal on the same pty, then comes back", async () => {
220221 const terminal = headlessTerminal(100, 30, "xterm-256color");
221222 const { app: tui, session } = createForgeApp(
222223 app,
@@ -230,21 +231,11 @@ describe.skipIf(NO_OPENSSH)("a terminal for every app, over ssh", () => {
230231 );
231232 tui.start();
232233 await settleApp(tui);
233 tui.ctx.push(appsScreen(session, tui.ctx));
234 await settleApp(tui);
235 // Narrow the list to `hello` so the cursor is on it, then `t`.
236 for (const key of ["/", "h", "e", "l", "l", "o", "enter"]) {
237 tui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
238 await settleApp(tui);
239 }
234 // What a pull's `T` does for its preview, here for production: the
235 // dashboard steps aside for the app's own terminal.
240236 terminal.takeOutput();
241 tui.dispatch({
242 type: "key",
243 key: "t",
244 ctrl: false,
245 alt: false,
246 shift: false,
247 });
237 const hello = getRepo(app.db, "alice", "hello")!;
238 const ran = openAppTerminal(session, tui.ctx, hello, "production");
248239 const deadline = Date.now() + 10_000;
249240 while (!terminal.output().includes("j/k move") && Date.now() < deadline)
250241 await Bun.sleep(50);
@@ -254,11 +245,15 @@ describe.skipIf(NO_OPENSSH)("a terminal for every app, over ssh", () => {
254245 // …and `q` ends it, which is when the forge's UI comes back.
255246 terminal.feed("q");
256247 const back = Date.now() + 10_000;
257 while (!tui.screen.toText().includes("filter apps") && Date.now() < back) {
248 await ran;
249 while (
250 !tui.screen.toText().includes("filter repositories") &&
251 Date.now() < back
252 ) {
258253 await Bun.sleep(50);
259254 await settleApp(tui);
260255 }
261 expect(tui.screen.toText()).toContain("hello");
256 expect(tui.screen.toText()).toContain("alice/hello");
262257 tui.stop();
263258 }, 40_000);
264259
test/surfaces-adversarial.test.ts +7 −105
@@ -4,8 +4,7 @@
44 * do not already prove: CSRF on the routes those files did not target,
55 * rendering safety (a run log, an agent's brief, an issue title are all
66 * attacker-controlled text), the strict CSP every new page must carry, that
7 * a listing never pays a query per row, a guest's reach in the terminal, and
8 * that a run's log cannot corrupt the terminal frame it is drawn into.
7 * a listing never pays a query per row, and a guest's reach in the terminal.
98 *
109 * No `AppsRuntime` runs here on purpose: every page under test renders from
1110 * rows alone (`runtime === undefined` is itself a first-class, already
@@ -29,12 +28,10 @@ import { createUser, getUserByName, type User } from "../src/core/users.ts";
2928 import { serve, type Servers } from "../src/http/server.ts";
3029 import { id } from "../src/kernel/id.ts";
3130 import { quietLogs } from "./helpers/globals.ts";
32 import { appendRunLog, runLogListenerCount } from "../src/runs/log.ts";
31 import { appendRunLog } from "../src/runs/log.ts";
3332 import { content as contentStyle } from "../src/web/style/content.ts";
34 import { runScreen } from "../src/tui/screens/run.ts";
35 import { appsScreen } from "../src/tui/screens/apps.ts";
36 import { appScreen } from "../src/tui/screens/app.ts";
37 import { crewScreen } from "../src/tui/screens/crew.ts";
33 import { issuesScreen } from "../src/tui/screens/issues.ts";
34 import { local } from "../src/tui/screens/where.ts";
3835 import { createForgeApp } from "../src/tui/forge.ts";
3936 import type { Session, TuiIdentity } from "../src/tui/session.ts";
4037 import type { App as TuiApp, Screen } from "../src/tui/app/app.ts";
@@ -749,13 +746,6 @@ describe("surfaces adversarial: tui", () => {
749746 files: [{ path: "README.md", content: "# hello\n" }],
750747 });
751748 if (!committed.ok) throw new Error(JSON.stringify(committed.error));
752 // No AppsRuntime is started: the app screen renders the "no runtime"
753 // state, which is exactly what a guest and a writer alike see it say.
754 forge.db
755 .query(
756 `INSERT INTO app (id, repo_id, slug, access, preview_data) VALUES (?1, ?2, 'hello', 'public', 'copy')`,
757 )
758 .run(id(), made.value.id);
759749 }, 30_000);
760750
761751 afterAll(async () => {
@@ -763,41 +753,6 @@ describe("surfaces adversarial: tui", () => {
763753 await rm(dataDir, { recursive: true, force: true });
764754 });
765755
766 test("a guest cannot open New app, redeploy, stop, assign, or reach the crew screen", async () => {
767 const h = boot(identityFor(undefined));
768 try {
769 expect(h.session.signedIn).toBe(false);
770
771 // Apps: `n` is disabled and does nothing — the key bar draws it dim
772 // but never wires it, so the only honest proof is that pressing it
773 // changes nothing (no prompt screen pushed).
774 await h.push((s, ctx) => appsScreen(s, ctx));
775 await h.until("hello");
776 const appsBefore = h.text();
777 await h.press("n");
778 expect(h.text()).toBe(appsBefore);
779 expect(h.text()).not.toContain("New app");
780
781 // The app screen: redeploy/stop keys do nothing for a guest.
782 await h.push((s, ctx) => appScreen(s, ctx, hello));
783 await h.until("slug hello");
784 const before = h.text();
785 await h.press("r");
786 expect(h.text()).toBe(before); // no toast, no confirm screen pushed
787 await h.press("s");
788 expect(h.text()).toBe(before);
789
790 // The crew screen: reachable (it says who it is for), but every verb
791 // is inert and the reason is stated, not hidden.
792 await h.push((s, ctx) => crewScreen(s, ctx));
793 await h.until("only a forge admin can change the crew");
794 await h.press("n");
795 expect(h.text()).not.toContain("New agent");
796 } finally {
797 h.stop();
798 }
799 }, 30_000);
800
801756 test("a guest cannot assign an issue: the issue screen never wires `a`", async () => {
802757 const { openIssue, assignIssue } = await import("../src/actions/issues.ts");
803758 const { issueScreen } = await import("../src/tui/screens/issue.ts");
@@ -829,66 +784,13 @@ describe("surfaces adversarial: tui", () => {
829784 }
830785 }, 30_000);
831786
832 test("a run log with ANSI escapes and control bytes does not corrupt the frame", async () => {
833 const runId = id();
834 forge.db
835 .query(
836 `INSERT INTO run (id, repo_id, kind, env, state) VALUES (?1, ?2, 'deploy', 'production', 'running')`,
837 )
838 .run(runId, hello.id);
839 const before = runLogListenerCount();
840 const h = boot(identityFor(alice));
841 try {
842 await h.push((s, ctx) => runScreen(s, ctx, hello, runId));
843 await h.until("running");
844 appendRunLog(
845 dataDir,
846 runId,
847 "\x1b[31mred\x1b[0m\x1b[2J\x1b[?1049htitle\x1b]0;evil\x07 plain text survives",
848 );
849 await h.until("plain text survives");
850 // No raw escape byte reached the rendered frame.
851 expect(h.text()).not.toContain("\x1b");
852 // The frame is still a well-formed grid — no crash, no runaway state.
853 expect(h.app.screen.toText().split("\n").length).toBeGreaterThan(5);
854 } finally {
855 h.stop();
856 }
857 expect(runLogListenerCount()).toBe(before);
858 }, 30_000);
859
860 test("the run screen releases its log subscription when popped", async () => {
861 const runId = id();
862 forge.db
863 .query(
864 `INSERT INTO run (id, repo_id, kind, env, state) VALUES (?1, ?2, 'deploy', 'production', 'running')`,
865 )
866 .run(runId, hello.id);
867 const before = runLogListenerCount();
868 const h = boot(identityFor(alice));
869 try {
870 await h.push((s, ctx) => runScreen(s, ctx, hello, runId));
871 await h.until("running");
872 expect(runLogListenerCount()).toBe(before + 1);
873 h.pop();
874 await h.settle();
875 expect(runLogListenerCount()).toBe(before);
876 } finally {
877 h.stop();
878 }
879 }, 30_000);
880
881787 test("a screen popped while its load is in flight renders and toasts nothing afterward", async () => {
882 const runId = id();
883 forge.db
884 .query(
885 `INSERT INTO run (id, repo_id, kind, env, state) VALUES (?1, ?2, 'deploy', 'production', 'running')`,
886 )
887 .run(runId, hello.id);
888788 const h = boot(identityFor(alice));
889789 try {
890790 // Push and immediately pop, before the load's promise has settled.
891 h.app.ctx.push(runScreen(h.session, h.app.ctx, hello, runId));
791 h.app.ctx.push(
792 issuesScreen(h.session, h.app.ctx, local(h.session, hello), "issue"),
793 );
892794 h.pop();
893795 await h.settle();
894796 // The forge is back where it was; nothing threw, nothing left a toast
test/tui-apps.test.ts +52 −207
@@ -1,13 +1,15 @@
11 /**
2 * Apps, runs and the crew, through the terminal (docs/APPS.md).
2 * Apps, runs and the crew, as the terminal still shows them (docs/APPS.md):
3 * inside an issue, a pull and a repository's settings. The apps, app, runs,
4 * run and crew screens are gone — the studio on the web is where an app is
5 * run and a crew is managed — and the tests at the end prove no key or
6 * palette entry still reaches for them.
37 *
48 * One real forge with a real `AppsRuntime`, driven headlessly. The app under
59 * test is deliberately *not* an app — a README and nothing else — so every
610 * deploy fails in milliseconds with the one sentence the runtime writes for
711 * that, and the screens are asserted on the honest words they must carry:
8 * "failed" beside its reason, never "failed" alone. The live half is real
9 * too: a line appended to a run's log lands on the run screen through the
10 * in-process listener, and an event on the bus redraws the app.
12 * "failed" beside its reason, never "failed" alone.
1113 */
1214
1315 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
@@ -25,10 +27,9 @@ import {
2527 } from "../src/actions/repos.ts";
2628 import { createApp, type App as Forge } from "../src/app.ts";
2729 import { AppsRuntime } from "../src/apps/runtime.ts";
28 import { getAgentByName, type Agent } from "../src/core/agents.ts";
29 import { getAppByRepo } from "../src/core/apps.ts";
30 import { getIssue, listIssues, type Issue } from "../src/core/issues.ts";
31 import { getRepo, getRepoById, type Repo } from "../src/core/repos.ts";
30 import type { Agent } from "../src/core/agents.ts";
31 import { getIssue, type Issue } from "../src/core/issues.ts";
32 import { getRepoById, type Repo } from "../src/core/repos.ts";
3233 import {
3334 claimRun,
3435 createRun,
@@ -36,14 +37,13 @@ import {
3637 listRuns,
3738 type Run,
3839 } from "../src/core/runs.ts";
39 import { createUser, type User } from "../src/core/users.ts";
40 import { createUser, getUserByName, type User } from "../src/core/users.ts";
4041 import { serve, type Servers } from "../src/http/server.ts";
4142 import { quietLogs } from "./helpers/globals.ts";
4243 import { appendRunLog } from "../src/runs/log.ts";
4344 import type { App, Screen } from "../src/tui/app/app.ts";
44 import { createForgeApp } from "../src/tui/forge.ts";
45 import { createForgeApp, forgeCommands } from "../src/tui/forge.ts";
4546 import { issueScreen } from "../src/tui/screens/issue.ts";
46 import { runsScreen } from "../src/tui/screens/runs.ts";
4747 import { pullScreen } from "../src/tui/screens/pull.ts";
4848 import { settingsScreen } from "../src/tui/screens/settings.ts";
4949 import { local } from "../src/tui/screens/where.ts";
@@ -257,69 +257,6 @@ describe("terminal: apps, runs and the crew", () => {
257257 await rm(dataDir, { recursive: true, force: true });
258258 });
259259
260 test("the apps screen: every app, its production state in words, its URL", async () => {
261 const h = boot(forge, identityFor(alice));
262 try {
263 await h.press("a");
264 await h.until("alice/site");
265 const text = h.text();
266 expect(text).toContain("failed");
267 expect(text).toContain(`http://site.${DOMAIN}:${servers.port}`);
268 } finally {
269 h.stop();
270 }
271 }, 30_000);
272
273 test("the app screen: the environment, its error, the log, the runs", async () => {
274 const h = boot(forge, identityFor(alice));
275 try {
276 await h.press("a");
277 await h.until("alice/site");
278 await h.press("enter");
279 await h.until("production");
280 // The failure is a sentence, on the row and in the panel.
281 expect(h.text()).toContain(`failed: ${NOT_AN_APP}`);
282 expect(h.text()).toContain("slug site");
283
284 // `enter` opens the deploy's log; the last line is the reason.
285 await h.press("enter");
286 await h.until("deploy production of alice/site");
287 expect(h.text()).toContain(`failed: ${NOT_AN_APP}`);
288 expect(h.text()).toContain("deploy production");
289
290 // A line written to the log now lands on screen without a keypress:
291 // the subscription was taken before the file was read.
292 appendRunLog(dataDir, productionRun.id, "a fresh line from the test");
293 await h.until("a fresh line from the test");
294
295 // Back on the app screen, `l` lists every run of the repository.
296 await h.press("esc");
297 await h.until("slug site");
298 await h.press("l");
299 await h.until("deploy production");
300 expect(h.text()).toContain("failed");
301 expect(h.text()).toContain(NOT_AN_APP);
302 } finally {
303 h.stop();
304 }
305 }, 30_000);
306
307 test("the run screen refuses nothing quietly: a finished run cannot be cancelled", async () => {
308 const h = boot(forge, identityFor(alice));
309 try {
310 await h.press("a");
311 await h.until("alice/site");
312 await h.press("enter", "enter");
313 await h.until("deploy production of alice/site");
314 // `x` is disabled on a finished run: the key does nothing and the help
315 // bar says so rather than offering a cancel that would be refused.
316 await h.press("x");
317 expect(h.text()).not.toContain("Cancel this run");
318 } finally {
319 h.stop();
320 }
321 }, 30_000);
322
323260 test("assign: nobody, the people who can read it, then the crew with its mark", async () => {
324261 const h = boot(forge, identityFor(alice));
325262 try {
@@ -346,7 +283,8 @@ describe("terminal: apps, runs and the crew", () => {
346283 // The head line names the assignee and says what it is.
347284 expect(h.text()).toContain("assigned to robo (agent)");
348285
349 // The crew's work on the issue is a card; `enter` from it opens the run.
286 // The crew's work on the issue is a card; `enter` from it hands over
287 // the run's web page, where its log lives.
350288 const run = createRun(forge.db, {
351289 repoId: site.id,
352290 kind: "agent",
@@ -366,16 +304,7 @@ describe("terminal: apps, runs and the crew", () => {
366304 expect(h.text()).toContain("opened pull #2 with the melody");
367305 expect(h.text()).toContain("$0.42");
368306 await h.press("tab", "enter");
369 await h.until("agent robo");
370 expect(h.text()).toContain("cloning alice/site");
371 expect(h.text()).toContain("opened pull #2 with the melody");
372
373 // The repository's runs list names the conversation each run is
374 // about, from one grouped read; inside the issue that would only
375 // repeat the page, so the card above did not.
376 await h.push((s, ctx) => runsScreen(s, ctx, site));
377 await h.until(`agent robo · #${issue.number} Make the site sing`);
378 expect(h.text()).toContain("deploy production");
307 await h.until(`/alice/site/runs/${run.id}`);
379308 } finally {
380309 h.stop();
381310 }
@@ -506,64 +435,7 @@ describe("terminal: apps, runs and the crew", () => {
506435 }
507436 }, 30_000);
508437
509 test("the crew screen: the roster, a new agent, and retiring one", async () => {
510 const h = boot(forge, identityFor(alice));
511 try {
512 await h.press("A");
513 await h.until("Account");
514 await h.press("c");
515 await h.until("robo");
516 expect(h.text()).toContain("sonnet");
517 expect(h.text()).toContain("I write small, careful patches.");
518 // No crew was started for this forge, and the screen says so.
519 expect(h.text()).toContain("the crew is not running on this forge");
520
521 await h.press("n");
522 await h.until("Username");
523 await h.type("pixel");
524 await h.press("enter");
525 await h.until("Display name");
526 await h.press("enter");
527 await h.until("Model");
528 await h.press("enter");
529 await h.until("Brief");
530 await h.press("enter");
531 await h.until("Budget per run");
532 await h.type("2.5");
533 await h.press("enter");
534 await h.until("pixel is on the crew");
535 expect(h.text()).toContain("$2.50/run");
536 expect(getAgentByName(forge.db, "pixel")?.budgetUsd).toBe(2.5);
537
538 await h.select("pixel");
539 await h.press("d");
540 await h.until("Retire pixel");
541 await h.press("right", "enter");
542 await h.until("pixel retired");
543 expect(getAgentByName(forge.db, "pixel")).toBeUndefined();
544 } finally {
545 h.stop();
546 }
547 }, 30_000);
548
549 test("a non-admin cannot reach the crew's verbs", async () => {
550 const bob = (await import("../src/core/users.ts")).getUserByName(
551 forge.db,
552 "bob",
553 )!;
554 const h = boot(forge, identityFor(bob));
555 try {
556 await h.press("A");
557 await h.until("Account");
558 expect(h.text()).not.toContain("crew");
559 await h.press("c");
560 expect(h.text()).not.toContain("Crew");
561 } finally {
562 h.stop();
563 }
564 }, 30_000);
565
566 test("the dashboard: an Apps pane and an On you pane, both enterable", async () => {
438 test("the dashboard: an On you pane, enterable, and no Apps pane", async () => {
567439 const mine = openIssue(forge, alice, site, {
568440 title: "Tune the drums",
569441 body: "",
@@ -575,13 +447,9 @@ describe("terminal: apps, runs and the crew", () => {
575447
576448 const h = boot(forge, identityFor(alice));
577449 try {
578 await h.until("Apps");
579 expect(h.text()).toContain("On you");
450 await h.until("On you");
580451 expect(h.text()).toContain("Tune the drums");
581 // The apps pane is first in the tab order, one step back from repos.
582 await h.press("shift+tab", "enter");
583 await h.until("slug site");
584 await h.press("esc");
452 expect(h.text()).not.toContain("Apps");
585453 // repos → activity → on you.
586454 await h.press("tab", "tab", "enter");
587455 await h.until("assigned to alice");
@@ -591,75 +459,52 @@ describe("terminal: apps, runs and the crew", () => {
591459 }
592460 }, 30_000);
593461
594 test("T on the app screen: tell the crew, and the free builder takes it", async () => {
462 test("the removed screens are unreachable: no palette entry, no dashboard or account key", async () => {
595463 const h = boot(forge, identityFor(alice));
596464 try {
597 await h.press("a");
598 await h.until("alice/site");
599 await h.press("enter");
600 await h.until("slug site");
601 await h.press("T");
602 await h.until("Tell the crew");
603 await h.type("Make the header pop");
604 await h.press("enter");
605 await h.until("robo is on it");
606 const told = listIssues(forge.db, site.id, { state: "all" }).find(
607 (i) => i.title === "Make the header pop",
608 );
609 expect(told?.assigneeId).toBe(robo.user.id);
610 } finally {
611 h.stop();
612 }
613 }, 30_000);
465 await h.until("On you");
466 // alice is the site admin, so the crew entry would have been there.
467 const ids = forgeCommands(h.session)(h.app.ctx).map((c) => c.id);
468 for (const gone of ["apps", "app", "runs", "new-app", "crew"])
469 expect(ids).not.toContain(gone);
470 expect(ids).toContain("repo");
471 expect(ids).toContain("pulls");
614472
615 test("N on the dashboard reaches New app directly, with no detour through Apps", async () => {
616 const h = boot(forge, identityFor(alice));
617 try {
618 await h.until("Apps");
619 await h.press("N");
620 await h.until("New app");
473 const before = h.text();
474 expect(before).not.toContain("new app");
475 for (const key of ["a", "N", "t"]) {
476 await h.press(key);
477 expect(h.text()).toBe(before);
478 }
479
480 await h.press("A");
481 await h.until("Account");
482 expect(h.text()).not.toContain("crew");
483 const account = h.text();
484 await h.press("c");
485 expect(h.text()).toBe(account);
486
487 // What stays first-class is one key away: the workspace opens on the
488 // repository, its tree and its README.
621489 await h.press("esc");
622 // Esc discarded the flow: the dashboard is back underneath it.
623 await h.until("Apps");
490 await h.until("On you");
491 await h.select("alice/site");
492 await h.press("enter");
493 await h.until("README.md");
624494 } finally {
625495 h.stop();
626496 }
627497 }, 30_000);
628498
629 // Last on purpose: it starts a real deploy of the scaffold in the
630 // background, and nothing after it should have to share the machine.
631 test("new app, the golden path: a name, a sentence, who sees it, who works on it", async () => {
632 const h = boot(forge, identityFor(alice));
499 test("a non-admin's account screen names no crew either", async () => {
500 const bob = getUserByName(forge.db, "bob")!;
501 const h = boot(forge, identityFor(bob));
633502 try {
634 await h.press("a");
635 await h.until("alice/site");
636 await h.press("n");
637 await h.until("New app");
638 await h.type("notes");
639 await h.press("enter");
640 await h.until("What should notes do?");
641 await h.type("A place for notes");
642 await h.press("enter");
643 await h.until("Who may see it?");
644 await h.press("enter");
645 await h.until("Who works on it?");
646 await h.select("robo");
647 await h.press("enter");
648 // The toast names the URL and the agent; the repository opens beneath.
649 await h.until(
650 `notes: starting at http://notes.${DOMAIN}:${servers.port}`,
651 );
652 expect(h.text()).toContain("robo is on #1");
653 const repo = getRepo(forge.db, "alice", "notes");
654 expect(repo).toBeDefined();
655 if (repo === undefined) return;
656 expect(repo.visibility).toBe("private");
657 expect(getAppByRepo(forge.db, repo.id)?.slug).toBe("notes");
658 const first = getIssue(forge.db, repo.id, 1);
659 expect(first?.title).toBe("Build notes");
660 expect(first?.assigneeId).toBe(robo.user.id);
503 await h.press("A");
504 await h.until("Account");
505 expect(h.text()).not.toContain("crew");
661506 } finally {
662507 h.stop();
663508 }
664 }, 60_000);
509 }, 30_000);
665510 });
test/tui-size.test.ts +8 −82
@@ -1,11 +1,11 @@
11 /**
22 * The terminal UI lays itself out for the terminal it is in.
33 *
4 * Four screens at four sizes, against a real forge: every frame fills its
4 * Three screens at four sizes, against a real forge: every frame fills its
55 * grid and nothing crosses the right edge; a list beside its detail pane
6 * stacks below `TWO_PANE_COLS`; a resize mid-session keeps the selected row
7 * and the reader's place in a log; the app's pty hears the new size while it
8 * has the terminal; and under the floor one line says so.
6 * stacks below `TWO_PANE_COLS`; a resize mid-session keeps the selected
7 * row; the app's pty hears the new size while it has the terminal; and
8 * under the floor one line says so.
99 *
1010 * The cell buffer cannot overflow by construction, so the width assertions
1111 * are made against the *replayed* bytes as well — the virtual terminal that
@@ -21,15 +21,12 @@ import { commitToBranch, createRepo } from "../src/actions/repos.ts";
2121 import { signUp } from "../src/actions/users.ts";
2222 import { createApp, type App as Forge } from "../src/app.ts";
2323 import type { Repo } from "../src/core/repos.ts";
24 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
2524 import type { User } from "../src/core/users.ts";
2625 import { quietLogs } from "./helpers/globals.ts";
27 import { appendRunLog } from "../src/runs/log.ts";
2826 import type { App, Screen } from "../src/tui/app/app.ts";
2927 import { createForgeApp } from "../src/tui/forge.ts";
3028 import { issuesScreen } from "../src/tui/screens/issues.ts";
31 import { runScreen } from "../src/tui/screens/run.ts";
32 import { runsScreen } from "../src/tui/screens/runs.ts";
29 import { repoScreen } from "../src/tui/screens/repo.ts";
3330 import { local } from "../src/tui/screens/where.ts";
3431 import type { Session } from "../src/tui/session.ts";
3532 import { Screen as Surface } from "../src/tui/term/screen.ts";
@@ -58,10 +55,6 @@ const SIZES: readonly [number, number][] = [
5855 [200, 60],
5956 ];
6057
61 /** A log line that is one row at 120 columns and two at 60. */
62 const LOG_LINE = (n: number): string =>
63 `line ${n}: cloning the repository and installing what it needs takes a moment`;
64
6558 interface Harness {
6659 app: App;
6760 session: Session;
@@ -78,7 +71,6 @@ describe("the terminal lays out for the terminal it is in", () => {
7871 let forge: Forge;
7972 let alice: User;
8073 let repo: Repo;
81 let runId: string;
8274
8375 const boot = async (cols: number, rows: number): Promise<Harness> => {
8476 const terminal = headlessTerminal(cols, rows);
@@ -119,17 +111,14 @@ describe("the terminal lays out for the terminal it is in", () => {
119111 };
120112
121113 const dashboard = async (h: Harness): Promise<void> => undefined;
122 const runs = (h: Harness): Promise<void> =>
123 h.push((s, ctx) => runsScreen(s, ctx, repo));
124 const run = (h: Harness): Promise<void> =>
125 h.push((s, ctx) => runScreen(s, ctx, repo, runId));
114 const workspace = (h: Harness): Promise<void> =>
115 h.push((s, ctx) => repoScreen(s, ctx, local(s, repo)));
126116 const issues = (h: Harness): Promise<void> =>
127117 h.push((s, ctx) => issuesScreen(s, ctx, local(s, repo), "issue"));
128118 /** Each screen, and the content that proves it drew its rows. */
129119 const SCREENS = {
130120 dashboard: { open: dashboard, shows: "alice/lighthouse", cursor: true },
131 runs: { open: runs, shows: "deploy production", cursor: true },
132 run: { open: run, shows: LOG_LINE(39).slice(-13), cursor: false },
121 workspace: { open: workspace, shows: "README.md", cursor: false },
133122 issues: { open: issues, shows: "Lamp 30", cursor: true },
134123 } as const;
135124
@@ -160,23 +149,6 @@ describe("the terminal lays out for the terminal it is in", () => {
160149 title: `Lamp ${i} flickers at dusk and nobody knows why it does that`,
161150 body: "",
162151 });
163 for (let i = 0; i < 20; i++) {
164 const made = createRun(forge.db, {
165 repoId: repo.id,
166 kind: "deploy",
167 env: i % 2 === 0 ? "production" : "pull-3",
168 reason: "push to main",
169 commitOid: "abcdef0123456789",
170 });
171 claimRun(forge.db, made.id);
172 for (let l = 0; l < 40; l++) appendRunLog(dataDir, made.id, LOG_LINE(l));
173 finishRun(forge.db, made.id, {
174 state: i % 3 === 0 ? "failed" : "ok",
175 summary: `built and started in ${i} seconds`,
176 costUsd: 0,
177 });
178 runId = made.id;
179 }
180152 });
181153
182154 afterAll(async () => {
@@ -271,52 +243,6 @@ describe("the terminal lays out for the terminal it is in", () => {
271243 }
272244 });
273245
274 test("the runs list keeps its cursor across a resize too", async () => {
275 const h = await boot(200, 60);
276 try {
277 await runs(h);
278 await h.press("j", "j", "j", "j", "j", "j", "j");
279 const before = selected(h.lines());
280 expect(before).toBeDefined();
281 await h.resize(60, 20);
282 // Same run: the same state word and summary on the caret row.
283 expect(selected(h.lines())?.slice(0, 40)).toBe(before?.slice(0, 40));
284 } finally {
285 h.stop();
286 }
287 });
288
289 test("a run's log wraps to the width and keeps the reader's line across a resize", async () => {
290 const h = await boot(120, 40);
291 try {
292 await run(h);
293 const text = h.app.screen.toText();
294 expect(text).toContain(LOG_LINE(39));
295 // Scroll up: following stops, and the top row names a line.
296 await h.press("k", "k", "k", "k");
297 const top = h.lines()[6] ?? "";
298 const at = /^line (\d+):/.exec(top);
299 expect(at).not.toBeNull();
300
301 await h.resize(60, 20);
302 const narrow = h.app.screen.toText();
303 // Every word of a long line is somewhere on screen: the tail that a
304 // clipped log would have lost is drawn on the next row.
305 expect(narrow).toContain("takes a moment");
306 for (const line of narrow.split("\n"))
307 expect(displayWidth(line)).toBeLessThanOrEqual(60);
308 // The line that was at the top is still at the top, now two rows tall.
309 expect(h.lines()[6]).toStartWith(`line ${at![1]}:`);
310
311 // `G` follows the end again, and the last row is the last line's tail.
312 await h.press("G");
313 const rows = h.lines();
314 expect(rows[rows.length - 2]).toContain("takes a moment");
315 } finally {
316 h.stop();
317 }
318 });
319
320246 test("the app's pty hears the new size while it holds the terminal", async () => {
321247 const h = await boot(80, 24);
322248 try {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs