Public: anyone can read this; the crew can change it.
Contraction: remove the TUI's apps, runs and crew screens #412
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/398-contraction-remove-the-tui-s-apps-runs-a → main 2 commits fork
17 files
changed +203
−2195
docs/DESIGN.md +19 −35
| @@ -603,10 +603,9 @@ something you can click" is the whole claim. | ||
| 603 | 603 | |
| 604 | 604 | ### 6·0a. The dashboard's panes |
| 605 | 605 | |
| 606 | `Apps` (only when there are any; `a` opens the apps screen regardless), | |
| 607 | 606 | `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 | |
| 610 | 609 | the accent frame, `j`/`k` move inside it and `enter` opens what it is pointing |
| 611 | 610 | at — an activity row opens **the issue, pull or repository it is about**, a |
| 612 | 611 | 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. | ||
| 1050 | 1049 | |
| 1051 | 1050 | --- |
| 1052 | 1051 | |
| 1053 | ### 6g. Apps, runs and the crew | |
| 1052 | ### 6g. Apps, runs and the crew, where the terminal still shows them | |
| 1054 | 1053 | |
| 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: | |
| 1057 | 1058 | |
| 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. | |
| 1072 | 1059 | - **Pull** — a preview line when the repository is an app (`O` opens it, |
| 1073 | 1060 | `P` opens production beside it: comparing the two is the review), a |
| 1074 | 1061 | checks row in the merge box and a _waiting on checks_ state with the refusal |
| 1075 | 1062 | in words on `m`, `a` to assign (nobody, people, then the crew with the bot |
| 1076 | 1063 | 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. | |
| 1086 | 1069 | - **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. | |
| 1088 | 1075 | - **Settings** — the App tab (enable with slug, access and preview data; edit; |
| 1089 | 1076 | `D` to stop running as an app behind a confirm), `C` for require checks on |
| 1090 | 1077 | 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. | |
| 1094 | 1078 | |
| 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. | |
| 1097 | 1081 | |
| 1098 | 1082 | ## 7. Two rules that are security boundaries |
| 1099 | 1083 |
src/tui/forge.ts −35
| @@ -19,8 +19,6 @@ import { fileIcon, icon, type IconName } from "./ui/icons.ts"; | ||
| 19 | 19 | import type { SessionOpts, Terminal } from "./term/terminal.ts"; |
| 20 | 20 | import { Session, type TuiIdentity } from "./session.ts"; |
| 21 | 21 | import { accountScreen } from "./screens/account.ts"; |
| 22 | import { appScreen } from "./screens/app.ts"; | |
| 23 | import { appsScreen, newAppFlow } from "./screens/apps.ts"; | |
| 24 | 22 | import { |
| 25 | 23 | ISSUE_MARK, |
| 26 | 24 | ORIGIN_MARK, |
| @@ -28,9 +26,7 @@ import { | ||
| 28 | 26 | errorText, |
| 29 | 27 | promptScreen, |
| 30 | 28 | } from "./screens/common.ts"; |
| 31 | import { crewScreen } from "./screens/crew.ts"; | |
| 32 | 29 | import { issueScreen } from "./screens/issue.ts"; |
| 33 | import { runsScreen } from "./screens/runs.ts"; | |
| 34 | 30 | import { pullScreen } from "./screens/pull.ts"; |
| 35 | 31 | import { blobScreen } from "./screens/repo.ts"; |
| 36 | 32 | import { dashboardScreen } from "./screens/dashboard.ts"; |
| @@ -73,16 +69,6 @@ const REPO_VERBS: readonly { | ||
| 73 | 69 | }, |
| 74 | 70 | { id: "new-issue", title: "New issue", open: newIssueScreen }, |
| 75 | 71 | { 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 | }, | |
| 86 | 72 | { id: "settings", title: "Repository settings", open: settingsScreen }, |
| 87 | 73 | ]; |
| 88 | 74 | |
| @@ -105,12 +91,6 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 105 | 91 | title: "Explore repositories", |
| 106 | 92 | run: () => ctx.push(exploreScreen(session, ctx)), |
| 107 | 93 | }, |
| 108 | { | |
| 109 | id: "apps", | |
| 110 | title: "Apps", | |
| 111 | hint: "what is running", | |
| 112 | run: () => ctx.push(appsScreen(session, ctx)), | |
| 113 | }, | |
| 114 | 94 | { |
| 115 | 95 | id: "profile", |
| 116 | 96 | title: "Open a profile", |
| @@ -163,27 +143,12 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 163 | 143 | ); |
| 164 | 144 | if (session.signedIn) |
| 165 | 145 | 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 | }, | |
| 174 | 146 | { |
| 175 | 147 | id: "account", |
| 176 | 148 | title: "Account settings", |
| 177 | 149 | run: () => ctx.push(accountScreen(session, ctx)), |
| 178 | 150 | }, |
| 179 | 151 | ); |
| 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 | }); | |
| 187 | 152 | commands.push({ |
| 188 | 153 | id: "quit", |
| 189 | 154 | title: "Quit", |
src/tui/screens/account.ts +1 −12
| @@ -38,7 +38,6 @@ import { | ||
| 38 | 38 | ago, |
| 39 | 39 | runAsync, |
| 40 | 40 | } from "./common.ts"; |
| 41 | import { crewScreen } from "./crew.ts"; | |
| 42 | 41 | |
| 43 | 42 | const TABS = ["Profile", "Tokens", "SSH keys"] as const; |
| 44 | 43 | |
| @@ -58,29 +57,23 @@ export function accountScreen(f: Session, ctx: Ctx): Screen { | ||
| 58 | 57 | const screen: Screen = { |
| 59 | 58 | title: "Account", |
| 60 | 59 | 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")] : []; | |
| 64 | 60 | if (tab === 0) |
| 65 | 61 | return [ |
| 66 | 62 | binding(["tab"], "next tab"), |
| 67 | 63 | binding(["d"], "display name"), |
| 68 | 64 | binding(["e"], "email"), |
| 69 | 65 | binding(["p"], "password"), |
| 70 | ...crew, | |
| 71 | 66 | ]; |
| 72 | 67 | if (tab === 1) |
| 73 | 68 | return [ |
| 74 | 69 | binding(["tab"], "next tab"), |
| 75 | 70 | binding(["n"], "new token"), |
| 76 | 71 | binding(["D"], "revoke"), |
| 77 | ...crew, | |
| 78 | 72 | ]; |
| 79 | 73 | return [ |
| 80 | 74 | binding(["tab"], "next tab"), |
| 81 | 75 | binding(["n"], "add key"), |
| 82 | 76 | binding(["D"], "delete key"), |
| 83 | ...crew, | |
| 84 | 77 | ]; |
| 85 | 78 | }, |
| 86 | 79 | load() { |
| @@ -100,7 +93,7 @@ export function accountScreen(f: Session, ctx: Ctx): Screen { | ||
| 100 | 93 | ["username", f.user.username], |
| 101 | 94 | ["display name", f.user.displayName || "(none)"], |
| 102 | 95 | ["email", f.user.email], |
| 103 | ["role", f.user.isAdmin ? "site admin — c manages the crew" : "user"], | |
| 96 | ["role", f.user.isAdmin ? "site admin" : "user"], | |
| 104 | 97 | ["joined", ago(f.user.createdAt)], |
| 105 | 98 | ]); |
| 106 | 99 | return; |
| @@ -144,10 +137,6 @@ export function accountScreen(f: Session, ctx: Ctx): Screen { | ||
| 144 | 137 | state.index = 0; |
| 145 | 138 | return true; |
| 146 | 139 | } |
| 147 | if (e.key === "c" && f.user?.isAdmin === true) { | |
| 148 | ctx.push(crewScreen(f, ctx)); | |
| 149 | return true; | |
| 150 | } | |
| 151 | 140 | if (tab === 0) return profileKey(e); |
| 152 | 141 | if (tab === 1) return tokenKey(e); |
| 153 | 142 | 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 @@ | ||
| 13 | 13 | */ |
| 14 | 14 | |
| 15 | 15 | import { listCrew } from "../../actions/crew.ts"; |
| 16 | import type { Agent } from "../../core/agents.ts"; | |
| 16 | 17 | import { assignIssue } from "../../actions/issues.ts"; |
| 17 | 18 | import type { Issue } from "../../core/issues.ts"; |
| 18 | 19 | import { canRead, type Repo } from "../../core/repos.ts"; |
| @@ -20,8 +21,27 @@ import { getUserById, listPeople, type User } from "../../core/users.ts"; | ||
| 20 | 21 | import type { Ctx, Screen } from "../app/app.ts"; |
| 21 | 22 | import { icon, type IconSet } from "../ui/icons.ts"; |
| 22 | 23 | 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 | } | |
| 25 | 45 | |
| 26 | 46 | /** |
| 27 | 47 | * ` · 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 { | ||
| 56 | 56 | errorText, |
| 57 | 57 | hitList, |
| 58 | 58 | live, |
| 59 | openLink, | |
| 60 | 59 | relTime, |
| 61 | 60 | runAsync, |
| 62 | 61 | type ListRow, |
| 63 | 62 | } from "./common.ts"; |
| 64 | import { openEnvUrl } from "../../actions/apps.ts"; | |
| 65 | import { openAppTerminal } from "./apptty.ts"; | |
| 66 | 63 | import { describeEvent, type FeedLine } from "./activity.ts"; |
| 67 | 64 | 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"; | |
| 76 | 65 | import { exploreScreen } from "./explore.ts"; |
| 77 | 66 | import { issueScreen } from "./issue.ts"; |
| 78 | 67 | import { newRepoScreen } from "./newrepo.ts"; |
| @@ -83,14 +72,13 @@ import { local } from "./where.ts"; | ||
| 83 | 72 | import { signInScreen } from "./signin.ts"; |
| 84 | 73 | |
| 85 | 74 | /** |
| 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. | |
| 89 | 78 | */ |
| 90 | type PaneId = "apps" | "repos" | "activity" | "onyou" | "machine" | "orgs"; | |
| 79 | type PaneId = "repos" | "activity" | "onyou" | "machine" | "orgs"; | |
| 91 | 80 | |
| 92 | 81 | const TITLES: Readonly<Record<PaneId, string>> = { |
| 93 | apps: "Apps", | |
| 94 | 82 | repos: "Repositories", |
| 95 | 83 | activity: "Recent activity", |
| 96 | 84 | onyou: "On you", |
| @@ -104,14 +92,8 @@ interface OnYouItem { | ||
| 104 | 92 | readonly repo: Repo; |
| 105 | 93 | } |
| 106 | 94 | |
| 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 | ||
| 112 | 95 | /** The `tab` order, whichever of these the frame had room for. */ |
| 113 | 96 | const ORDER: readonly PaneId[] = [ |
| 114 | "apps", | |
| 115 | 97 | "repos", |
| 116 | 98 | "activity", |
| 117 | 99 | "onyou", |
| @@ -125,12 +107,10 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 125 | 107 | let orgs: string[] = []; |
| 126 | 108 | let pins: Pin[] = []; |
| 127 | 109 | let pinned = new Set<string>(); |
| 128 | let apps: AppItem[] = []; | |
| 129 | 110 | let onYou: OnYouItem[] = []; |
| 130 | 111 | const filter = new Filter(); |
| 131 | 112 | /** One cursor per pane: moving in one must never move another. */ |
| 132 | 113 | const cursors: Record<PaneId, ListState> = { |
| 133 | apps: new ListState(), | |
| 134 | 114 | repos: new ListState(), |
| 135 | 115 | activity: new ListState(), |
| 136 | 116 | onyou: new ListState(), |
| @@ -145,10 +125,6 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 145 | 125 | pins = f.user ? listPins(f.db, f.user.id) : []; |
| 146 | 126 | pinned = f.user ? pinnedRepoIds(f.db, f.user.id) : new Set<string>(); |
| 147 | 127 | 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); | |
| 152 | 128 | onYou = assignedToMe(); |
| 153 | 129 | // "Yours" is ownership plus the orgs you are on a team of; everything |
| 154 | 130 | // else lives in Explore, one key away. |
| @@ -204,26 +180,22 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 204 | 180 | * frame decides, not a rule repeated here: at two columns everything is |
| 205 | 181 | * up; narrower, the activity feed stacks under the repositories and the |
| 206 | 182 | * 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. | |
| 210 | 184 | */ |
| 211 | 185 | let drawn: ReadonlySet<PaneId> = new Set<PaneId>(["repos"]); |
| 212 | 186 | const panes = (): PaneId[] => ORDER.filter((pane) => drawn.has(pane)); |
| 213 | 187 | |
| 214 | 188 | const at = (pane: PaneId): ListState => cursors[pane]; |
| 215 | 189 | 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; | |
| 227 | 199 | |
| 228 | 200 | const cycle = (step: number): void => { |
| 229 | 201 | const list = panes(); |
| @@ -294,10 +266,6 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 294 | 266 | ); |
| 295 | 267 | }; |
| 296 | 268 | |
| 297 | const openApp = (item: AppItem | undefined): void => { | |
| 298 | if (item !== undefined) ctx.push(appScreen(f, ctx, item.repo)); | |
| 299 | }; | |
| 300 | ||
| 301 | 269 | const openAssigned = (item: OnYouItem | undefined): void => { |
| 302 | 270 | if (item === undefined) return; |
| 303 | 271 | const src = local(f, item.repo); |
| @@ -310,8 +278,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 310 | 278 | |
| 311 | 279 | /** `enter` on whatever the focused pane's cursor is on. */ |
| 312 | 280 | 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]); | |
| 315 | 282 | else if (focus === "activity") openEvent(feed[at("activity").index]); |
| 316 | 283 | else if (focus === "onyou") openAssigned(onYou[at("onyou").index]); |
| 317 | 284 | else if (focus === "machine") openPin(pins[at("machine").index]); |
| @@ -329,16 +296,12 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 329 | 296 | binding(["j", "k"], "move"), |
| 330 | 297 | binding(["enter"], "open"), |
| 331 | 298 | binding(["/"], "filter"), |
| 332 | binding(["a"], "apps"), | |
| 333 | binding(["o"], "open in browser", { disabled: focus !== "apps" }), | |
| 334 | binding(["t"], "terminal", { disabled: focus !== "apps" }), | |
| 335 | 299 | binding(["e"], "explore"), |
| 336 | 300 | binding(["p"], "pin", { disabled: !f.signedIn }), |
| 337 | 301 | binding(["d"], "unpin", { |
| 338 | 302 | disabled: focus !== "machine" || pins.length === 0, |
| 339 | 303 | }), |
| 340 | 304 | binding(["n"], "new repo", { disabled: !f.signedIn }), |
| 341 | binding(["N"], "new app", { disabled: !f.signedIn }), | |
| 342 | 305 | binding(["A"], "account", { disabled: !f.signedIn }), |
| 343 | 306 | binding(["s"], "sign in", { disabled: f.signedIn }), |
| 344 | 307 | binding(["r"], "refresh"), |
| @@ -346,8 +309,8 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 346 | 309 | }, |
| 347 | 310 | load(signal) { |
| 348 | 311 | // 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 | |
| 351 | 314 | // refreshes all of them. (Run events carry a repo id, so they pass the |
| 352 | 315 | // same filter as everything else.) |
| 353 | 316 | live(f, signal, {}, reload); |
| @@ -361,29 +324,18 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 361 | 324 | main: fill(3), |
| 362 | 325 | aside: fill(2), |
| 363 | 326 | }); |
| 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; | |
| 369 | 327 | // Decide what is on screen before drawing any of it: the focus ring is |
| 370 | 328 | // painted by the draws, and a window that just shrank may have taken |
| 371 | 329 | // the focused pane away, in which case the cursor lands on the |
| 372 | 330 | // repositories, which are always there. |
| 373 | 331 | const here = new Set<PaneId>(["repos"]); |
| 374 | if (appsH > 0) here.add("apps"); | |
| 375 | 332 | if (aside !== undefined) here.add("activity"); |
| 376 | 333 | if (aside !== undefined && !stacked && f.signedIn) |
| 377 | 334 | here.add("onyou").add("machine").add("orgs"); |
| 378 | 335 | drawn = here; |
| 379 | 336 | if (!here.has(focus)) focus = "repos"; |
| 380 | 337 | |
| 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)]); | |
| 387 | 339 | ctx.setCursor(filter.render(s, box!, c, "filter repositories")); |
| 388 | 340 | drawRepos(s, list!, c, set); |
| 389 | 341 | if (aside === undefined) return; |
| @@ -421,26 +373,6 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 421 | 373 | ctx.push(exploreScreen(f, ctx)); |
| 422 | 374 | return true; |
| 423 | 375 | } |
| 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 | } | |
| 444 | 376 | if (e.key === "d" && focus === "machine") { |
| 445 | 377 | unpin(pins[at("machine").index]); |
| 446 | 378 | return true; |
| @@ -463,10 +395,6 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 463 | 395 | ctx.push(newRepoScreen(f, ctx)); |
| 464 | 396 | return true; |
| 465 | 397 | } |
| 466 | if (e.key === "N" && f.signedIn) { | |
| 467 | newAppFlow(f, ctx, screen); | |
| 468 | return true; | |
| 469 | } | |
| 470 | 398 | if (e.key === "A" && f.user) { |
| 471 | 399 | ctx.push(accountScreen(f, ctx)); |
| 472 | 400 | return true; |
| @@ -489,32 +417,6 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 489 | 417 | |
| 490 | 418 | // ---- the panes ---- |
| 491 | 419 | |
| 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 | ||
| 518 | 420 | /** The inbox: what is assigned to this account, across every repository. */ |
| 519 | 421 | function drawOnYou(s: Surface, r: Rect, c: Tokens, set: IconSet): void { |
| 520 | 422 | const on = focus === "onyou"; |
| @@ -563,7 +465,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 563 | 465 | emptyState(s, r, c, { |
| 564 | 466 | title: f.signedIn ? "No repositories yet" : "Nothing public yet", |
| 565 | 467 | 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." | |
| 567 | 469 | : "Press s to sign in, or e to explore.", |
| 568 | 470 | }); |
| 569 | 471 | return; |
src/tui/screens/issue.ts +3 −4
| @@ -45,8 +45,7 @@ import { | ||
| 45 | 45 | } from "./common.ts"; |
| 46 | 46 | import { assignPicker, assignedText } from "./assign.ts"; |
| 47 | 47 | 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"; | |
| 50 | 49 | import { Composer, threadLines, type ThreadEntry } from "./thread.ts"; |
| 51 | 50 | import { local, localRepo, whereRows, whereStrip } from "./where.ts"; |
| 52 | 51 | |
| @@ -110,7 +109,7 @@ export function issueScreen( | ||
| 110 | 109 | const openRun = (): void => { |
| 111 | 110 | const run = crew.selected; |
| 112 | 111 | if (run !== undefined && repo !== undefined) |
| 113 | ctx.push(runScreen(f, ctx, repo, run.id)); | |
| 112 | openRunLog(f, ctx, repo, run); | |
| 114 | 113 | }; |
| 115 | 114 | |
| 116 | 115 | /** The core row a write needs. Only ever reached on a local source. */ |
| @@ -282,7 +281,7 @@ export function issueScreen( | ||
| 282 | 281 | }); |
| 283 | 282 | crew.render(s, inner, c, ctx, focus === "crew", (run) => { |
| 284 | 283 | focus = "crew"; |
| 285 | if (repo !== undefined) ctx.push(runScreen(f, ctx, repo, run.id)); | |
| 284 | if (repo !== undefined) openRunLog(f, ctx, repo, run); | |
| 286 | 285 | }); |
| 287 | 286 | } |
| 288 | 287 |
src/tui/screens/pull.ts +28 −7
| @@ -40,7 +40,11 @@ import { | ||
| 40 | 40 | import { openEnvUrl } from "../../actions/apps.ts"; |
| 41 | 41 | import { listRepoRuns } from "../../actions/runs.ts"; |
| 42 | 42 | 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"; | |
| 44 | 48 | import { getAppByRepo } from "../../core/apps.ts"; |
| 45 | 49 | import { getIssue, type Issue } from "../../core/issues.ts"; |
| 46 | 50 | import { getPull, type Pull } from "../../core/pulls.ts"; |
| @@ -92,13 +96,11 @@ import { | ||
| 92 | 96 | relTime, |
| 93 | 97 | runAsync, |
| 94 | 98 | } from "./common.ts"; |
| 95 | import { envTone } from "./apps.ts"; | |
| 96 | 99 | import { assignPicker, assignedText } from "./assign.ts"; |
| 97 | 100 | import { DiffView, type DiffAnchor, type DiffNote } from "./commits.ts"; |
| 98 | 101 | import { liveBindings, liveKey } from "./mirror.ts"; |
| 99 | 102 | 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"; | |
| 102 | 104 | import { Composer, threadLines, type ThreadEntry } from "./thread.ts"; |
| 103 | 105 | import { local, localRepo, whereRows, whereStrip } from "./where.ts"; |
| 104 | 106 | import { openAppTerminal } from "./apptty.ts"; |
| @@ -716,7 +718,7 @@ export function pullScreen( | ||
| 716 | 718 | }); |
| 717 | 719 | if (tab === 2) { |
| 718 | 720 | 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); | |
| 720 | 722 | }); |
| 721 | 723 | } else if (tab === 1) { |
| 722 | 724 | // The thread count is a section header, not a badge on the tab: how |
| @@ -795,7 +797,7 @@ export function pullScreen( | ||
| 795 | 797 | if (e.key === "enter") { |
| 796 | 798 | const run = crew.selected; |
| 797 | 799 | if (run !== undefined && repo !== undefined) |
| 798 | ctx.push(runScreen(f, ctx, repo, run.id)); | |
| 800 | openRunLog(f, ctx, repo, run); | |
| 799 | 801 | return true; |
| 800 | 802 | } |
| 801 | 803 | } |
| @@ -949,6 +951,21 @@ export type MergeState = | ||
| 949 | 951 | | "ready" |
| 950 | 952 | | "unknown"; |
| 951 | 953 | |
| 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 | ||
| 952 | 969 | /** |
| 953 | 970 | * The preview, in one line: a dot in the environment's tone, the word, the |
| 954 | 971 | * URL as a link, and the error when there is one — never "failed" alone. |
| @@ -982,7 +999,11 @@ function drawPreviewLine( | ||
| 982 | 999 | ? `running${preview.since === undefined ? "" : ` since ${ago(preview.since)}`}` |
| 983 | 1000 | : preview.state === "deploying" |
| 984 | 1001 | ? `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)}`; | |
| 986 | 1007 | x = s.text( |
| 987 | 1008 | x, |
| 988 | 1009 | 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 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 6 | 6 | * |
| 7 | 7 | * A run row says four things in order: its state (a dot in the tone and the |
| 8 | 8 | * word), what it was (`deploy production`, `agent robo`), why (the reason), |
| @@ -11,29 +11,25 @@ | ||
| 11 | 11 | * the summary that says what failed. |
| 12 | 12 | */ |
| 13 | 13 | |
| 14 | import { listRepoRuns } from "../../actions/runs.ts"; | |
| 15 | 14 | import { getIssuesByIds, type Issue } from "../../core/issues.ts"; |
| 16 | 15 | import type { Repo } from "../../core/repos.ts"; |
| 17 | 16 | import type { Run, RunState } from "../../core/runs.ts"; |
| 18 | 17 | 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"; | |
| 21 | 19 | import type { Rect, Screen as Surface } from "../term/screen.ts"; |
| 22 | 20 | import type { Tokens } from "../ui/theme.ts"; |
| 23 | 21 | 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"; | |
| 25 | 23 | import type { Session } from "../session.ts"; |
| 26 | 24 | import { |
| 27 | 25 | ListState, |
| 28 | 26 | drawList, |
| 29 | errorText, | |
| 30 | 27 | hitList, |
| 31 | live, | |
| 32 | 28 | metaOf, |
| 29 | openLink, | |
| 33 | 30 | relTime, |
| 34 | 31 | type ListRow, |
| 35 | 32 | } from "./common.ts"; |
| 36 | import { runScreen } from "./run.ts"; | |
| 37 | 33 | |
| 38 | 34 | /** The tone a run's state wears — the same reading on every surface. */ |
| 39 | 35 | export function runTone(state: RunState): Tone | undefined { |
| @@ -55,13 +51,6 @@ export function runTone(state: RunState): Tone | undefined { | ||
| 55 | 51 | export const runHollow = (state: RunState): boolean => |
| 56 | 52 | state === "queued" || state === "cancelled"; |
| 57 | 53 | |
| 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 | ||
| 65 | 54 | /** `deploy production`, `deploy pull #4`, `agent robo`. */ |
| 66 | 55 | export function runTitle( |
| 67 | 56 | run: Run, |
| @@ -198,66 +187,22 @@ export class RunList { | ||
| 198 | 187 | } |
| 199 | 188 | } |
| 200 | 189 | |
| 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}`); | |
| 263 | 208 | } |
src/tui/screens/settings.ts +6 −2
| @@ -51,7 +51,7 @@ import { truncate } from "../term/width.ts"; | ||
| 51 | 51 | import { fill, len, split } from "../ui/layout.ts"; |
| 52 | 52 | import { labelChip, tabs } from "../ui/widgets.ts"; |
| 53 | 53 | import type { Session } from "../session.ts"; |
| 54 | import { agentPick } from "./apps.ts"; | |
| 54 | import { agentPick } from "./assign.ts"; | |
| 55 | 55 | import { |
| 56 | 56 | ListState, |
| 57 | 57 | confirmScreen, |
| @@ -65,7 +65,6 @@ import { | ||
| 65 | 65 | promptScreen, |
| 66 | 66 | runAsync, |
| 67 | 67 | } from "./common.ts"; |
| 68 | import { budgetText } from "./crew.ts"; | |
| 69 | 68 | import { refPickerScreen } from "./refs.ts"; |
| 70 | 69 | import { local } from "./where.ts"; |
| 71 | 70 | |
| @@ -78,6 +77,11 @@ const TABS = [ | ||
| 78 | 77 | "Danger", |
| 79 | 78 | ] as const; |
| 80 | 79 | |
| 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 | ||
| 81 | 85 | export function settingsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { |
| 82 | 86 | let current = repo; |
| 83 | 87 | let labels: Label[] = []; |
test/apps-ssh.test.ts +13 −18
| @@ -18,10 +18,11 @@ import { parseAppLogin } from "../src/apps/tty.ts"; | ||
| 18 | 18 | import { newApp } from "../src/actions/apps.ts"; |
| 19 | 19 | import { listRuns } from "../src/core/runs.ts"; |
| 20 | 20 | import { createUser, type User } from "../src/core/users.ts"; |
| 21 | import { getRepo } from "../src/core/repos.ts"; | |
| 21 | 22 | import { serve, type Servers } from "../src/http/server.ts"; |
| 22 | 23 | import type { SshServer } from "../src/ssh/server.ts"; |
| 23 | 24 | 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"; | |
| 25 | 26 | import { serveForgeSsh } from "../src/tui/ssh.ts"; |
| 26 | 27 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 27 | 28 | import { getAppBySlug } from "../src/core/apps.ts"; |
| @@ -216,7 +217,7 @@ describe.skipIf(NO_OPENSSH)("a terminal for every app, over ssh", () => { | ||
| 216 | 217 | expect(code).toBe(1); |
| 217 | 218 | }, 30_000); |
| 218 | 219 | |
| 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 () => { | |
| 220 | 221 | const terminal = headlessTerminal(100, 30, "xterm-256color"); |
| 221 | 222 | const { app: tui, session } = createForgeApp( |
| 222 | 223 | app, |
| @@ -230,21 +231,11 @@ describe.skipIf(NO_OPENSSH)("a terminal for every app, over ssh", () => { | ||
| 230 | 231 | ); |
| 231 | 232 | tui.start(); |
| 232 | 233 | 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. | |
| 240 | 236 | 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"); | |
| 248 | 239 | const deadline = Date.now() + 10_000; |
| 249 | 240 | while (!terminal.output().includes("j/k move") && Date.now() < deadline) |
| 250 | 241 | await Bun.sleep(50); |
| @@ -254,11 +245,15 @@ describe.skipIf(NO_OPENSSH)("a terminal for every app, over ssh", () => { | ||
| 254 | 245 | // …and `q` ends it, which is when the forge's UI comes back. |
| 255 | 246 | terminal.feed("q"); |
| 256 | 247 | 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 | ) { | |
| 258 | 253 | await Bun.sleep(50); |
| 259 | 254 | await settleApp(tui); |
| 260 | 255 | } |
| 261 | expect(tui.screen.toText()).toContain("hello"); | |
| 256 | expect(tui.screen.toText()).toContain("alice/hello"); | |
| 262 | 257 | tui.stop(); |
| 263 | 258 | }, 40_000); |
| 264 | 259 |
test/surfaces-adversarial.test.ts +7 −105
| @@ -4,8 +4,7 @@ | ||
| 4 | 4 | * do not already prove: CSRF on the routes those files did not target, |
| 5 | 5 | * rendering safety (a run log, an agent's brief, an issue title are all |
| 6 | 6 | * 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. | |
| 9 | 8 | * |
| 10 | 9 | * No `AppsRuntime` runs here on purpose: every page under test renders from |
| 11 | 10 | * rows alone (`runtime === undefined` is itself a first-class, already |
| @@ -29,12 +28,10 @@ import { createUser, getUserByName, type User } from "../src/core/users.ts"; | ||
| 29 | 28 | import { serve, type Servers } from "../src/http/server.ts"; |
| 30 | 29 | import { id } from "../src/kernel/id.ts"; |
| 31 | 30 | import { quietLogs } from "./helpers/globals.ts"; |
| 32 | import { appendRunLog, runLogListenerCount } from "../src/runs/log.ts"; | |
| 31 | import { appendRunLog } from "../src/runs/log.ts"; | |
| 33 | 32 | 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"; | |
| 38 | 35 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 39 | 36 | import type { Session, TuiIdentity } from "../src/tui/session.ts"; |
| 40 | 37 | import type { App as TuiApp, Screen } from "../src/tui/app/app.ts"; |
| @@ -749,13 +746,6 @@ describe("surfaces adversarial: tui", () => { | ||
| 749 | 746 | files: [{ path: "README.md", content: "# hello\n" }], |
| 750 | 747 | }); |
| 751 | 748 | 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); | |
| 759 | 749 | }, 30_000); |
| 760 | 750 | |
| 761 | 751 | afterAll(async () => { |
| @@ -763,41 +753,6 @@ describe("surfaces adversarial: tui", () => { | ||
| 763 | 753 | await rm(dataDir, { recursive: true, force: true }); |
| 764 | 754 | }); |
| 765 | 755 | |
| 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 | ||
| 801 | 756 | test("a guest cannot assign an issue: the issue screen never wires `a`", async () => { |
| 802 | 757 | const { openIssue, assignIssue } = await import("../src/actions/issues.ts"); |
| 803 | 758 | const { issueScreen } = await import("../src/tui/screens/issue.ts"); |
| @@ -829,66 +784,13 @@ describe("surfaces adversarial: tui", () => { | ||
| 829 | 784 | } |
| 830 | 785 | }, 30_000); |
| 831 | 786 | |
| 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 | ||
| 881 | 787 | 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); | |
| 888 | 788 | const h = boot(identityFor(alice)); |
| 889 | 789 | try { |
| 890 | 790 | // 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 | ); | |
| 892 | 794 | h.pop(); |
| 893 | 795 | await h.settle(); |
| 894 | 796 | // The forge is back where it was; nothing threw, nothing left a toast |
test/tui-apps.test.ts +52 −207
| @@ -1,13 +1,15 @@ | ||
| 1 | 1 | /** |
| 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. | |
| 3 | 7 | * |
| 4 | 8 | * One real forge with a real `AppsRuntime`, driven headlessly. The app under |
| 5 | 9 | * test is deliberately *not* an app — a README and nothing else — so every |
| 6 | 10 | * deploy fails in milliseconds with the one sentence the runtime writes for |
| 7 | 11 | * 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. | |
| 11 | 13 | */ |
| 12 | 14 | |
| 13 | 15 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| @@ -25,10 +27,9 @@ import { | ||
| 25 | 27 | } from "../src/actions/repos.ts"; |
| 26 | 28 | import { createApp, type App as Forge } from "../src/app.ts"; |
| 27 | 29 | 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"; | |
| 32 | 33 | import { |
| 33 | 34 | claimRun, |
| 34 | 35 | createRun, |
| @@ -36,14 +37,13 @@ import { | ||
| 36 | 37 | listRuns, |
| 37 | 38 | type Run, |
| 38 | 39 | } 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"; | |
| 40 | 41 | import { serve, type Servers } from "../src/http/server.ts"; |
| 41 | 42 | import { quietLogs } from "./helpers/globals.ts"; |
| 42 | 43 | import { appendRunLog } from "../src/runs/log.ts"; |
| 43 | 44 | 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"; | |
| 45 | 46 | import { issueScreen } from "../src/tui/screens/issue.ts"; |
| 46 | import { runsScreen } from "../src/tui/screens/runs.ts"; | |
| 47 | 47 | import { pullScreen } from "../src/tui/screens/pull.ts"; |
| 48 | 48 | import { settingsScreen } from "../src/tui/screens/settings.ts"; |
| 49 | 49 | import { local } from "../src/tui/screens/where.ts"; |
| @@ -257,69 +257,6 @@ describe("terminal: apps, runs and the crew", () => { | ||
| 257 | 257 | await rm(dataDir, { recursive: true, force: true }); |
| 258 | 258 | }); |
| 259 | 259 | |
| 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 | ||
| 323 | 260 | test("assign: nobody, the people who can read it, then the crew with its mark", async () => { |
| 324 | 261 | const h = boot(forge, identityFor(alice)); |
| 325 | 262 | try { |
| @@ -346,7 +283,8 @@ describe("terminal: apps, runs and the crew", () => { | ||
| 346 | 283 | // The head line names the assignee and says what it is. |
| 347 | 284 | expect(h.text()).toContain("assigned to robo (agent)"); |
| 348 | 285 | |
| 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. | |
| 350 | 288 | const run = createRun(forge.db, { |
| 351 | 289 | repoId: site.id, |
| 352 | 290 | kind: "agent", |
| @@ -366,16 +304,7 @@ describe("terminal: apps, runs and the crew", () => { | ||
| 366 | 304 | expect(h.text()).toContain("opened pull #2 with the melody"); |
| 367 | 305 | expect(h.text()).toContain("$0.42"); |
| 368 | 306 | 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}`); | |
| 379 | 308 | } finally { |
| 380 | 309 | h.stop(); |
| 381 | 310 | } |
| @@ -506,64 +435,7 @@ describe("terminal: apps, runs and the crew", () => { | ||
| 506 | 435 | } |
| 507 | 436 | }, 30_000); |
| 508 | 437 | |
| 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 () => { | |
| 567 | 439 | const mine = openIssue(forge, alice, site, { |
| 568 | 440 | title: "Tune the drums", |
| 569 | 441 | body: "", |
| @@ -575,13 +447,9 @@ describe("terminal: apps, runs and the crew", () => { | ||
| 575 | 447 | |
| 576 | 448 | const h = boot(forge, identityFor(alice)); |
| 577 | 449 | try { |
| 578 | await h.until("Apps"); | |
| 579 | expect(h.text()).toContain("On you"); | |
| 450 | await h.until("On you"); | |
| 580 | 451 | 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"); | |
| 585 | 453 | // repos → activity → on you. |
| 586 | 454 | await h.press("tab", "tab", "enter"); |
| 587 | 455 | await h.until("assigned to alice"); |
| @@ -591,75 +459,52 @@ describe("terminal: apps, runs and the crew", () => { | ||
| 591 | 459 | } |
| 592 | 460 | }, 30_000); |
| 593 | 461 | |
| 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 () => { | |
| 595 | 463 | const h = boot(forge, identityFor(alice)); |
| 596 | 464 | 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"); | |
| 614 | 472 | |
| 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. | |
| 621 | 489 | 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"); | |
| 624 | 494 | } finally { |
| 625 | 495 | h.stop(); |
| 626 | 496 | } |
| 627 | 497 | }, 30_000); |
| 628 | 498 | |
| 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)); | |
| 633 | 502 | 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"); | |
| 661 | 506 | } finally { |
| 662 | 507 | h.stop(); |
| 663 | 508 | } |
| 664 | }, 60_000); | |
| 509 | }, 30_000); | |
| 665 | 510 | }); |
test/tui-size.test.ts +8 −82
| @@ -1,11 +1,11 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The terminal UI lays itself out for the terminal it is in. |
| 3 | 3 | * |
| 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 | |
| 5 | 5 | * 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. | |
| 9 | 9 | * |
| 10 | 10 | * The cell buffer cannot overflow by construction, so the width assertions |
| 11 | 11 | * are made against the *replayed* bytes as well — the virtual terminal that |
| @@ -21,15 +21,12 @@ import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | ||
| 21 | 21 | import { signUp } from "../src/actions/users.ts"; |
| 22 | 22 | import { createApp, type App as Forge } from "../src/app.ts"; |
| 23 | 23 | import type { Repo } from "../src/core/repos.ts"; |
| 24 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 25 | 24 | import type { User } from "../src/core/users.ts"; |
| 26 | 25 | import { quietLogs } from "./helpers/globals.ts"; |
| 27 | import { appendRunLog } from "../src/runs/log.ts"; | |
| 28 | 26 | import type { App, Screen } from "../src/tui/app/app.ts"; |
| 29 | 27 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 30 | 28 | 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"; | |
| 33 | 30 | import { local } from "../src/tui/screens/where.ts"; |
| 34 | 31 | import type { Session } from "../src/tui/session.ts"; |
| 35 | 32 | import { Screen as Surface } from "../src/tui/term/screen.ts"; |
| @@ -58,10 +55,6 @@ const SIZES: readonly [number, number][] = [ | ||
| 58 | 55 | [200, 60], |
| 59 | 56 | ]; |
| 60 | 57 | |
| 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 | ||
| 65 | 58 | interface Harness { |
| 66 | 59 | app: App; |
| 67 | 60 | session: Session; |
| @@ -78,7 +71,6 @@ describe("the terminal lays out for the terminal it is in", () => { | ||
| 78 | 71 | let forge: Forge; |
| 79 | 72 | let alice: User; |
| 80 | 73 | let repo: Repo; |
| 81 | let runId: string; | |
| 82 | 74 | |
| 83 | 75 | const boot = async (cols: number, rows: number): Promise<Harness> => { |
| 84 | 76 | const terminal = headlessTerminal(cols, rows); |
| @@ -119,17 +111,14 @@ describe("the terminal lays out for the terminal it is in", () => { | ||
| 119 | 111 | }; |
| 120 | 112 | |
| 121 | 113 | 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))); | |
| 126 | 116 | const issues = (h: Harness): Promise<void> => |
| 127 | 117 | h.push((s, ctx) => issuesScreen(s, ctx, local(s, repo), "issue")); |
| 128 | 118 | /** Each screen, and the content that proves it drew its rows. */ |
| 129 | 119 | const SCREENS = { |
| 130 | 120 | 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 }, | |
| 133 | 122 | issues: { open: issues, shows: "Lamp 30", cursor: true }, |
| 134 | 123 | } as const; |
| 135 | 124 | |
| @@ -160,23 +149,6 @@ describe("the terminal lays out for the terminal it is in", () => { | ||
| 160 | 149 | title: `Lamp ${i} flickers at dusk and nobody knows why it does that`, |
| 161 | 150 | body: "", |
| 162 | 151 | }); |
| 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 | } | |
| 180 | 152 | }); |
| 181 | 153 | |
| 182 | 154 | afterAll(async () => { |
| @@ -271,52 +243,6 @@ describe("the terminal lays out for the terminal it is in", () => { | ||
| 271 | 243 | } |
| 272 | 244 | }); |
| 273 | 245 | |
| 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 | ||
| 320 | 246 | test("the app's pty hears the new size while it holds the terminal", async () => { |
| 321 | 247 | const h = await boot(80, 24); |
| 322 | 248 | try { |
new above
offline