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 +55 -0
- package/LICENSE +21 -0
- package/README.md +105 -0
- package/dist/tui.d.ts +11 -0
- package/package.json +55 -0
- package/src/controller.ts +71 -0
- package/src/main-session.ts +36 -0
- package/src/mirror-file.ts +64 -0
- package/src/mirror.ts +105 -0
- package/src/presentation.ts +137 -0
- package/src/refresh-events.ts +20 -0
- package/src/runtime.ts +82 -0
- package/src/sidebar.tsx +74 -0
- package/src/status-section.tsx +104 -0
- package/src/tui.tsx +28 -0
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
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
|
+
}
|
package/src/sidebar.tsx
ADDED
|
@@ -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
|