Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
604 changes: 50 additions & 554 deletions README.md

Large diffs are not rendered by default.

Binary file added media/hero.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion packages/opencode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ finish, and paste the whole log back into their context. Cockpit gives your agen
developer actually has — long-running terminals, a way to wait for "ready", and output it can read
without drowning in it — and gives *you* a live view of all of it, inside OpenCode.

![The shells panel: a dev server running under the conversation](https://raw.githubusercontent.com/Codestz/opencode-cockpit/main/media/dock.gif)
![One OpenCode session with Cockpit: background tests failing then passing, subagents at work, the PR landing in Trail, the context filling](https://raw.githubusercontent.com/Codestz/opencode-cockpit/main/media/hero.gif)

*A real recording — every demo here is generated from a live OpenCode session by
[`bun run record`](https://github.com/Codestz/opencode-cockpit/blob/main/CONTRIBUTING.md), and re-run on release, so none of them can drift from what
Expand Down
75 changes: 4 additions & 71 deletions packages/shell/src/cli/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Record<Shared, number>> = {}
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 ------------------------------------------------------------------------------------

Expand Down
9 changes: 7 additions & 2 deletions packages/shell/src/cli/samples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
103 changes: 102 additions & 1 deletion packages/shell/src/tui/lib/sidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Record<Shared, number>> = {}
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,
]
}
13 changes: 8 additions & 5 deletions packages/shell/src/tui/lib/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
41 changes: 41 additions & 0 deletions packages/shell/test/sidebar-block.test.ts
Original file line number Diff line number Diff line change
@@ -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])
})
})
5 changes: 2 additions & 3 deletions site/.gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
dist/
.astro/
node_modules/
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/
2 changes: 2 additions & 0 deletions site/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export default defineConfig({
components: {
// The landing page is ours; Starlight owns everything under /docs.
SiteTitle: "./src/components/DocsTitle.astro",
// Adds Geist and Geist Mono, which Starlight would not otherwise load.
Head: "./src/components/docs/Head.astro",
},
sidebar: [
{
Expand Down
Loading
Loading