opencode-agent-progress 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/DESIGN.md ADDED
@@ -0,0 +1,55 @@
1
+ # OpenCode Agent Progress Design System
2
+
3
+ ## 0. Research Log
4
+
5
+ - Embedded reference: OpenCode native sidebar and OMO 4.19 progress sections define the layout contract.
6
+ - Runtime reference: `opencode-metrics` proves node-level render invalidation works on OpenTUI 0.4.x.
7
+ - Skipped Lazyweb and image drafts: this is a terminal-native compatibility surface with a concrete host reference.
8
+
9
+ ## 1. Atmosphere & Identity
10
+
11
+ A quiet operational instrument that looks native to OpenCode. Its signature is immediate state clarity: the main session and every subordinate activity change in place without decorative motion or separate branding.
12
+
13
+ ## 2. Color
14
+
15
+ All colors come from `TuiThemeCurrent`. Primary text uses `text`, metadata uses `textMuted`, active work uses `accent`, retries use `warning`, failures use `error`, and completed work uses `success`. The plugin never declares raw color values.
16
+
17
+ ## 3. Typography
18
+
19
+ The host terminal owns font family, size, line height, and character width. Labels are concise English identifiers because terminal alignment and log scanning are the primary use cases. Status text stays on one line and truncates before it can resize the sidebar.
20
+
21
+ ## 4. Spacing & Layout
22
+
23
+ OpenTUI character cells are the base unit. Sections use one-cell vertical separation, one-cell inner padding when bordered, and full available sidebar width. The layout is a single vertical stack with no nested cards.
24
+
25
+ ## 5. Components
26
+
27
+ ### Progress Section
28
+
29
+ - **Structure**: title followed by compact status rows.
30
+ - **Variants**: main session, agents, jobs, and ULW loop.
31
+ - **States**: idle, busy, retry, running, completed, error, cancelled, stale, and empty.
32
+ - **Accessibility**: status is always expressed in text, never color alone.
33
+ - **Motion**: none; content changes in place.
34
+ - **Layout**: full-width terminal stack with bounded labels.
35
+
36
+ ### Status Row
37
+
38
+ - **Structure**: left label and right status/value.
39
+ - **Variants**: neutral, active, success, warning, and error.
40
+ - **States**: visible or omitted; no disabled interaction state.
41
+ - **Accessibility**: stable text labels and theme-provided contrast.
42
+ - **Motion**: none.
43
+ - **Layout**: one terminal row with deterministic width.
44
+
45
+ ## 6. Motion & Interaction
46
+
47
+ Updates are immediate and non-animated. The plugin does not add controls in its first release; state changes are informational and must never steal keyboard focus.
48
+
49
+ ## 7. Depth & Surface
50
+
51
+ The strategy is borders-only, inherited from the host theme. Each major section may use one native single-line border; rows never become separate cards.
52
+
53
+ ## 8. Accessibility Constraints & Accepted Debt
54
+
55
+ All state meaning must survive monochrome terminals. Labels are bounded to prevent clipping. Accepted debt for `0.1.0`: narrow terminals below the host sidebar minimum are governed by OpenCode itself rather than a plugin-specific responsive mode.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mingjian Shao
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # opencode-agent-progress
2
+
3
+ Live OpenCode sidebar progress for the main session, OMO agents, background jobs, and ULW loops.
4
+
5
+ The plugin exists to keep progress visible when host-level redraws lag behind state updates. It reads
6
+ OpenCode session state directly, consumes the OMO v1 mirror, and updates mounted OpenTUI nodes without
7
+ requiring the user to leave and re-enter a session.
8
+
9
+ ## Requirements
10
+
11
+ - OpenCode `1.18.3` (verified; other releases require the validation described below)
12
+ - `@opencode-ai/plugin` `1.17.x`
13
+ - OpenTUI `0.3.4` through `0.4.x`
14
+ - OMO `4.19.x` for agents, jobs, and ULW mirror data
15
+
16
+ The main-session section works without OMO. When no compatible mirror exists, the sidebar reports
17
+ `waiting`, `invalid`, or `stale` instead of rendering untrusted data.
18
+
19
+ ## Local installation
20
+
21
+ Install and validate the project:
22
+
23
+ ```bash
24
+ pnpm install
25
+ pnpm check
26
+ ```
27
+
28
+ Add the local TUI plugin to `~/.config/opencode/tui.json`. Keep the OMO entry so it remains installed,
29
+ but deactivate only its TUI module to avoid duplicate sidebar sections:
30
+
31
+ ```json
32
+ {
33
+ "plugin": [
34
+ "oh-my-openagent@latest",
35
+ "file:///absolute/path/to/opencode-agent-progress/src/tui.tsx"
36
+ ],
37
+ "plugin_enabled": {
38
+ "oh-my-openagent:tui": false
39
+ }
40
+ }
41
+ ```
42
+
43
+ Do not set OMO's `tui.sidebar.enabled` to `false`. That setting also stops the server-side mirror writer,
44
+ so agent, job, and ULW state would no longer reach this plugin.
45
+
46
+ Leave `oh-my-openagent@latest` enabled in `opencode.json` so OMO orchestration and mirror generation keep
47
+ running. Restart the OpenCode TUI after changing plugin configuration, then open the sidebar with the
48
+ configured `sidebar_toggle` binding. The default is `<leader>b`.
49
+
50
+ ## Displayed state
51
+
52
+ - **Main**: session title, status, active agent, and model.
53
+ - **Agents**: active OMO agent names and states.
54
+ - **Jobs**: bounded job labels, status, and tool-call count.
55
+ - **ULW**: goals, checks, queue, and active goal.
56
+ - **OMO**: mirror health when live OMO state is unavailable.
57
+
58
+ Status is always expressed as text. Theme colors add emphasis but never carry meaning alone.
59
+
60
+ ## Runtime design
61
+
62
+ OpenCode events trigger immediate reads for connection, session, message, and todo updates. A one-second
63
+ poll is retained for OMO's file mirror and as a reconnect fallback. Identical views are deduplicated.
64
+
65
+ The sidebar uses fixed OpenTUI row slots and updates `TextRenderable` nodes directly. Each changed node
66
+ requests its own render; the plugin does not call the host's global `api.renderer.requestRender()`.
67
+ Mounted nodes own controller cleanup, so event subscriptions and timers are released when the sidebar is
68
+ destroyed or replaced.
69
+
70
+ OMO mirror compatibility is intentionally strict:
71
+
72
+ - SHA-1 project hash and canonical path matching follow OMO's mirror-path contract.
73
+ - Zod parses schema version `1` at the file boundary.
74
+ - Mirrors older than six seconds are marked stale.
75
+ - Unknown schemas fail closed as `invalid`.
76
+
77
+ ## Development
78
+
79
+ ```bash
80
+ bun test
81
+ bun run typecheck
82
+ bun run lint
83
+ bun run build
84
+ ```
85
+
86
+ `bunfig.toml` preloads the official OpenTUI Solid transform. Source TSX remains the package entry because
87
+ OpenCode's TUI plugin loader compiles it in the host runtime.
88
+
89
+ ## Verified matrix
90
+
91
+ | Surface | Version | Result |
92
+ |---|---:|---|
93
+ | OpenCode | `1.18.3` | Local file plugin loaded from isolated `tui.json` |
94
+ | `@opencode-ai/plugin` | `1.17.16` | Typecheck and runtime slot registration passed |
95
+ | OpenTUI | `0.4.3` | In-memory renderer and real TUI passed |
96
+ | OMO mirror | schema `1` | Missing-to-live refresh passed without session re-entry |
97
+
98
+ The isolated real-TUI check used a 120-column terminal. The plugin region measured 37 of 40 available
99
+ columns, with no overflow and aligned borders.
100
+
101
+ ## Compatibility policy
102
+
103
+ Host and OpenTUI upgrades must pass `pnpm check` plus an isolated real-TUI run before the supported range
104
+ is widened. A new OMO mirror schema requires a new parser branch and fixtures; it must not be accepted by
105
+ loosening the current schema.
package/dist/tui.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { TuiPlugin } from '@opencode-ai/plugin/tui';
2
+
3
+ /** @jsxImportSource @opentui/solid */
4
+ /** @jsxRuntime automatic */
5
+
6
+ declare const plugin: {
7
+ id: string;
8
+ tui: TuiPlugin;
9
+ };
10
+
11
+ export { plugin as default };
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "opencode-agent-progress",
3
+ "version": "0.1.0",
4
+ "description": "Live OpenCode TUI progress for the main session, subagents, jobs, and OMO loops",
5
+ "type": "module",
6
+ "main": "./src/tui.tsx",
7
+ "types": "./dist/tui.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./src/tui.tsx",
11
+ "types": "./dist/tui.d.ts"
12
+ }
13
+ },
14
+ "oc-plugin": [
15
+ "tui"
16
+ ],
17
+ "files": [
18
+ "dist/tui.d.ts",
19
+ "src",
20
+ "DESIGN.md",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "scripts": {
25
+ "build": "tsup",
26
+ "typecheck": "tsc --noEmit",
27
+ "lint": "biome check .",
28
+ "test": "bun test",
29
+ "check": "bun run lint && bun run typecheck && bun run test && bun run build",
30
+ "prepublishOnly": "bun run check"
31
+ },
32
+ "dependencies": {
33
+ "solid-js": "1.9.12",
34
+ "zod": "4.4.3"
35
+ },
36
+ "peerDependencies": {
37
+ "@opencode-ai/plugin": ">=1.17.0 <2",
38
+ "@opentui/core": ">=0.3.4 <0.5.0",
39
+ "@opentui/solid": ">=0.3.4 <0.5.0"
40
+ },
41
+ "devDependencies": {
42
+ "@biomejs/biome": "2.5.4",
43
+ "@opencode-ai/plugin": "1.17.16",
44
+ "@opentui/core": "0.4.3",
45
+ "@opentui/solid": "0.4.3",
46
+ "@types/bun": "1.3.14",
47
+ "tsup": "8.5.1",
48
+ "typescript": "5.9.3"
49
+ },
50
+ "engines": {
51
+ "bun": ">=1.2.0"
52
+ },
53
+ "packageManager": "pnpm@11.13.1",
54
+ "license": "MIT"
55
+ }
@@ -0,0 +1,71 @@
1
+ import type { MainSessionView } from "./main-session"
2
+ import type { MirrorFileResult } from "./mirror-file"
3
+ import { type ProgressEventBus, registerRefreshEvents } from "./refresh-events"
4
+
5
+ export type ProgressView = {
6
+ readonly main: MainSessionView
7
+ readonly mirror: MirrorFileResult
8
+ }
9
+
10
+ export interface ProgressSource {
11
+ read(): ProgressView
12
+ }
13
+
14
+ export interface ProgressClock {
15
+ every(intervalMs: number, task: () => void): () => void
16
+ }
17
+
18
+ export type ProgressControllerOptions = {
19
+ readonly source: ProgressSource
20
+ readonly eventBus: ProgressEventBus
21
+ readonly clock: ProgressClock
22
+ readonly intervalMs: number
23
+ }
24
+
25
+ export interface ProgressController {
26
+ get(): ProgressView
27
+ refresh(): void
28
+ subscribe(listener: (view: ProgressView) => void): () => void
29
+ dispose(): void
30
+ }
31
+
32
+ function progressViewKey(view: ProgressView): string {
33
+ return JSON.stringify(view)
34
+ }
35
+
36
+ export function createProgressController(options: ProgressControllerOptions): ProgressController {
37
+ let current = options.source.read()
38
+ let currentKey = progressViewKey(current)
39
+ let disposed = false
40
+ const listeners = new Set<(view: ProgressView) => void>()
41
+
42
+ const refresh = () => {
43
+ if (disposed) return
44
+ const next = options.source.read()
45
+ const nextKey = progressViewKey(next)
46
+ if (nextKey === currentKey) return
47
+
48
+ current = next
49
+ currentKey = nextKey
50
+ for (const listener of listeners) listener(current)
51
+ }
52
+
53
+ const disposeEvents = registerRefreshEvents(options.eventBus, refresh)
54
+ const disposeClock = options.clock.every(options.intervalMs, refresh)
55
+
56
+ return {
57
+ get: () => current,
58
+ refresh,
59
+ subscribe(listener) {
60
+ listeners.add(listener)
61
+ return () => listeners.delete(listener)
62
+ },
63
+ dispose() {
64
+ if (disposed) return
65
+ disposed = true
66
+ disposeEvents()
67
+ disposeClock()
68
+ listeners.clear()
69
+ },
70
+ }
71
+ }
@@ -0,0 +1,36 @@
1
+ export type SessionStatus = "idle" | "busy" | "retry"
2
+
3
+ export type SessionRecord = {
4
+ readonly id: string
5
+ readonly title: string
6
+ readonly agent?: string
7
+ readonly model?: {
8
+ readonly id: string
9
+ readonly providerID: string
10
+ }
11
+ }
12
+
13
+ export interface MainSessionReader {
14
+ getSession(sessionID: string): SessionRecord | undefined
15
+ getStatus(sessionID: string): SessionStatus | undefined
16
+ }
17
+
18
+ export type MainSessionView = {
19
+ readonly sessionID: string
20
+ readonly title: string
21
+ readonly agent: string
22
+ readonly model: string
23
+ readonly status: SessionStatus
24
+ }
25
+
26
+ export function readMainSession(reader: MainSessionReader, sessionID: string): MainSessionView {
27
+ const session = reader.getSession(sessionID)
28
+ const model = session?.model
29
+ return {
30
+ sessionID,
31
+ title: session?.title ?? "Untitled",
32
+ agent: session?.agent ?? "main",
33
+ model: model ? `${model.providerID}/${model.id}` : "unknown",
34
+ status: reader.getStatus(sessionID) ?? "idle",
35
+ }
36
+ }
@@ -0,0 +1,64 @@
1
+ import { createHash } from "node:crypto"
2
+ import { readFileSync, realpathSync } from "node:fs"
3
+ import { join, resolve } from "node:path"
4
+ import type { OmoMirrorSnapshot } from "./mirror"
5
+ import { parseOmoMirror } from "./mirror"
6
+
7
+ export type MirrorFileResult =
8
+ | { readonly kind: "ready"; readonly snapshot: OmoMirrorSnapshot; readonly stale: boolean }
9
+ | { readonly kind: "missing" }
10
+ | { readonly kind: "invalid" }
11
+
12
+ export type MirrorFileOptions = {
13
+ readonly projectDirectory: string
14
+ readonly dataHome: string
15
+ readonly now: () => number
16
+ readonly staleAfterMs: number
17
+ }
18
+
19
+ function canonicalProjectDirectory(projectDirectory: string): string {
20
+ try {
21
+ return realpathSync.native(projectDirectory)
22
+ } catch (error) {
23
+ if (error instanceof Error) return resolve(projectDirectory)
24
+ throw error
25
+ }
26
+ }
27
+
28
+ function errorCode(error: unknown): string | undefined {
29
+ if (!(error instanceof Error) || !("code" in error)) return undefined
30
+ return typeof error.code === "string" ? error.code : undefined
31
+ }
32
+
33
+ export function omoMirrorFilePath(projectDirectory: string, dataHome: string): string {
34
+ const canonicalDirectory = canonicalProjectDirectory(projectDirectory)
35
+ const projectHash = createHash("sha1").update(canonicalDirectory).digest("hex").slice(0, 16)
36
+ return join(
37
+ dataHome,
38
+ "opencode",
39
+ "storage",
40
+ "oh-my-openagent",
41
+ "tui-state",
42
+ `${projectHash}.json`,
43
+ )
44
+ }
45
+
46
+ export function readOmoMirrorFile(options: MirrorFileOptions): MirrorFileResult {
47
+ const projectDirectory = canonicalProjectDirectory(options.projectDirectory)
48
+ const path = omoMirrorFilePath(projectDirectory, options.dataHome)
49
+ let raw: string
50
+ try {
51
+ raw = readFileSync(path, "utf8")
52
+ } catch (error) {
53
+ if (errorCode(error) === "ENOENT") return { kind: "missing" }
54
+ throw error
55
+ }
56
+
57
+ const parsed = parseOmoMirror(raw, projectDirectory)
58
+ if (parsed.kind === "invalid") return parsed
59
+ return {
60
+ kind: "ready",
61
+ snapshot: parsed.snapshot,
62
+ stale: options.now() - parsed.snapshot.updatedAt > options.staleAfterMs,
63
+ }
64
+ }
package/src/mirror.ts ADDED
@@ -0,0 +1,105 @@
1
+ import { z } from "zod"
2
+
3
+ export type AgentStatus = "busy" | "idle" | "error" | "running" | "retry"
4
+ export type JobStatus = "pending" | "running" | "completed" | "error" | "cancelled" | "interrupt"
5
+
6
+ export type AgentRow = {
7
+ readonly name: string
8
+ readonly status: AgentStatus
9
+ }
10
+
11
+ export type JobRow = {
12
+ readonly title: string
13
+ readonly status: JobStatus
14
+ readonly toolCalls: number | null
15
+ readonly lastTool: string | null
16
+ }
17
+
18
+ export type LoopProgress = {
19
+ readonly goalsDone: number
20
+ readonly goalsTotal: number
21
+ readonly pass: number
22
+ readonly fail: number
23
+ readonly pending: number
24
+ readonly blocked: number
25
+ readonly activeGoal: string | null
26
+ }
27
+
28
+ export type OmoMirrorSnapshot = {
29
+ readonly projectDir: string
30
+ readonly updatedAt: number
31
+ readonly activeAgents: readonly AgentRow[]
32
+ readonly jobBoard: readonly JobRow[]
33
+ readonly loop: LoopProgress | null
34
+ }
35
+
36
+ export type MirrorParseResult =
37
+ | { readonly kind: "valid"; readonly snapshot: OmoMirrorSnapshot }
38
+ | { readonly kind: "invalid" }
39
+
40
+ const AgentRowSchema = z.object({
41
+ name: z.string(),
42
+ status: z.enum(["busy", "idle", "error", "running", "retry"]),
43
+ })
44
+
45
+ const JobRowSchema = z.object({
46
+ title: z.string(),
47
+ status: z.enum(["pending", "running", "completed", "error", "cancelled", "interrupt"]),
48
+ toolCalls: z.number().int().nonnegative().nullable(),
49
+ lastTool: z.string().nullable(),
50
+ })
51
+
52
+ const LoopSchema = z.object({
53
+ kind: z.literal("live"),
54
+ goalsDone: z.number().int().nonnegative(),
55
+ goalsTotal: z.number().int().nonnegative(),
56
+ pass: z.number().int().nonnegative(),
57
+ fail: z.number().int().nonnegative(),
58
+ pending: z.number().int().nonnegative(),
59
+ blocked: z.number().int().nonnegative(),
60
+ activeGoal: z.string().nullable(),
61
+ })
62
+
63
+ const OmoMirrorSchema = z.object({
64
+ version: z.literal(1),
65
+ projectDir: z.string(),
66
+ updatedAt: z.number().int().nonnegative(),
67
+ activeAgents: z.array(AgentRowSchema),
68
+ jobBoard: z.array(JobRowSchema),
69
+ loop: LoopSchema.nullable(),
70
+ })
71
+
72
+ export function parseOmoMirror(raw: string, expectedProjectDir: string): MirrorParseResult {
73
+ let input: unknown
74
+ try {
75
+ input = JSON.parse(raw)
76
+ } catch (error) {
77
+ if (error instanceof SyntaxError) return { kind: "invalid" }
78
+ throw error
79
+ }
80
+
81
+ const parsed = OmoMirrorSchema.safeParse(input)
82
+ if (!parsed.success || parsed.data.projectDir !== expectedProjectDir) return { kind: "invalid" }
83
+
84
+ const { loop } = parsed.data
85
+ return {
86
+ kind: "valid",
87
+ snapshot: {
88
+ projectDir: parsed.data.projectDir,
89
+ updatedAt: parsed.data.updatedAt,
90
+ activeAgents: parsed.data.activeAgents,
91
+ jobBoard: parsed.data.jobBoard,
92
+ loop: loop
93
+ ? {
94
+ goalsDone: loop.goalsDone,
95
+ goalsTotal: loop.goalsTotal,
96
+ pass: loop.pass,
97
+ fail: loop.fail,
98
+ pending: loop.pending,
99
+ blocked: loop.blocked,
100
+ activeGoal: loop.activeGoal,
101
+ }
102
+ : null,
103
+ },
104
+ }
105
+ }
@@ -0,0 +1,137 @@
1
+ import type { ProgressView } from "./controller"
2
+ import type { SessionStatus } from "./main-session"
3
+ import type { AgentStatus, JobStatus, LoopProgress, OmoMirrorSnapshot } from "./mirror"
4
+
5
+ export type ProgressTone = "neutral" | "active" | "success" | "warning" | "error"
6
+
7
+ export type StatusRow = {
8
+ readonly label: string
9
+ readonly value: string
10
+ readonly tone: ProgressTone
11
+ }
12
+
13
+ export type ProgressSection = {
14
+ readonly title: string
15
+ readonly rows: readonly StatusRow[]
16
+ }
17
+
18
+ const MAIN_TONES = {
19
+ idle: "neutral",
20
+ busy: "active",
21
+ retry: "warning",
22
+ } as const satisfies Record<SessionStatus, ProgressTone>
23
+
24
+ const AGENT_TONES = {
25
+ idle: "neutral",
26
+ busy: "active",
27
+ running: "active",
28
+ retry: "warning",
29
+ error: "error",
30
+ } as const satisfies Record<AgentStatus, ProgressTone>
31
+
32
+ const JOB_TONES = {
33
+ pending: "warning",
34
+ running: "active",
35
+ completed: "success",
36
+ error: "error",
37
+ cancelled: "neutral",
38
+ interrupt: "warning",
39
+ } as const satisfies Record<JobStatus, ProgressTone>
40
+
41
+ function truncate(value: string, maximumLength: number): string {
42
+ return value.length <= maximumLength ? value : `${value.slice(0, maximumLength - 3)}...`
43
+ }
44
+
45
+ function mainSection(view: ProgressView): ProgressSection {
46
+ return {
47
+ title: "Main",
48
+ rows: [
49
+ {
50
+ label: truncate(view.main.title, 24),
51
+ value: view.main.status,
52
+ tone: MAIN_TONES[view.main.status],
53
+ },
54
+ { label: "agent", value: truncate(view.main.agent, 28), tone: "neutral" },
55
+ { label: "model", value: truncate(view.main.model, 28), tone: "neutral" },
56
+ ],
57
+ }
58
+ }
59
+
60
+ function agentSection(snapshot: OmoMirrorSnapshot): ProgressSection {
61
+ const rows: readonly StatusRow[] =
62
+ snapshot.activeAgents.length === 0
63
+ ? [{ label: "agents", value: "none", tone: "neutral" }]
64
+ : snapshot.activeAgents.map((agent) => ({
65
+ label: truncate(agent.name, 24),
66
+ value: agent.status,
67
+ tone: AGENT_TONES[agent.status],
68
+ }))
69
+ return { title: "Agents", rows }
70
+ }
71
+
72
+ function jobSection(snapshot: OmoMirrorSnapshot): ProgressSection {
73
+ const rows: readonly StatusRow[] =
74
+ snapshot.jobBoard.length === 0
75
+ ? [{ label: "jobs", value: "none", tone: "neutral" }]
76
+ : snapshot.jobBoard.map((job) => {
77
+ const calls = job.toolCalls === null ? "" : ` · ${job.toolCalls}`
78
+ return {
79
+ label: truncate(job.title, 16),
80
+ value: `${job.status}${calls}`,
81
+ tone: JOB_TONES[job.status],
82
+ }
83
+ })
84
+ return { title: "Jobs", rows }
85
+ }
86
+
87
+ function loopRows(loop: LoopProgress): readonly StatusRow[] {
88
+ const goalsTone: ProgressTone = loop.goalsDone === loop.goalsTotal ? "success" : "active"
89
+ const checksTone: ProgressTone = loop.fail > 0 ? "error" : loop.pass > 0 ? "success" : "neutral"
90
+ const queueTone: ProgressTone =
91
+ loop.blocked > 0 ? "error" : loop.pending > 0 ? "warning" : "neutral"
92
+ const rows: StatusRow[] = [
93
+ { label: "goals", value: `${loop.goalsDone}/${loop.goalsTotal}`, tone: goalsTone },
94
+ { label: "checks", value: `${loop.pass} pass · ${loop.fail} fail`, tone: checksTone },
95
+ { label: "queue", value: `${loop.pending} pending · ${loop.blocked} blocked`, tone: queueTone },
96
+ ]
97
+ if (loop.activeGoal !== null) {
98
+ rows.push({ label: "active", value: truncate(loop.activeGoal, 28), tone: "active" })
99
+ }
100
+ return rows
101
+ }
102
+
103
+ function loopSection(snapshot: OmoMirrorSnapshot): ProgressSection {
104
+ return {
105
+ title: "ULW",
106
+ rows:
107
+ snapshot.loop === null
108
+ ? [{ label: "loop", value: "inactive", tone: "neutral" }]
109
+ : loopRows(snapshot.loop),
110
+ }
111
+ }
112
+
113
+ export function buildProgressSections(view: ProgressView): readonly ProgressSection[] {
114
+ const main = mainSection(view)
115
+ switch (view.mirror.kind) {
116
+ case "missing":
117
+ return [
118
+ main,
119
+ { title: "OMO", rows: [{ label: "mirror", value: "waiting", tone: "neutral" }] },
120
+ ]
121
+ case "invalid":
122
+ return [main, { title: "OMO", rows: [{ label: "mirror", value: "invalid", tone: "error" }] }]
123
+ case "ready":
124
+ if (view.mirror.stale) {
125
+ return [
126
+ main,
127
+ { title: "OMO", rows: [{ label: "mirror", value: "stale", tone: "warning" }] },
128
+ ]
129
+ }
130
+ return [
131
+ main,
132
+ agentSection(view.mirror.snapshot),
133
+ jobSection(view.mirror.snapshot),
134
+ loopSection(view.mirror.snapshot),
135
+ ]
136
+ }
137
+ }
@@ -0,0 +1,20 @@
1
+ export const REFRESH_EVENT_TYPES = [
2
+ "server.connected",
3
+ "session.status",
4
+ "session.updated",
5
+ "message.updated",
6
+ "todo.updated",
7
+ ] as const
8
+
9
+ export type RefreshEventType = (typeof REFRESH_EVENT_TYPES)[number]
10
+
11
+ export interface ProgressEventBus {
12
+ on(type: RefreshEventType, handler: () => void): () => void
13
+ }
14
+
15
+ export function registerRefreshEvents(eventBus: ProgressEventBus, refresh: () => void): () => void {
16
+ const disposers = REFRESH_EVENT_TYPES.map((type) => eventBus.on(type, refresh))
17
+ return () => {
18
+ for (const dispose of disposers) dispose()
19
+ }
20
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,82 @@
1
+ import { homedir } from "node:os"
2
+ import { join } from "node:path"
3
+ import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
4
+ import {
5
+ createProgressController,
6
+ type ProgressClock,
7
+ type ProgressController,
8
+ type ProgressSource,
9
+ } from "./controller"
10
+ import {
11
+ type MainSessionReader,
12
+ readMainSession,
13
+ type SessionRecord,
14
+ type SessionStatus,
15
+ } from "./main-session"
16
+ import { readOmoMirrorFile } from "./mirror-file"
17
+ import type { ProgressEventBus } from "./refresh-events"
18
+
19
+ const POLL_INTERVAL_MS = 1_000
20
+ const MIRROR_STALE_MS = 6_000
21
+
22
+ const SYSTEM_CLOCK: ProgressClock = {
23
+ every(intervalMs, task) {
24
+ const timer = setInterval(task, intervalMs)
25
+ return () => clearInterval(timer)
26
+ },
27
+ }
28
+
29
+ export interface HostSessionAccess {
30
+ get(sessionID: string): SessionRecord | undefined
31
+ status(sessionID: string): { readonly type: SessionStatus } | undefined
32
+ }
33
+
34
+ export function createHostSessionReader(session: HostSessionAccess): MainSessionReader {
35
+ return {
36
+ getSession: (sessionID) => session.get(sessionID),
37
+ getStatus: (sessionID) => session.status(sessionID)?.type,
38
+ }
39
+ }
40
+
41
+ export function createTuiProgressController(
42
+ api: Pick<TuiPluginApi, "state" | "event">,
43
+ sessionID: string,
44
+ ): ProgressController {
45
+ const sessionReader = createHostSessionReader(api.state.session)
46
+ const { XDG_DATA_HOME: configuredDataHome } = process.env
47
+ const dataHome = configuredDataHome ?? join(homedir(), ".local", "share")
48
+ const source: ProgressSource = {
49
+ read: () => ({
50
+ main: readMainSession(sessionReader, sessionID),
51
+ mirror: readOmoMirrorFile({
52
+ projectDirectory: api.state.path.directory,
53
+ dataHome,
54
+ now: Date.now,
55
+ staleAfterMs: MIRROR_STALE_MS,
56
+ }),
57
+ }),
58
+ }
59
+ const eventBus: ProgressEventBus = {
60
+ on(type, handler) {
61
+ switch (type) {
62
+ case "server.connected":
63
+ return api.event.on("server.connected", handler)
64
+ case "session.status":
65
+ return api.event.on("session.status", handler)
66
+ case "session.updated":
67
+ return api.event.on("session.updated", handler)
68
+ case "message.updated":
69
+ return api.event.on("message.updated", handler)
70
+ case "todo.updated":
71
+ return api.event.on("todo.updated", handler)
72
+ }
73
+ },
74
+ }
75
+
76
+ return createProgressController({
77
+ source,
78
+ eventBus,
79
+ clock: SYSTEM_CLOCK,
80
+ intervalMs: POLL_INTERVAL_MS,
81
+ })
82
+ }
@@ -0,0 +1,74 @@
1
+ import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
2
+ import { type BoxRenderable, RenderableEvents } from "@opentui/core"
3
+ import type { JSX } from "@opentui/solid"
4
+ import type { ProgressController } from "./controller"
5
+ import { buildProgressSections, type ProgressSection } from "./presentation"
6
+ import { StatusSection } from "./status-section"
7
+
8
+ export type ProgressPalette = Pick<
9
+ TuiThemeCurrent,
10
+ "text" | "textMuted" | "accent" | "success" | "warning" | "error" | "borderSubtle"
11
+ >
12
+
13
+ export type ProgressSidebarProps = {
14
+ readonly controller: ProgressController
15
+ readonly palette: ProgressPalette
16
+ }
17
+
18
+ const SECTION_SLOTS = [
19
+ { title: "Main", maximumRows: 3 },
20
+ { title: "Agents", maximumRows: 12 },
21
+ { title: "Jobs", maximumRows: 12 },
22
+ { title: "ULW", maximumRows: 4 },
23
+ { title: "OMO", maximumRows: 1 },
24
+ ] as const
25
+
26
+ function indexSections(sections: readonly ProgressSection[]): ReadonlyMap<string, ProgressSection> {
27
+ return new Map(sections.map((section) => [section.title, section]))
28
+ }
29
+
30
+ export function ProgressSidebar(props: ProgressSidebarProps): JSX.Element {
31
+ let sections = indexSections(buildProgressSections(props.controller.get()))
32
+ let root: BoxRenderable | undefined
33
+ let disposed = false
34
+ const syncs = new Set<() => void>()
35
+
36
+ const syncAll = () => {
37
+ for (const sync of syncs) sync()
38
+ root?.requestRender()
39
+ }
40
+ const registerSync = (sync: () => void) => {
41
+ syncs.add(sync)
42
+ }
43
+ const cleanup = () => {
44
+ if (disposed) return
45
+ disposed = true
46
+ unsubscribe()
47
+ syncs.clear()
48
+ props.controller.dispose()
49
+ }
50
+ const attachRoot = (node: BoxRenderable) => {
51
+ root = node
52
+ node.once(RenderableEvents.DESTROYED, cleanup)
53
+ syncAll()
54
+ queueMicrotask(syncAll)
55
+ }
56
+ const unsubscribe = props.controller.subscribe((next) => {
57
+ sections = indexSections(buildProgressSections(next))
58
+ syncAll()
59
+ queueMicrotask(syncAll)
60
+ })
61
+
62
+ return (
63
+ <box ref={attachRoot} flexDirection="column" gap={1} width="100%">
64
+ {SECTION_SLOTS.map((slot) => (
65
+ <StatusSection
66
+ maximumRows={slot.maximumRows}
67
+ palette={props.palette}
68
+ section={() => sections.get(slot.title)}
69
+ registerSync={registerSync}
70
+ />
71
+ ))}
72
+ </box>
73
+ )
74
+ }
@@ -0,0 +1,104 @@
1
+ import type { BoxRenderable, TextRenderable } from "@opentui/core"
2
+ import type { JSX } from "@opentui/solid"
3
+ import type { ProgressSection, ProgressTone } from "./presentation"
4
+ import type { ProgressPalette } from "./sidebar"
5
+
6
+ type RowNodes = {
7
+ box?: BoxRenderable
8
+ label?: TextRenderable
9
+ value?: TextRenderable
10
+ }
11
+
12
+ export type StatusSectionProps = {
13
+ readonly maximumRows: number
14
+ readonly palette: ProgressPalette
15
+ readonly section: () => ProgressSection | undefined
16
+ readonly registerSync: (sync: () => void) => void
17
+ }
18
+
19
+ function toneColor(tone: ProgressTone, palette: ProgressPalette): ProgressPalette["text"] {
20
+ const colors = {
21
+ neutral: palette.textMuted,
22
+ active: palette.accent,
23
+ success: palette.success,
24
+ warning: palette.warning,
25
+ error: palette.error,
26
+ } as const satisfies Record<ProgressTone, ProgressPalette["text"]>
27
+ return colors[tone]
28
+ }
29
+
30
+ export function StatusSection(props: StatusSectionProps): JSX.Element {
31
+ let sectionNode: BoxRenderable | undefined
32
+ let titleNode: TextRenderable | undefined
33
+ const rows: RowNodes[] = Array.from({ length: props.maximumRows }, () => ({}))
34
+
35
+ const sync = () => {
36
+ const section = props.section()
37
+ if (sectionNode) sectionNode.visible = section !== undefined
38
+ if (titleNode) titleNode.content = section?.title ?? ""
39
+
40
+ for (const [index, nodes] of rows.entries()) {
41
+ const row = section?.rows[index]
42
+ if (nodes.box) nodes.box.visible = row !== undefined
43
+ if (nodes.label) nodes.label.content = row?.label ?? ""
44
+ if (nodes.value) {
45
+ nodes.value.content = row?.value ?? ""
46
+ nodes.value.fg = row ? toneColor(row.tone, props.palette) : props.palette.textMuted
47
+ nodes.value.requestRender()
48
+ }
49
+ }
50
+ sectionNode?.requestRender()
51
+ }
52
+
53
+ const attachSection = (node: BoxRenderable) => {
54
+ sectionNode = node
55
+ sync()
56
+ }
57
+ const attachTitle = (node: TextRenderable) => {
58
+ titleNode = node
59
+ sync()
60
+ }
61
+ const attachRow = (index: number) => (node: BoxRenderable) => {
62
+ const nodes = rows[index]
63
+ if (nodes) nodes.box = node
64
+ sync()
65
+ }
66
+ const attachLabel = (index: number) => (node: TextRenderable) => {
67
+ const nodes = rows[index]
68
+ if (nodes) nodes.label = node
69
+ sync()
70
+ }
71
+ const attachValue = (index: number) => (node: TextRenderable) => {
72
+ const nodes = rows[index]
73
+ if (nodes) nodes.value = node
74
+ sync()
75
+ }
76
+
77
+ props.registerSync(sync)
78
+
79
+ return (
80
+ <box
81
+ ref={attachSection}
82
+ borderStyle="single"
83
+ borderColor={props.palette.borderSubtle}
84
+ flexDirection="column"
85
+ padding={1}
86
+ visible={false}
87
+ width="100%"
88
+ >
89
+ <text ref={attachTitle} fg={props.palette.text} content="" />
90
+ {rows.map((_, index) => (
91
+ <box
92
+ ref={attachRow(index)}
93
+ flexDirection="row"
94
+ justifyContent="space-between"
95
+ visible={false}
96
+ width="100%"
97
+ >
98
+ <text ref={attachLabel(index)} fg={props.palette.textMuted} content="" />
99
+ <text ref={attachValue(index)} fg={props.palette.textMuted} content="" />
100
+ </box>
101
+ ))}
102
+ </box>
103
+ )
104
+ }
package/src/tui.tsx ADDED
@@ -0,0 +1,28 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ /** @jsxRuntime automatic */
3
+ import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
4
+ import { createTuiProgressController } from "./runtime"
5
+ import { ProgressSidebar } from "./sidebar"
6
+
7
+ const tui: TuiPlugin = async (api) => {
8
+ api.slots.register({
9
+ order: 900,
10
+ slots: {
11
+ sidebar_content(context, props) {
12
+ return (
13
+ <ProgressSidebar
14
+ controller={createTuiProgressController(api, props.session_id)}
15
+ palette={context.theme.current}
16
+ />
17
+ )
18
+ },
19
+ },
20
+ })
21
+ }
22
+
23
+ const plugin = {
24
+ id: "opencode-agent-progress",
25
+ tui,
26
+ } satisfies TuiPluginModule
27
+
28
+ export default plugin