From 9136c54e407ade5ab9ba7e525bf0f4335d8ddc58 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 10:32:21 -0500 Subject: [PATCH 1/8] Shell: the sidebar block is a pure builder the preview and the site share, and the sample clock runs forward sidebarBlock() in tui/lib/sidebar.ts draws the Shells block as rows of tones (watchTone beside kindTone); the preview CLI's private copy is gone. The sample dev server's log said 12:04:19 then 12:04:10: its clock now counts on, one second a line. Co-Authored-By: Claude Opus 5.5 --- packages/shell/src/cli/preview.ts | 75 +------- packages/shell/src/cli/samples.ts | 9 +- packages/shell/src/tui/lib/sidebar.ts | 103 ++++++++++- packages/shell/src/tui/lib/view.ts | 13 +- packages/shell/test/sidebar-block.test.ts | 41 +++++ site/src/components/landing/Bay.astro | 162 ------------------ site/src/components/landing/Closing.astro | 35 ---- site/src/components/landing/Compare.astro | 33 ---- site/src/components/landing/Fitted.astro | 42 ----- site/src/components/landing/Next.astro | 34 ---- site/src/components/landing/Platform.astro | 36 ---- site/src/components/landing/SectionHead.astro | 9 - site/src/components/landing/Specs.astro | 17 -- 13 files changed, 162 insertions(+), 447 deletions(-) create mode 100644 packages/shell/test/sidebar-block.test.ts delete mode 100644 site/src/components/landing/Bay.astro delete mode 100644 site/src/components/landing/Closing.astro delete mode 100644 site/src/components/landing/Compare.astro delete mode 100644 site/src/components/landing/Fitted.astro delete mode 100644 site/src/components/landing/Next.astro delete mode 100644 site/src/components/landing/Platform.astro delete mode 100644 site/src/components/landing/SectionHead.astro delete mode 100644 site/src/components/landing/Specs.astro diff --git a/packages/shell/src/cli/preview.ts b/packages/shell/src/cli/preview.ts index 2b8afd05..d277b2d6 100644 --- a/packages/shell/src/cli/preview.ts +++ b/packages/shell/src/cli/preview.ts @@ -12,24 +12,14 @@ */ import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui" -import { - emptyBlock, - FEWER_TEXT, - HEADING_GAP, - moreText, - type State as Shared, - summaryRuns, - warnRows, -} from "@opencode-cockpit/client/design" import type { ScreenResult, ShellInfo } from "@opencode-cockpit/protocol/shell" import { type ConsoleInput, consoleRows, type Row, type Run } from "../tui/lib/console.ts" -import { fold, sidebarRow } from "../tui/lib/sidebar.ts" +import { sidebarBlock } from "../tui/lib/sidebar.ts" import { badgeText, displayCommand, kindColor, kindOf, - STATE, statusDetail, tailRuns, truncate, @@ -112,66 +102,9 @@ interface SidebarState { notices?: readonly string[] } -function sidebar(list: readonly ShellInfo[], width: number, state: SidebarState = {}): Row[] { - /** As `components/sidebar.tsx` draws them: the design module's rows for an empty block and a notice. */ - const warnings = (state.notices ?? []).flatMap((text) => warnRows(text, width).map((row) => row as Row)) - if (list.length === 0) - return [ - ...emptyBlock("Shells", width, state.hideWhenEmpty === true && warnings.length === 0).map( - (row) => row as Row, - ), - ...warnings, - ] - const folding = fold({ - all: list, - folded: list.filter((shell) => kindOf(shell) === "run" || kindOf(shell) === "fail"), - showAll: state.showAll === true, - rows: 5, - expandedRows: 12, - }) - const toggle: Row[] = !folding.toggle - ? [] - : [ - fitRow( - [ - { - text: ` ${folding.toggle === "fewer" ? FEWER_TEXT : moreText(folding.more)}`, - tone: "muted", - }, - ], - width, - ), - ] - return [...blockRows(folding.shown, list, width), ...toggle, ...warnings] -} - -function blockRows(shown: readonly ShellInfo[], list: readonly ShellInfo[], width: number): Row[] { - /** As `components/sidebar.tsx` draws it: the name left, every count flush right, no row of air. */ - const tally: Partial> = {} - for (const shell of list) tally[STATE[kindOf(shell)]] = (tally[STATE[kindOf(shell)]] ?? 0) + 1 - const counts = summaryRuns(tally, Math.max(8, width - "Shells ".length)) - const used = "Shells".length + counts.reduce((n, part) => n + part.text.length, 0) - const heading: Row = [ - { text: "Shells", bold: true }, - { text: " ".repeat(Math.max(1, width - used)) }, - ...counts.map((part): Run => ({ text: part.text, tone: part.tone ?? "muted" })), - ] - return [ - fitRow(heading, width), - ...Array.from({ length: HEADING_GAP }, () => fitRow([], width)), - ...shown.map((shell): Row => { - const row = sidebarRow(shell, SAMPLE_NOW, 2, width) - const kind = hex(kindColor(theme, kindOf(shell))) - return [ - { text: row.rule, fg: kind }, - { text: row.label, fg: kind, bold: true }, - { text: row.title }, - { text: row.watch, fg: hex(watchColor(theme, shell)) }, - { text: row.detail, tone: "muted" }, - ] - }), - ] -} +/** The block as `components/sidebar.tsx` draws it: the shared builder, at the sample's moment. */ +const sidebar = (list: readonly ShellInfo[], width: number, state: SidebarState = {}): Row[] => + sidebarBlock({ list, now: SAMPLE_NOW, frame: 2, width, ...state }) // --- the dock ------------------------------------------------------------------------------------ diff --git a/packages/shell/src/cli/samples.ts b/packages/shell/src/cli/samples.ts index dbfbd153..26d0afe1 100644 --- a/packages/shell/src/cli/samples.ts +++ b/packages/shell/src/cli/samples.ts @@ -94,9 +94,14 @@ export const SAMPLE_LIST: ShellInfo[] = [ SHELLS.done, ] +/** One second apart, from 12:04:10 on: the clock only moves forward, however many lines there are. */ +const clock = (i: number): string => { + const at = 4 * 60 + 10 + i + return `12:${String(Math.floor(at / 60)).padStart(2, "0")}:${String(at % 60).padStart(2, "0")}` +} + const vite = (i: number): ScreenRun[] => [ - { text: "12:04:1" }, - { text: String(i % 10) }, + { text: clock(i) }, { text: " PM " }, { text: "[vite]", fg: "#56b6c2", bold: true }, { text: " hmr update ", fg: "#7fd88f" }, diff --git a/packages/shell/src/tui/lib/sidebar.ts b/packages/shell/src/tui/lib/sidebar.ts index 763c2096..8452e4bc 100644 --- a/packages/shell/src/tui/lib/sidebar.ts +++ b/packages/shell/src/tui/lib/sidebar.ts @@ -11,8 +11,18 @@ * in `kindColor` and `watchColor`, and the preview paints through the same two. */ +import { + emptyBlock, + FEWER_TEXT, + HEADING_GAP, + moreText, + type State as Shared, + summaryRuns, + warnRows, +} from "@opencode-cockpit/client/design" import type { ShellInfo } from "@opencode-cockpit/protocol/shell" -import { badgeText, type Kind, kindOf, shortDetail, watchLabel } from "./view.ts" +import type { Row, Run } from "./console.ts" +import { badgeText, type Kind, kindOf, kindTone, STATE, shortDetail, watchLabel, watchTone } from "./view.ts" export interface SidebarRow { kind: Kind @@ -131,3 +141,94 @@ export function sidebarRow(shell: ShellInfo, now: number, frame: number, width: const badgeOnly = badge.slice(0, Math.max(0, width)).padEnd(Math.max(0, width)) return { ...row, rule: badgeOnly.slice(0, 1), label: badgeOnly.slice(1), title: "", watch: "", detail: "" } } + +export interface BlockInput { + list: readonly ShellInfo[] + now: number + /** The spinner's clock. */ + frame: number + width: number + /** Expanded by a click on `+ N more`. */ + showAll?: boolean + hideWhenEmpty?: boolean + /** Settings to fix, as sentences (client/settings `noticeText`): `!` rows under the block. */ + notices?: readonly string[] +} + +/** Exactly `width` cells: cut with `…`, or padded. */ +function fit(row: Row, width: number): Row { + const out: Row = [] + let used = 0 + for (const run of row) { + if (used >= width) break + const room = width - used + const text = run.text.length > room ? `${run.text.slice(0, Math.max(0, room - 1))}…` : run.text + out.push({ ...run, text }) + used += text.length + } + if (used < width) out.push({ text: " ".repeat(width - used) }) + return out +} + +/** + * The Shells block as rows of tones, as `components/sidebar.tsx` draws it: the name left and every + * count flush right with no row of air, the shells folded to the ones still worth a look, `+ N more` + * under them, and any settings notice last. The preview CLI prints it and the site draws it; a theme + * turns the tones into colours. + */ +export function sidebarBlock(input: BlockInput): Row[] { + const { list, now, frame, width } = input + const warnings = (input.notices ?? []).flatMap((text) => warnRows(text, width).map((row) => row as Row)) + if (list.length === 0) + return [ + ...emptyBlock("Shells", width, input.hideWhenEmpty === true && warnings.length === 0).map( + (row) => row as Row, + ), + ...warnings, + ] + const folding = fold({ + all: list, + folded: list.filter((shell) => kindOf(shell) === "run" || kindOf(shell) === "fail"), + showAll: input.showAll === true, + rows: 5, + expandedRows: 12, + }) + const tally: Partial> = {} + for (const shell of list) tally[STATE[kindOf(shell)]] = (tally[STATE[kindOf(shell)]] ?? 0) + 1 + const counts = summaryRuns(tally, Math.max(8, width - "Shells ".length)) + const used = "Shells".length + counts.reduce((n, part) => n + part.text.length, 0) + const heading: Row = [ + { text: "Shells", bold: true }, + { text: " ".repeat(Math.max(1, width - used)) }, + ...counts.map((part): Run => ({ text: part.text, tone: part.tone ?? "muted" })), + ] + const rows = folding.shown.map((shell): Row => { + const row = sidebarRow(shell, now, frame, width) + const tone = kindTone(kindOf(shell)) + return fit( + [ + { text: row.rule, tone }, + { text: row.label, tone, bold: true }, + { text: row.title }, + { text: row.watch, tone: watchTone(shell) }, + { text: row.detail, tone: "muted" }, + ], + width, + ) + }) + const toggle = folding.toggle + ? [ + fit( + [{ text: ` ${folding.toggle === "fewer" ? FEWER_TEXT : moreText(folding.more)}`, tone: "muted" }], + width, + ), + ] + : [] + return [ + fit(heading, width), + ...Array.from({ length: HEADING_GAP }, () => fit([], width)), + ...rows, + ...toggle, + ...warnings, + ] +} diff --git a/packages/shell/src/tui/lib/view.ts b/packages/shell/src/tui/lib/view.ts index 44997221..e78b71f8 100644 --- a/packages/shell/src/tui/lib/view.ts +++ b/packages/shell/src/tui/lib/view.ts @@ -145,20 +145,23 @@ export function watchLabel(s: ShellInfo): string { return `${name} ${mark}` } -export function watchColor(theme: TuiThemeCurrent, s: ShellInfo) { +/** A watcher's health as a tone: what it says, before any theme decides how that looks. */ +export function watchTone(s: ShellInfo): "success" | "error" | "accent" | "muted" { switch (s.watch?.status) { case "ok": - return theme.success + return "success" case "fail": - return theme.error + return "error" /** Pending: still unmistakably a watcher, in the colour the console uses for what is live. */ case "pending": - return theme.accent + return "accent" default: - return theme.textMuted + return "muted" } } +export const watchColor = (theme: TuiThemeCurrent, s: ShellInfo) => toneColor(theme, watchTone(s)) + /** * Compact detail for narrow lists: how long it ran, in the sidebar's one kind of time — or, for a * failure, the exit code, which is the fact worth the room. diff --git a/packages/shell/test/sidebar-block.test.ts b/packages/shell/test/sidebar-block.test.ts new file mode 100644 index 00000000..15deff93 --- /dev/null +++ b/packages/shell/test/sidebar-block.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test" +import { EMPTY_TEXT } from "@opencode-cockpit/client/design" +import { SAMPLE_LIST, SAMPLE_NOW, SHELLS } from "../src/cli/samples.ts" +import type { Row } from "../src/tui/lib/console.ts" +import { sidebarBlock } from "../src/tui/lib/sidebar.ts" + +const text = (row: Row) => row.map((run) => run.text).join("") +const block = (list = SAMPLE_LIST, width = 36, showAll = false) => + sidebarBlock({ list, now: SAMPLE_NOW, frame: 0, width, showAll }) + +describe("the Shells block, as rows of tones", () => { + test("every row is exactly its width", () => { + for (const width of [24, 30, 36, 48]) + for (const showAll of [false, true]) + for (const row of block(SAMPLE_LIST, width, showAll)) expect(text(row).length).toBe(width) + }) + + test("the heading names the block and counts every state, flush right", () => { + const heading = text(block()[0]) + expect(heading.startsWith("Shells")).toBe(true) + expect(heading.trimEnd()).toMatch(/running/) + }) + + test("folded: what still needs a look, then `+ N more`; expanded: all of them and `− fewer`", () => { + const folded = block().map(text) + expect(folded.at(-1)).toMatch(/\+ \d+ more/) + expect(folded.some((row) => row.includes("DONE"))).toBe(false) + const open = block(SAMPLE_LIST, 36, true).map(text) + expect(open.some((row) => row.includes("DONE"))).toBe(true) + }) + + test("a state is a tone, not a colour: a failed shell's mark is the error tone", () => { + const failed = block([SHELLS.failed]).find((row) => text(row).includes("FAIL")) + expect(failed?.find((run) => run.text.includes("FAIL"))?.tone).toBe("error") + expect(failed?.some((run) => run.fg !== undefined)).toBe(false) + }) + + test("empty: the client's block — heading, its row of air, `none yet`", () => { + expect(block([]).map((row) => text(row).trim())).toEqual(["Shells", "", EMPTY_TEXT]) + }) +}) diff --git a/site/src/components/landing/Bay.astro b/site/src/components/landing/Bay.astro deleted file mode 100644 index 2e4fa461..00000000 --- a/site/src/components/landing/Bay.astro +++ /dev/null @@ -1,162 +0,0 @@ ---- -/** - * One bay, given a section of its own: what it is, what it gives you, and it running. - * - * Every bay renders through this, so fitting another is an entry in `bays.items` rather than a - * redesign of the page. Media is either the recordings (Shell) or a screenshot (Statusline); the - * shape of the section does not change between them. - */ -import { bays } from "../../data/landing" - -interface Props { - id: string - number: string -} -const { id, number } = Astro.props -const bay = bays.items.find((item) => item.id === id)! -const base = import.meta.env.BASE_URL.replace(/\/$/, "") -const casts = bay.media.kind === "casts" ? bay.media.clips : [] ---- -
-
-

