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
2 changes: 2 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ The session counters come from OpenCode's cumulative session token and cost fiel
| `tps` | A live token-throughput estimate while output is observable, held across tool execution and between steps, then the frozen average for the completed run. It appears only once a rate exists. |
| `bgagent` | The number of running direct subagents of the session; it hides when the count is zero. |

> **Spinner color compatibility:** OpenCode 2.0.15 exposes only `accent`, `interactive`, and `neutral` hues in its public theme types. The spinner uses those hues or a hex agent color directly and falls back to the theme's categorical palette for other named colors. On older OpenCode 2 versions, the spinner color may not match the agent's color. Animation is unaffected.

While a run streams, TPS (tokens per second) is estimated from observed UTF-8 bytes using `bytesPerToken`. When the model stream ends, the last live estimate is held across tool execution and between steps; new observable output resumes the live estimate. Once a step reports its output and reasoning usage, those exact tokens replace that step's estimate, and at run end the cumulative average stays frozen until the next prompt. The label always reads `~N t/s`, so TPS has no appearance setting. The tracker shares its lineage with the standalone [opencode2-tps](https://github.com/P-Theo/opencode2-tps) plugin, minus its `display` option, but the held running display and its refresh behavior differ. See [TPS rate tuning](#tps-rate-tuning) for `refreshHz` and `bytesPerToken`.