{number} {bay.tagline}

-

{bay.name}

-

{bay.blurb}

-

- {/* A bay that gives the agent nothing says so by leaving the side out, not by printing a dash. */} - {bay.gains.agent !== "—" && agent {bay.gains.agent}} - you {bay.gains.you} -

-
- -
-
    - {bay.points.map((point) => ( -
  • →{point}
  • - ))} -
- - {bay.media.kind === "image" && ( -
- {bay.media.alt} -
{bay.media.caption}
-
- )} - - {bay.media.kind === "casts" && ( -
-
- {casts.map((clip, i) => ( - - ))} -
-
-
-

{casts[0]?.caption}

-
-
- )} -
- - -
- - - - - - diff --git a/site/src/components/landing/Closing.astro b/site/src/components/landing/Closing.astro deleted file mode 100644 index 30ffb5fa..00000000 --- a/site/src/components/landing/Closing.astro +++ /dev/null @@ -1,35 +0,0 @@ ---- -import { closing, nav } from "../../data/landing" -const base = import.meta.env.BASE_URL ---- -
-
-

{closing.title}

-

{closing.body}

- -
-
- - - - diff --git a/site/src/components/landing/Compare.astro b/site/src/components/landing/Compare.astro deleted file mode 100644 index ed7c967a..00000000 --- a/site/src/components/landing/Compare.astro +++ /dev/null @@ -1,33 +0,0 @@ ---- -import SectionHead from "./SectionHead.astro" -import { compare } from "../../data/landing" ---- -
- -
-
- {compare.columns[0]}{compare.columns[1]} -
- {compare.rows.map(([then, now], i) => ( -
- {String(i + 1).padStart(2, "0")}{then} - →{now} -
- ))} -
-
- - diff --git a/site/src/components/landing/Fitted.astro b/site/src/components/landing/Fitted.astro deleted file mode 100644 index 402b7297..00000000 --- a/site/src/components/landing/Fitted.astro +++ /dev/null @@ -1,42 +0,0 @@ ---- -/** The index: what is fitted, in one glance, each linking to its own section below. */ -import SectionHead from "./SectionHead.astro" -import { bays } from "../../data/landing" ---- -
- - - -
- - diff --git a/site/src/components/landing/Next.astro b/site/src/components/landing/Next.astro deleted file mode 100644 index 07e9809b..00000000 --- a/site/src/components/landing/Next.astro +++ /dev/null @@ -1,34 +0,0 @@ ---- -/** What is coming, and the reason it is next. */ -import SectionHead from "./SectionHead.astro" -import { next } from "../../data/landing" ---- -
- - -
- {next.items.map((item) => ( -
-
-

{item.name}

- {item.state === "next" ? "next up" : "after that"} -
-

{item.blurb}

- {item.why &&

{item.why}

} -
- ))} -
- -
- - diff --git a/site/src/components/landing/Platform.astro b/site/src/components/landing/Platform.astro deleted file mode 100644 index 9d969d1f..00000000 --- a/site/src/components/landing/Platform.astro +++ /dev/null @@ -1,36 +0,0 @@ ---- -import SectionHead from "./SectionHead.astro" -import { platform } from "../../data/landing" - -/** One inline set of glyphs: no icon dependency, and they inherit the accent. */ -const paths: Record = { - daemon: '', - config: '', - panel: '', - wire: '', -} ---- -
- -
- {platform.pieces.map((p) => ( -
-
-
-

{p.title}

-

-

-
- ))} -
-
- - diff --git a/site/src/components/landing/SectionHead.astro b/site/src/components/landing/SectionHead.astro deleted file mode 100644 index 8d3f27a8..00000000 --- a/site/src/components/landing/SectionHead.astro +++ /dev/null @@ -1,9 +0,0 @@ ---- -interface Props { number: string; kicker: string; title: string; intro?: string } -const { number, kicker, title, intro } = Astro.props ---- -
- {number && {number}}{kicker} -
-

{title}

-{intro &&

} diff --git a/site/src/components/landing/Specs.astro b/site/src/components/landing/Specs.astro deleted file mode 100644 index a0143290..00000000 --- a/site/src/components/landing/Specs.astro +++ /dev/null @@ -1,17 +0,0 @@ ---- -import { specs } from "../../data/landing" ---- -

-
- {specs.map((s) => ( - {s.value}{s.label} - ))} -
-
- - From e816f4b0b0a3a530838d68cb60fd0a2f1083d0b6 Mon Sep 17 00:00:00 2001 From: Codestz Date: Sun, 4 Oct 2026 10:32:21 -0500 Subject: [PATCH 2/8] Landing: the Flight Deck page, drawn by the bays' own renderers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hero is an OpenCode window playing a scripted session — a background test run that fails and passes, subagents, Trail recording the PR — then a section per bay, Trail told as four steps with its real recording one click away, /cockpit-setup, and install. Every terminal row is the bay's own core/view code, bundled per bay by scripts/engine.ts and fetched as its section nears the screen. The install commands come from the package version, for OpenCode 1 and 2. Co-Authored-By: Claude Opus 5.5 --- site/.gitignore | 2 + site/engine/bays/review.ts | 21 + site/engine/bays/shell.ts | 46 ++ site/engine/bays/status.ts | 24 + site/engine/bays/subagents.ts | 32 + site/engine/bays/trail.ts | 36 ++ site/engine/bays/trust.ts | 17 + site/engine/bays/updater.ts | 32 + site/engine/paint.ts | 52 ++ site/engine/zlib-stub.ts | 3 + site/package.json | 2 +- site/scripts/engine.ts | 52 ++ site/src/components/Cast.astro | 2 +- site/src/components/landing/Feature.astro | 23 + site/src/components/landing/Final.astro | 14 + site/src/components/landing/Hero.astro | 97 ++-- site/src/components/landing/Install.astro | 71 +-- site/src/components/landing/Nav.astro | 66 +-- site/src/components/landing/Setup.astro | 18 + site/src/components/landing/TrailStory.astro | 35 ++ site/src/components/landing/Window.astro | 23 + site/src/data/landing.ts | 577 ++++++------------- site/src/layouts/Base.astro | 2 +- site/src/lib/inline.ts | 7 + site/src/pages/index.astro | 58 +- site/src/scripts/landing.ts | 400 +++++++++++++ site/src/styles/engine.css | 39 ++ site/src/styles/landing.css | 284 ++++++++- 28 files changed, 1403 insertions(+), 632 deletions(-) create mode 100644 site/engine/bays/review.ts create mode 100644 site/engine/bays/shell.ts create mode 100644 site/engine/bays/status.ts create mode 100644 site/engine/bays/subagents.ts create mode 100644 site/engine/bays/trail.ts create mode 100644 site/engine/bays/trust.ts create mode 100644 site/engine/bays/updater.ts create mode 100644 site/engine/paint.ts create mode 100644 site/engine/zlib-stub.ts create mode 100644 site/scripts/engine.ts create mode 100644 site/src/components/landing/Feature.astro create mode 100644 site/src/components/landing/Final.astro create mode 100644 site/src/components/landing/Setup.astro create mode 100644 site/src/components/landing/TrailStory.astro create mode 100644 site/src/components/landing/Window.astro create mode 100644 site/src/lib/inline.ts create mode 100644 site/src/scripts/landing.ts create mode 100644 site/src/styles/engine.css diff --git a/site/.gitignore b/site/.gitignore index 33ff25bc..2e60e1c2 100644 --- a/site/.gitignore +++ b/site/.gitignore @@ -5,3 +5,5 @@ public/casts/ # synced from media/ by scripts/sync.ts, like the casts above public/media/ src/content/docs/help/changelog.md +# built from engine/ by scripts/engine.ts +public/engine/ diff --git a/site/engine/bays/review.ts b/site/engine/bays/review.ts new file mode 100644 index 00000000..553fbf51 --- /dev/null +++ b/site/engine/bays/review.ts @@ -0,0 +1,21 @@ +/** Review: the pane (ctrl+x v) over the product's fixtures — a note waiting on the agent, and a PNG diff. */ +import { FIXTURES } from "../../../packages/review/src/core/fixtures.ts" +import { emptyReview, open, toggleRead } from "../../../packages/review/src/core/model/review.ts" +import { waitingOnAgent } from "../../../packages/review/src/core/model/submit.ts" +import { layout } from "../../../packages/review/src/core/view/layout.ts" + +/** One turn's work: the first file viewed, a note on the second, waiting on the agent. */ +export function noted(width: number, height: number) { + const { changes, looks } = FIXTURES.turn + const file = changes.files[1].path + let review = toggleRead(emptyReview(), changes.files[0].path) + review = open(review, { file, line: 1 }, "this swallows the parse error") + return layout(changes, review, { file, cursor: file, context: 3, waiting: waitingOnAgent(review).length, ...(looks ? { looks } : {}) } as never, { width, height }) +} + +/** A changed screenshot: before → after, in the terminal, the changed pixels lit. */ +export function image(width: number, height: number, index = 1) { + const { changes, looks } = FIXTURES.images + const file = changes.files[index % changes.files.length].path + return layout(changes, emptyReview(), { file, cursor: file, context: 3, waiting: 0, looks } as never, { width, height }) +} diff --git a/site/engine/bays/shell.ts b/site/engine/bays/shell.ts new file mode 100644 index 00000000..4c55b67c --- /dev/null +++ b/site/engine/bays/shell.ts @@ -0,0 +1,46 @@ +/** Shell: the console (ctrl+x j) over the product's sample shells and their screens, and the sidebar block. */ +import type { ShellInfo } from "@opencode-cockpit/protocol/shell" +import * as samples from "../../../packages/shell/src/cli/samples.ts" +import { consoleRows } from "../../../packages/shell/src/tui/lib/console.ts" +import { sidebarBlock } from "../../../packages/shell/src/tui/lib/sidebar.ts" + +const { SHELLS, SAMPLE_LIST, SAMPLE_NOW, SAMPLE_PROJECT, SAMPLE_LOG, DEV_SCREEN, TEST_SCREEN, BUILD_SCREEN } = samples +export const NOW = SAMPLE_NOW +export type Which = "running" | "failed" | "done" + +export function screen(which: Which, width: number, height: number, frame: number, now = SAMPLE_NOW) { + const shell = SHELLS[which] + const screen = which === "failed" ? TEST_SCREEN : which === "done" ? BUILD_SCREEN : DEV_SCREEN + return consoleRows({ + shell, now, frame, project: SAMPLE_PROJECT, screen, log: SAMPLE_LOG, view: "screen", up: 0, + typing: false, colors: true, filter: "", searching: false, draft: "", + keys: { shell: true, running: which === "running", view: "screen", filtered: false, count: SAMPLE_LIST.length, scope: "session", finished: 3 }, + position: `${SAMPLE_LIST.indexOf(shell) + 1}/${SAMPLE_LIST.length}`, width, height, fill: false, + } as never) +} + +/** The Shells block for any list of shells, at `now`. */ +export const sidebar = (list: readonly ShellInfo[], now: number, frame: number, width: number) => + sidebarBlock({ list, now, frame, width }) + +/** A sample shell to build a scripted one from: a dev server, its type checker healthy. */ +export const devServer = (startedAt: number): ShellInfo => ({ + ...SHELLS.running, + startedAt, + watch: { preset: "tsc", status: "ok", runs: 4, since: startedAt }, +}) + +/** A test run, as the hero scripts it: running, then exited — failed with its line, or passed. */ +export function testRun(run: number, startedAt: number, ended?: { at: number; failed?: string }): ShellInfo { + return { + ...SHELLS.done, + id: "sh_test", + title: "bun test", + args: ["-c", "bun test"], + run, + startedAt, + ...(ended + ? { status: "exited", exitCode: ended.failed ? 1 : 0, endedAt: ended.at, ...(ended.failed ? { summary: ended.failed } : {}) } + : { status: "running", exitCode: undefined, endedAt: undefined, pid: 4300 + run }), + } +} diff --git a/site/engine/bays/status.ts b/site/engine/bays/status.ts new file mode 100644 index 00000000..40b7c77e --- /dev/null +++ b/site/engine/bays/status.ts @@ -0,0 +1,24 @@ +/** Status: the sidebar table and the line under the prompt, over the product's fixtures. */ +import { FIXTURES } from "../../../packages/status/src/core/fixtures.ts" +import { fit, fitColumn } from "../../../packages/status/src/core/render.ts" +import { buildSegments } from "../../../packages/status/src/core/segments.ts" +import type { Run } from "../paint.ts" + +/* SIDEBAR_SEGMENTS (status/src/core/config.ts), copied: config.ts reaches for the settings file. */ +const SIDEBAR = ["title", { type: "context", style: "solid", width: 16, icon: "" }, { type: "session.status", priority: 95, icon: "", working: false }, "diagnostics", { type: "tokens", style: "row", icon: "" }, "in", "out", "cache", "write", "sep", "spend", "avail", "sep", "git"].map((e) => (typeof e === "string" ? { type: e } : e)) +const LINE = [{ type: "context", style: "bar", width: 14, icon: "" }, { type: "tokens", format: "tk {total}", icon: "" }, { type: "git.diff", icon: "" }, "todo", "session.status"].map((e) => (typeof e === "string" ? { type: e } : e)) + +type Fixture = keyof typeof FIXTURES +const ctx = (fixture: Fixture, width: number) => ({ ...FIXTURES[fixture].ctx, width }) + +export const table = (fixture: Fixture, width: number, rows = 14) => + fitColumn(buildSegments(ctx(fixture, width), SIDEBAR as never, { icons: true, debug: false }), width, rows).segments.map((s) => s.runs as Run[]) + +export function line(fixture: Fixture, width: number): Run[][] { + const out: Run[] = [] + fit(buildSegments(ctx(fixture, width), LINE as never, { icons: true, debug: false }), width, " │ ").segments.forEach((s, i) => { + if (i) out.push({ text: " │ ", tone: "border" }) + out.push(...(s.runs as Run[])) + }) + return [out] +} diff --git a/site/engine/bays/subagents.ts b/site/engine/bays/subagents.ts new file mode 100644 index 00000000..609ccff9 --- /dev/null +++ b/site/engine/bays/subagents.ts @@ -0,0 +1,32 @@ +/** Subagents: the product's recorded run, replayed up to any moment — the sidebar block and the pane. */ +import type { Change } from "../../../packages/subagents/src/core/model/changes.ts" +import { applyAll, emptyModel, subagentsOf } from "../../../packages/subagents/src/core/model/model.ts" +import { SAMPLE_NOW, SAMPLE_ROOT, sample } from "../../../packages/subagents/src/core/sample.ts" +import { screenRows } from "../../../packages/subagents/src/core/view/screen.ts" +import { sidebarLines } from "../../../packages/subagents/src/core/view/sidebar.ts" + +export { SAMPLE_NOW } +const CHANGES = sample() +/** The run's first minute: what `at` a second of replay maps to. */ +export const START = SAMPLE_NOW - 60_000 + +/** The changes up to `now`. They are not in time order, and a part with no time takes the last seen. */ +function upTo(now: number): Change[] { + let last = 0 + return CHANGES.filter((c) => (last = (c as { at?: number }).at ?? last) <= now) +} +const nodesAt = (now: number) => subagentsOf(applyAll(emptyModel(), upTo(now)), SAMPLE_ROOT) + +export const sidebar = (now: number, width: number, frame: number) => + sidebarLines({ nodes: nodesAt(now), width, now, frame, limit: 6 }).map((line) => line.row) + +/** One subagent's pane (ctrl+x d), as it looked at `now`. */ +export function pane(now: number, width: number, height: number, frame: number, which = 0) { + const nodes = nodesAt(now) + const node = nodes[Math.min(which, nodes.length - 1)] + if (!node) return [] + return screenRows({ + session: node.session, nodes, launcher: "build", width, height, now, frame, + open: new Set(), closed: new Set(), thinking: false, details: false, + }).rows +} diff --git a/site/engine/bays/trail.ts b/site/engine/bays/trail.ts new file mode 100644 index 00000000..e4d1b9ae --- /dev/null +++ b/site/engine/bays/trail.ts @@ -0,0 +1,36 @@ +/** Trail: its own sample trails, its own trail_add, its own sidebar block and /trail dialog. */ +import { arrange, conversationThings } from "../../../packages/trail/src/core/model.ts" +import { SAMPLE_NOW, SAMPLE_SESSION, SAMPLES } from "../../../packages/trail/src/core/sample.ts" +import { emptyState, type State } from "../../../packages/trail/src/core/store.ts" +import { runAdd } from "../../../packages/trail/src/core/tools.ts" +import { type DialogInput, dialogRows } from "../../../packages/trail/src/core/view/dialog.ts" +import { sidebarRows } from "../../../packages/trail/src/core/view/sidebar.ts" + +export { emptyState, SAMPLE_NOW, SAMPLE_SESSION } +export type { State } + +let counter = 0 +/** The agent's trail_add call, as the tool runs it. */ +export function add(state: State, args: Record, at: number, session = SAMPLE_SESSION) { + return runAdd(state, args, { + session, + rootSession: session, + sessionTitle: "Fix the bundle desync", + by: "agent", + at, + id: `ev_site_${++counter}`, + } as never) +} + +export const sidebar = (state: State, session: string, width: number, now: number, limit = 6) => + sidebarRows({ width, arranged: arrange(conversationThings(state, session)), now, limit }).rows + +/** A finished trail from the product's samples ("busy": nine records, two conversations). */ +export function sample(name = "busy") { + const { state, session } = (SAMPLES[name] as () => { state: State; session: string })() + return { + sidebar: (width: number, limit = 6) => sidebar(state, session, width, SAMPLE_NOW, limit), + dialog: (input: Partial & { width: number; height: number }) => + dialogRows({ state, session, now: SAMPLE_NOW, project: "opencode-cockpit", tab: "this", ...input }), + } +} diff --git a/site/engine/bays/trust.ts b/site/engine/bays/trust.ts new file mode 100644 index 00000000..5209089b --- /dev/null +++ b/site/engine/bays/trust.ts @@ -0,0 +1,17 @@ +/** Trust: its engine run over a sample session — the /trust screen and the sidebar block. */ +import { SAMPLE_NOW, SAMPLE_SETTINGS, SAMPLES } from "../../../packages/trust/src/core/sample.ts" +import { activityRows } from "../../../packages/trust/src/core/view/activity.ts" +import { sidebarRows } from "../../../packages/trust/src/core/view/sidebar.ts" + +const sample = (name: string) => SAMPLES[name]() + +export function sidebar(name: string, width: number) { + const { engine, trouble } = sample(name) + return sidebarRows({ width, recent: engine.recent(), count: engine.count(), pending: engine.pending(), state: engine.state, limit: 3, ...(trouble ? { trouble } : {}) } as never) +} + +/** ctrl+x p: what it answered today, what is almost earned, the rules. */ +export function activity(name: string, width: number, height: number) { + const { engine } = sample(name) + return activityRows({ state: engine.state, settings: SAMPLE_SETTINGS, now: SAMPLE_NOW, history: engine.history, width, height, project: "app" } as never).rows +} diff --git a/site/engine/bays/updater.ts b/site/engine/bays/updater.ts new file mode 100644 index 00000000..42b5261a --- /dev/null +++ b/site/engine/bays/updater.ts @@ -0,0 +1,32 @@ +/** Updater: /plugins-update over four plugins — two behind, one unreachable, one local. */ +import { buildPlan } from "../../../packages/updater/src/core/plan.ts" +import { parseSpec } from "../../../packages/updater/src/core/spec.ts" +import { keyRow, listRows, titleRow } from "../../../packages/updater/src/core/view/layout.ts" + +const FILE = { path: "/home/me/.config/opencode/opencode.json", scope: "global", owner: "config" } + +export function list(width: number, cursor: number, selected: readonly string[], latest: string) { + const plans = buildPlan({ + plugins: [ + { name: "opencode-cockpit", source: "npm", running: "0.8.0" }, + { name: "@acme/opencode-lint-rules", source: "npm", running: "1.2.0" }, + { name: "oc-offline", source: "npm", running: "2.0.0" }, + { name: "/home/me/work/plugins/shell-experiments", source: "file" }, + ], + entries: [ + { file: FILE, spec: parseSpec("opencode-cockpit") }, + { file: FILE, spec: parseSpec("@acme/opencode-lint-rules@latest") }, + { file: FILE, spec: parseSpec("oc-offline") }, + ], + published: new Map([["opencode-cockpit", latest], ["@acme/opencode-lint-rules", "1.3.0"]]), + cacheDirs: new Map(), + } as never) + const gap = { runs: [{ text: " ".repeat(width) }] } + return [ + titleRow("Plugins", "what runs · what is published", width), + gap, + ...listRows(plans, width, { cursor, selected: new Set(selected) } as never, "/home/me"), + gap, + keyRow([["space", "Select"], ["enter", "Update"], ["esc", "Close"]] as never, width), + ] +} diff --git a/site/engine/paint.ts b/site/engine/paint.ts new file mode 100644 index 00000000..c023ef9d --- /dev/null +++ b/site/engine/paint.ts @@ -0,0 +1,52 @@ +/** + * Rows → HTML. Every bay draws rows of runs; this only turns a run's tone *name* into a class + * (`t-accent`, `f-selected`) — what that looks like is the site's theme, in engine.css. A bay that + * carries its own colour (a program's output in Shell, an image in Review) keeps it inline. + * + * Review and Updater wrap a row as `{ runs }`; the rest are the runs themselves. + */ +export interface Run { + text: string + tone?: string + fill?: string + bold?: boolean + faint?: boolean + dim?: boolean + italic?: boolean + fg?: string + bg?: string + color?: string | number + background?: string | number +} +export type AnyRow = readonly Run[] | { runs: readonly Run[] } + +const esc = (s: string) => s.replace(/&/g, "&").replace(//g, ">") +const hex = (c: string | number | undefined) => (typeof c === "number" ? `#${c.toString(16).padStart(6, "0")}` : c) + +function run(r: Run): string { + const cls = [ + r.tone && `t-${r.tone}`, + r.bold && "b", + (r.faint || r.dim) && "dim", + r.italic && "i", + r.fill && r.fill !== "none" && `f-${r.fill}`, + ] + .filter(Boolean) + .join(" ") + const fg = hex(r.fg ?? r.color) + const bg = hex(r.bg ?? r.background) + const style = fg || bg ? ` style="${fg ? `color:${fg};` : ""}${bg ? `background:${bg};` : ""}"` : "" + return cls || style ? `${esc(r.text)}` : esc(r.text) +} + +export function paint(rows: readonly AnyRow[]): string { + return rows + .map((row) => { + const runs = "runs" in row ? row.runs : row + return `
${runs.map(run).join("") || " "}
` + }) + .join("") +} + +/** A row of nothing, `width` wide. */ +export const blank = (width: number): Run[] => [{ text: " ".repeat(width) }] diff --git a/site/engine/zlib-stub.ts b/site/engine/zlib-stub.ts new file mode 100644 index 00000000..11570bac --- /dev/null +++ b/site/engine/zlib-stub.ts @@ -0,0 +1,3 @@ +// Review's PNG reader imports node:zlib; the site never decodes a PNG, so it gets a stub that says so. +export const inflate = (_: unknown, cb: (e: Error) => void) => cb(new Error("zlib unavailable in browser")) +export const inflateSync = () => { throw new Error("zlib unavailable in browser") } diff --git a/site/package.json b/site/package.json index c104593a..1efee826 100644 --- a/site/package.json +++ b/site/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "sync": "bun scripts/sync.ts", + "sync": "bun scripts/sync.ts && bun scripts/engine.ts", "dev": "bun run sync && astro dev", "build": "bun run sync && astro build", "preview": "astro preview" diff --git a/site/scripts/engine.ts b/site/scripts/engine.ts new file mode 100644 index 00000000..1db0ab80 --- /dev/null +++ b/site/scripts/engine.ts @@ -0,0 +1,52 @@ +/** + * Bundles the bays' own renderers for the browser, so the landing page draws each bay with the code + * that draws it in OpenCode: one module per bay in public/engine/, plus the chunks they share, and a + * section fetches its bay only when it is about to be seen. + * + * bun scripts/engine.ts (runs before dev and build, after sync) + * + * Bun rather than Vite: Trust reaches for node:path at runtime, which Bun polyfills and Vite does not. + */ +import { rmSync } from "node:fs" +import { join, resolve } from "node:path" + +const site = resolve(import.meta.dir, "..") +const packages = resolve(site, "..", "packages") +const out = join(site, "public", "engine") +const bays = ["trail", "subagents", "shell", "review", "status", "trust", "updater"] + +rmSync(out, { recursive: true, force: true }) +const result = await Bun.build({ + entrypoints: [join(site, "engine", "paint.ts"), ...bays.map((bay) => join(site, "engine", "bays", `${bay}.ts`))], + root: join(site, "engine"), + outdir: out, + target: "browser", + format: "esm", + splitting: true, + minify: true, + naming: { entry: "[dir]/[name].js", chunk: "chunks/[name]-[hash].js" }, + // Shell's relativeCwd reads $HOME; a visitor has none. + define: { "process.env.HOME": '""' }, + plugins: [ + { + name: "workspace-source", + setup(build) { + // Review's PNG reader imports node:zlib; the page never decodes a PNG. + build.onResolve({ filter: /^node:zlib$/ }, () => ({ path: join(site, "engine", "zlib-stub.ts") })) + // Packages import each other by name; the site reads their source, not a build. + build.onResolve({ filter: /^@opencode-cockpit\// }, ({ path }) => { + const [, pkg, sub] = path.match(/^@opencode-cockpit\/([^/]+)\/?(.*)$/) ?? [] + return { path: join(packages, pkg, "src", `${sub || "index"}.ts`) } + }) + }, + }, + ], +}) +if (!result.success) { + for (const log of result.logs) console.error(log) + process.exit(1) +} +const kb = (n: number) => `${(n / 1024).toFixed(1)} KB` +const entries = result.outputs.filter((o) => o.kind === "entry-point") +const chunks = result.outputs.filter((o) => o.kind === "chunk") +console.log(`engine: ${entries.map((o) => `${o.path.split("/engine/")[1]} ${kb(o.size)}`).join(" · ")} · ${chunks.length} shared chunks ${kb(chunks.reduce((n, o) => n + o.size, 0))}`) diff --git a/site/src/components/Cast.astro b/site/src/components/Cast.astro index 9dbf1fb1..37c33132 100644 --- a/site/src/components/Cast.astro +++ b/site/src/components/Cast.astro @@ -78,7 +78,7 @@ const id = `cast-${name}-${Math.random().toString(36).slice(2, 8)}` --term-color-foreground: #c7d2dc; /* recordings keep a terminal ground in both themes: the colours were made for it */ --term-color-background: var(--ck-terminal); - --term-color-0: #0b0d10; --term-color-1: #f0797a; --term-color-2: #5fe3c0; --term-color-3: #f0b45e; + --term-color-0: var(--ck-terminal); /* programs paint their ground with black: make it ours */ --term-color-1: #f0797a; --term-color-2: #5fe3c0; --term-color-3: #f0b45e; --term-color-4: #6ea8fe; --term-color-5: #c79bf0; --term-color-6: #5fd8e3; --term-color-7: #c7d2dc; --term-color-8: #5f6e7b; --term-color-9: #ff9b9c; --term-color-10: #7ff0d1; --term-color-11: #ffc97d; --term-color-12: #8dbcff; --term-color-13: #dcb4ff; --term-color-14: #8ae8f0; --term-color-15: #e8edf2; diff --git a/site/src/components/landing/Feature.astro b/site/src/components/landing/Feature.astro new file mode 100644 index 00000000..0d7af31c --- /dev/null +++ b/site/src/components/landing/Feature.astro @@ -0,0 +1,23 @@ +--- +import type { Feature } from "../../data/landing" +import { inline } from "../../lib/inline" +import Window from "./Window.astro" +interface Props { + feature: Feature + flip?: boolean +} +const { feature, flip = false } = Astro.props +--- + +
+
+
{feature.eyebrow}
+

{feature.title} {feature.accent}

+

+

    + {feature.points.map((point) =>
  • {point.key}{point.text}
  • )} +
+ {feature.id[0].toUpperCase() + feature.id.slice(1)} docs → +
+ +
diff --git a/site/src/components/landing/Final.astro b/site/src/components/landing/Final.astro new file mode 100644 index 00000000..c3a1cfff --- /dev/null +++ b/site/src/components/landing/Final.astro @@ -0,0 +1,14 @@ +--- +import { final, install } from "../../data/landing" +import Install from "./Install.astro" +const base = import.meta.env.BASE_URL +--- + +
+

{final.title} {final.accent}

+ + +
{install.requires}
+
diff --git a/site/src/components/landing/Hero.astro b/site/src/components/landing/Hero.astro index 94a9d0e0..a49e45af 100644 --- a/site/src/components/landing/Hero.astro +++ b/site/src/components/landing/Hero.astro @@ -1,71 +1,40 @@ --- -/** - * The hook, and nothing else. - * - * It used to carry a hand-built mock of the panel on the right — a drawing of the product, beside - * the product's own recordings and screenshots further down. The real thing is a better argument - * than an illustration of it, so the space goes to the sentence instead. - */ -import { hero } from "../../data/landing" +import { bays, hero } from "../../data/landing" +import Install from "./Install.astro" --- -
-

{hero.eyebrow}

-

{hero.title[0]} {hero.title[1]} {hero.title[2]}

+ +
+
{hero.tag}
+

{hero.title}{hero.accent}

{hero.body}

+ + -
- Install Cockpit - See what it does +
+ +
+ +
+
+ +
+
+ build · claude-opus12%working 0s +
+
+ +
SHELLS background runs
+
SUBAGENTS helpers, live
+
TRAIL what it made
+
the conversation OPENCODE
+
context, filling STATUS
-
- ${hero.install} - +
+ {bays.map((bay) => {bay.name}{bay.what})}
-
- - - - + diff --git a/site/src/components/landing/Install.astro b/site/src/components/landing/Install.astro index 9694f4e1..1df9728b 100644 --- a/site/src/components/landing/Install.astro +++ b/site/src/components/landing/Install.astro @@ -1,56 +1,27 @@ --- -import SectionHead from "./SectionHead.astro" +/** + * The install command for both OpenCodes, from the published version. OpenCode 1 is shown first; + * the script remembers a visitor's choice. OpenCode 2 also needs its background service restarted. + */ import { install } from "../../data/landing" +interface Props { + compact?: boolean +} +const { compact = false } = Astro.props +const { v1, v2 } = install --- -
- -
- {install.modes.map((m, i) => ( - - ))} +
+
+ +
- -
- {install.modes.map((m, i) => ( - - ))} -
- {install.steps.map((s, i) => ( -
{String(i + 1).padStart(2, "0")}
- ))} -
+
+ $ {v1.command} +
-
- - - - + + diff --git a/site/src/components/landing/Nav.astro b/site/src/components/landing/Nav.astro index 93aa78ca..cb40f228 100644 --- a/site/src/components/landing/Nav.astro +++ b/site/src/components/landing/Nav.astro @@ -1,63 +1,15 @@ --- +import { github, nav } from "../../data/landing" import Logo from "../Logo.astro" -import { nav } from "../../data/landing" const base = import.meta.env.BASE_URL +const href = (to: string) => (to.startsWith("#") || to.startsWith("http") ? to : `${base}${to}`) --- -