## Appearance options
Expand Down
4 changes: 2 additions & 2 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Load the checkout using [Run from source](../README.md#run-from-source). See [Ar
| `npm run lint` | Run oxlint. |
| `npm run check` | Check TypeScript types. |
| `npm test` | Run the Bun tests. |
| `npm run check:compatibility` | Check the OpenCode SDK pin and README version floor. |
| `npm run check:compatibility` | Check that the OpenCode plugin and theme pins match and that the README minimum is no newer than the pin. |
| `npm run build` | Rebuild `dist/`. |
| `npm pack` | Run lint, type-checks, tests, and build, then create the publishable tarball. |

Expand All @@ -21,6 +21,6 @@ Tests live in [tests/](../tests), mostly named after the source module. `layout-

Local directory entries load the root `tui.tsx`. Published packages load `dist/tui.js`, built from `src/footer.tsx` by [scripts/build.mjs](../scripts/build.mjs). Test the packed artifact when changing runtime code or packaging. CI also imports it against the host's bundled runtime versions.

Keep `options.schema.json` aligned with `src/options.ts`. Update the pinned `@opencode/plugin` version and README compatibility floor together when a change requires a newer host.
Keep `options.schema.json` aligned with `src/options.ts`. Keep `@opencode/plugin` and `@opencode/theme` pinned to the same version so TypeScript checks the host's theme types. Raise the README minimum only when runtime behavior requires a newer host. A typings-only bump does not change it. The compatibility script checks these version labels, not runtime behavior.

See [Release](RELEASE.md) for publishing.
2 changes: 1 addition & 1 deletion docs/RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Maintainer runbook.

A path entry loads the `tui.tsx` source through the host's transform; an installed package loads the pre-built `dist/tui.js` through the `exports` subpath. Those are two different code paths, so the bundle gets tested before it goes out.

The pinned `@opencode/plugin` version is the build and test baseline. Move it in the same pull request as the fix or feature that needs it; CI validates the pin format and README compatibility floor, then checks GitHub for the matching official `anomalyco/opencode` release tag.
The pinned `@opencode/plugin` version is the build and test baseline. Pin `@opencode/theme` to the same version so theme types remain available to TypeScript. Move both pins in the same pull request as the change that needs them. CI checks their parity and the README compatibility floor, then looks up the matching official `anomalyco/opencode` release tag.

A docs-only release skips steps 1 to 5. The bundle is unchanged, and CI already packs and imports the artifact on every push to `main`. Go straight to step 6. A README change that matters on the package page needs a patch release to reach it, because npmjs.com only refreshes the rendered README when a new version is published.

Expand Down
481 changes: 319 additions & 162 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@
"devDependencies": {
"@babel/core": "^7.29.7",
"@babel/preset-typescript": "^7.29.7",
"@opencode/plugin": "2.0.12",
"@opencode/plugin": "2.0.15",
"@opencode/theme": "2.0.15",
"@opentui/core": "^0.5.11",
"@opentui/solid": "^0.5.10",
"@oxlint/plugins": "^1.82.0",
Expand Down
2 changes: 1 addition & 1 deletion scripts/check-compatibility.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.me

const readme = readFileSync(new URL("../README.md", import.meta.url), "utf8")

const packages = ["@opencode/plugin"]
const packages = ["@opencode/plugin", "@opencode/theme"]

const versions = packages.map((name) => packageJson.devDependencies[name])

Expand Down
15 changes: 7 additions & 8 deletions src/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,6 @@ const definition: Plugin.Definition = {
readonly sessionID: string
}

/** Mirrors the host's `local.agent.color`, including its palette fallback. */
function spinnerBaseColor(sessionID?: string): RGBA {
const fallback: RGBA = context.theme.border.base
const session = sessionID ? context.data.session.get(sessionID) : undefined
Expand All @@ -433,19 +432,19 @@ const definition: Plugin.Definition = {

if (!agent) return fallback

const step = context.themeMode === "light" ? 800 : 200

if (agent.color) {
const step = context.themeMode === "light" ? 800 : 200
if (/^#[0-9a-f]{6}$/iu.test(agent.color)) return RGBA.fromHex(agent.color)

// Hex from `cli.json`, or one of the config's theme color names.
const declared: RGBA | undefined = /^#[0-9a-f]{6}$/iu.test(agent.color)
? RGBA.fromHex(agent.color)
: context.theme.hue?.[agent.color]?.[step]
if (agent.color === "accent" || agent.color === "interactive" || agent.color === "neutral") {
const declared = context.theme.hue?.[agent.color]?.[step]

if (declared) return declared
if (declared) return declared
}
}

const palette: RGBA[] = []
const step = context.themeMode === "light" ? 800 : 200

for (const scale of context.theme.categorical ?? []) {
const color: RGBA | undefined = scale?.[step]
Expand Down
38 changes: 35 additions & 3 deletions tests/footer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,8 @@ interface FakeContext {
readonly theme: {
readonly text: { readonly muted: string }
readonly border: { readonly base: RGBA }
readonly hue?: { readonly accent: { readonly 200: RGBA } }
readonly categorical?: readonly { readonly 200: RGBA }[]
}
readonly storage: {
readonly memory: (
Expand All @@ -242,7 +244,7 @@ const setupWithFakeContext = definition.setup as unknown as (
context: FakeContext,
) => ReturnType<typeof definition.setup>

function createHarness(options: StatusOptionsInput & TpsOptionsInput = {}) {
function createHarness(options: StatusOptionsInput & TpsOptionsInput = {}, agentColor?: string) {
const claims: CapturedClaim[] = []
const handlers = new Map<string, ((event: FakeEvent) => void)[]>()
const generation: Generation = { active: 0 }
Expand Down Expand Up @@ -313,7 +315,10 @@ function createHarness(options: StatusOptionsInput & TpsOptionsInput = {}) {
},
},
model: { list: () => state.models },
agent: { list: () => [] },
agent: {
list: () =>
agentColor ? [{ id: "test", mode: "primary", hidden: false, color: agentColor }] : [],
},
},
}

Expand All @@ -322,7 +327,12 @@ function createHarness(options: StatusOptionsInput & TpsOptionsInput = {}) {
location: state.defaultLocation,
app: { version: "test" },
renderer: { widthMethod: "unicode" },
theme: { text: { muted: "#888888" }, border: { base: RGBA.fromHex("#ff8800") } },
theme: {
text: { muted: "#888888" },
border: { base: RGBA.fromHex("#ff8800") },
hue: { accent: { 200: RGBA.fromHex("#3388ff") } },
categorical: [{ 200: RGBA.fromHex("#e57837") }],
},
storage: { memory: () => [generation, (mutation: (draft: Generation) => void) => mutation(generation)] as const },
data,
ui: {
Expand Down Expand Up @@ -1387,6 +1397,28 @@ describe("built render", () => {
}
})

test.each([
["#12ab34", "#12ab34"],
["accent", "#3388ff"],
["custom", "#e57837"],
] satisfies ReadonlyArray<readonly [string, string]>)("uses agent color %s for the block spinner", async (agentColor, expected) => {
const h = createHarness({ appearance: { spinner: "blocks" } }, agentColor)

seed(h, { status: "running" })

const app = await renderClaim(h, "composer", { sessionID: "ses_test" }, 60, 2)

try {
const spans = app.captureSpans().lines[0]?.spans ?? []

expect(spans.some((span) => span.text.includes("■") && span.fg.equals(RGBA.fromHex(expected)))).toBe(true)
} finally {
app.renderer.destroy()
h.cleanup()
h.restore()
}
})

test("holds the live rate across tools and steps until new output arrives", async () => {
const h = createHarness()

Expand Down
Loading