opencode-agent-dock 0.1.0 → 1.0.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/README.md +55 -59
- package/package.json +7 -12
- package/src/domain.test.ts +145 -0
- package/src/index.tsx +8 -9
package/README.md
CHANGED
|
@@ -1,102 +1,98 @@
|
|
|
1
1
|
# opencode-agent-dock
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
[](LICENSE)
|
|
4
|
+
[](https://opencode.ai)
|
|
5
|
+
[](https://www.npmjs.com/package/opencode-agent-dock)
|
|
6
|
+
[](https://github.com/arttttt/opencode-agent-dock)
|
|
5
7
|
|
|
6
|
-
An [OpenCode](https://opencode.ai) **TUI plugin** that adds a **Claude-Code-style
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
An [OpenCode](https://opencode.ai) **TUI plugin** that adds a **Claude-Code-style bottom panel** of your running **subagents** — visible at a glance under the prompt, with inline keyboard navigation to browse them collapsed and open any one.
|
|
9
|
+
|
|
10
|
+
Subagents normally only surface inline in the chat, and the chat scrolls away from where they were spawned. `opencode-agent-dock` keeps the active session's subagents pinned to the bottom of the screen, each with its status, elapsed time and token spend.
|
|
11
|
+
|
|
12
|
+
## How it works
|
|
10
13
|
|
|
11
14
|
```
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
active session route
|
|
16
|
+
↓
|
|
17
|
+
resolve current session
|
|
18
|
+
├─ parent session → client.session.children() → keep the RUNNING ones
|
|
19
|
+
└─ subagent session → pin the one currently in focus (panel stays up)
|
|
20
|
+
↓
|
|
21
|
+
app_bottom slot renders the roster: ● title elapsed · tokens
|
|
22
|
+
↓
|
|
23
|
+
navigation: keymap.intercept catches ↑/↓/Enter BEFORE the prompt textarea
|
|
24
|
+
(the same mechanism opencode uses for copy-on-select), so while the
|
|
25
|
+
dock is focused the prompt is "locked" and arrows move the cursor
|
|
15
26
|
```
|
|
16
27
|
|
|
17
|
-
##
|
|
28
|
+
## Features
|
|
18
29
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
**
|
|
22
|
-
|
|
30
|
+
- **Persistent bottom panel** — running subagents stay visible under the prompt (Claude-Code-style: plain rows, no panel chrome).
|
|
31
|
+
- **Per-subagent telemetry** — status glyph, elapsed time and total token usage on every row.
|
|
32
|
+
- **Inline keyboard navigation** — browse the list collapsed, `Enter` opens the selected subagent's session.
|
|
33
|
+
- **Stays visible inside a subagent** — the focused subagent is highlighted; native `←/→` between siblings updates it live.
|
|
34
|
+
- **Self-cleaning** — finished/cancelled subagents drop out of the panel automatically; it hides when there are none.
|
|
35
|
+
- **Escape stays native** — `Esc` is never hijacked, so it still cancels subagents.
|
|
23
36
|
|
|
24
37
|
## Install
|
|
25
38
|
|
|
26
|
-
TUI
|
|
39
|
+
This is a **TUI plugin**, so it is configured in `tui.json` (not `opencode.json`):
|
|
27
40
|
|
|
28
41
|
```jsonc
|
|
29
|
-
// ~/.config/opencode/tui.json
|
|
42
|
+
// ~/.config/opencode/tui.json (user) or .opencode/tui.json (project)
|
|
30
43
|
{
|
|
44
|
+
"$schema": "https://opencode.ai/tui.json",
|
|
31
45
|
"plugin": ["opencode-agent-dock"]
|
|
32
46
|
}
|
|
33
47
|
```
|
|
34
48
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
```jsonc
|
|
38
|
-
{
|
|
39
|
-
"plugin": [["file:///absolute/path/to/opencode-agent-dock/src/index.tsx"]]
|
|
40
|
-
}
|
|
41
|
-
```
|
|
49
|
+
Restart OpenCode after adding it.
|
|
42
50
|
|
|
43
51
|
## Keybindings
|
|
44
52
|
|
|
45
53
|
| Key | Action |
|
|
46
54
|
| --- | --- |
|
|
47
|
-
| `
|
|
48
|
-
|
|
|
49
|
-
| `
|
|
50
|
-
| `
|
|
55
|
+
| `ctrl+x` `v` — or `/subagents` | Focus the dock (toggle) |
|
|
56
|
+
| `↑` / `↓` | Previous / next subagent |
|
|
57
|
+
| `Enter` | Open the selected subagent's session |
|
|
58
|
+
| `↑` from the first row, or `ctrl+x` `v` again | Leave the dock |
|
|
59
|
+
| `Esc` | _(native)_ cancel subagents |
|
|
60
|
+
|
|
61
|
+
The toggle key (`<leader>v`) can be remapped in `tui.json` under the `agent-dock.toggle` command if you prefer a different one.
|
|
62
|
+
|
|
63
|
+
> Why a toggle and not bare `↓`? In the parent session the prompt textarea captures arrow keys, and a plugin cannot blur the host prompt. `keymap.intercept` runs before the textarea, so once the dock is focused the arrows are ours. `↓` itself is also the prompt's history key, which made it an unreliable trigger.
|
|
51
64
|
|
|
52
65
|
## Architecture
|
|
53
66
|
|
|
54
|
-
Clean Architecture,
|
|
55
|
-
[`opencode-auto-vision`](https://github.com/arttttt/opencode-auto-vision):
|
|
56
|
-
dependencies point inward, the domain is pure, and the only thing that knows
|
|
57
|
-
about the opencode SDK / TUI API is the composition root + adapters.
|
|
67
|
+
Clean Architecture — dependencies point inward, the domain is pure, and the only place that knows about the opencode SDK / TUI API is the composition root + adapters.
|
|
58
68
|
|
|
59
69
|
```
|
|
60
70
|
src/
|
|
61
|
-
domain.ts
|
|
62
|
-
ports.ts
|
|
63
|
-
sdk.ts
|
|
64
|
-
constants.ts
|
|
71
|
+
domain.ts pure: Subagent, selection math, formatting (unit-tested)
|
|
72
|
+
ports.ts SubagentStore, Logger contracts
|
|
73
|
+
sdk.ts SDK Session/SessionStatus -> domain Subagent
|
|
74
|
+
constants.ts plugin id, slot name, limits
|
|
65
75
|
adapters/
|
|
66
|
-
opencode-subagent-store.ts
|
|
67
|
-
opencode-
|
|
68
|
-
opencode-logger.ts Logger via client.app.log
|
|
76
|
+
opencode-subagent-store.ts SubagentStore via client.session.children + events
|
|
77
|
+
opencode-logger.ts Logger via client.app.log
|
|
69
78
|
view/
|
|
70
|
-
Dock.tsx
|
|
71
|
-
index.tsx
|
|
79
|
+
Dock.tsx Solid render into the app_bottom slot
|
|
80
|
+
index.tsx composition root: slot + inline nav (intercept)
|
|
72
81
|
```
|
|
73
82
|
|
|
74
|
-
### Data flow
|
|
75
|
-
|
|
76
|
-
`api.route.current` resolves the active parent session →
|
|
77
|
-
`client.session.children({ sessionID })` returns its child sessions → each is
|
|
78
|
-
projected to a domain `Subagent` and status is read from
|
|
79
|
-
`api.state.session.status(id)` → `Dock` renders rows in `app_bottom`.
|
|
80
|
-
Selection is owned by the root; `enter` calls `route.navigate("session", …)`.
|
|
81
|
-
|
|
82
|
-
## Verify before release
|
|
83
|
-
|
|
84
|
-
- [ ] `client.session.children` response shape (`result.data`) and namespace.
|
|
85
|
-
- [ ] `Session.time.created` field name for elapsed-time origin.
|
|
86
|
-
- [ ] `app_bottom` slot `SolidPlugin` shape from `@opentui/solid` (cast today).
|
|
87
|
-
- [ ] Focus handling: pushing `agent-dock` mode reliably captures `j/k/enter`.
|
|
88
|
-
- [ ] Map terminal subagent states to `done` vs `error` (status `undefined` is
|
|
89
|
-
treated as `done` for now).
|
|
90
|
-
|
|
91
83
|
## Develop
|
|
92
84
|
|
|
93
85
|
```sh
|
|
94
86
|
npm install
|
|
87
|
+
npm test # vitest — domain unit tests
|
|
95
88
|
npm run typecheck
|
|
96
89
|
```
|
|
97
90
|
|
|
98
|
-
OpenCode runs on Bun and loads `.ts`/`.tsx` natively, so there is no build step
|
|
99
|
-
|
|
91
|
+
OpenCode runs on Bun and loads `.ts`/`.tsx` natively, so there is no build step for local file-plugin use:
|
|
92
|
+
|
|
93
|
+
```jsonc
|
|
94
|
+
{ "plugin": [["file:///absolute/path/to/opencode-agent-dock/src/index.tsx"]] }
|
|
95
|
+
```
|
|
100
96
|
|
|
101
97
|
## License
|
|
102
98
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-agent-dock",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "OpenCode TUI plugin — a Claude-Code-style bottom panel
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "OpenCode TUI plugin — a Claude-Code-style bottom panel of running subagents with inline keyboard navigation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
"./tui": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"LICENSE"
|
|
15
15
|
],
|
|
16
16
|
"scripts": {
|
|
17
|
+
"test": "vitest run",
|
|
17
18
|
"typecheck": "tsc --noEmit",
|
|
18
19
|
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
|
|
19
20
|
"format:check": "prettier --check \"src/**/*.{ts,tsx}\""
|
|
@@ -37,30 +38,24 @@
|
|
|
37
38
|
"bugs": {
|
|
38
39
|
"url": "https://github.com/arttttt/opencode-agent-dock/issues"
|
|
39
40
|
},
|
|
40
|
-
"dependencies": {
|
|
41
|
-
"@opencode-ai/plugin": "^1.18.4",
|
|
42
|
-
"@opencode-ai/sdk": "^1.18.4"
|
|
43
|
-
},
|
|
44
41
|
"peerDependencies": {
|
|
45
|
-
"@
|
|
46
|
-
"@
|
|
42
|
+
"@opencode-ai/plugin": ">=1.18.0",
|
|
43
|
+
"@opencode-ai/sdk": ">=1.18.0",
|
|
47
44
|
"@opentui/solid": ">=0.4.5"
|
|
48
45
|
},
|
|
49
46
|
"peerDependenciesMeta": {
|
|
50
|
-
"@opentui/core": { "optional": true },
|
|
51
|
-
"@opentui/keymap": { "optional": true },
|
|
52
47
|
"@opentui/solid": { "optional": true }
|
|
53
48
|
},
|
|
54
49
|
"devDependencies": {
|
|
55
50
|
"@opencode-ai/plugin": "^1.18.4",
|
|
56
51
|
"@opencode-ai/sdk": "^1.18.4",
|
|
57
52
|
"@opentui/core": ">=0.4.5",
|
|
58
|
-
"@opentui/keymap": ">=0.4.5",
|
|
59
53
|
"@opentui/solid": ">=0.4.5",
|
|
60
54
|
"@types/bun": "latest",
|
|
61
55
|
"prettier": "^3.8.1",
|
|
62
56
|
"solid-js": "^1.9.0",
|
|
63
|
-
"typescript": "^5.7.0"
|
|
57
|
+
"typescript": "^5.7.0",
|
|
58
|
+
"vitest": "^2.1.0"
|
|
64
59
|
},
|
|
65
60
|
"engines": {
|
|
66
61
|
"opencode": "^1.18.0"
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
sortSubagents,
|
|
4
|
+
clampSelection,
|
|
5
|
+
formatElapsed,
|
|
6
|
+
formatTokens,
|
|
7
|
+
truncate,
|
|
8
|
+
isTerminal,
|
|
9
|
+
toRow,
|
|
10
|
+
type Subagent,
|
|
11
|
+
} from "./domain.js";
|
|
12
|
+
import { statusFromSessionStatus } from "./sdk.js";
|
|
13
|
+
|
|
14
|
+
const sub = (overrides: Partial<Subagent> = {}): Subagent => ({
|
|
15
|
+
id: "ses_1",
|
|
16
|
+
title: "Analyze architecture",
|
|
17
|
+
status: "running",
|
|
18
|
+
startedAt: 0,
|
|
19
|
+
...overrides,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("sortSubagents", () => {
|
|
23
|
+
it("orders running before idle before terminal states", () => {
|
|
24
|
+
const list = [
|
|
25
|
+
sub({ id: "done", status: "done", startedAt: 1 }),
|
|
26
|
+
sub({ id: "idle", status: "idle", startedAt: 2 }),
|
|
27
|
+
sub({ id: "run", status: "running", startedAt: 3 }),
|
|
28
|
+
];
|
|
29
|
+
expect(sortSubagents(list).map((s) => s.id)).toEqual(["run", "idle", "done"]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("falls back to newest-first within the same status", () => {
|
|
33
|
+
const list = [
|
|
34
|
+
sub({ id: "old", status: "running", startedAt: 10 }),
|
|
35
|
+
sub({ id: "new", status: "running", startedAt: 90 }),
|
|
36
|
+
];
|
|
37
|
+
expect(sortSubagents(list).map((s) => s.id)).toEqual(["new", "old"]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("does not mutate the input", () => {
|
|
41
|
+
const list = [sub({ id: "a" }), sub({ id: "b" })];
|
|
42
|
+
const snapshot = list.map((s) => s.id);
|
|
43
|
+
sortSubagents(list);
|
|
44
|
+
expect(list.map((s) => s.id)).toEqual(snapshot);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("clampSelection", () => {
|
|
49
|
+
it("returns 0 for an empty list", () => {
|
|
50
|
+
expect(clampSelection(5, 0)).toBe(0);
|
|
51
|
+
});
|
|
52
|
+
it("clamps below 0 to 0", () => {
|
|
53
|
+
expect(clampSelection(-3, 4)).toBe(0);
|
|
54
|
+
});
|
|
55
|
+
it("clamps overflow to the last index", () => {
|
|
56
|
+
expect(clampSelection(99, 4)).toBe(3);
|
|
57
|
+
});
|
|
58
|
+
it("passes through in-range values", () => {
|
|
59
|
+
expect(clampSelection(2, 4)).toBe(2);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("formatElapsed", () => {
|
|
64
|
+
it("formats seconds under a minute", () => {
|
|
65
|
+
expect(formatElapsed(0, 59_000)).toBe("59s");
|
|
66
|
+
});
|
|
67
|
+
it("formats minutes with zero-padded seconds", () => {
|
|
68
|
+
expect(formatElapsed(0, 125_000)).toBe("2m 05s");
|
|
69
|
+
});
|
|
70
|
+
it("formats hours with zero-padded minutes", () => {
|
|
71
|
+
expect(formatElapsed(0, 3_725_000)).toBe("1h 02m");
|
|
72
|
+
});
|
|
73
|
+
it("never goes negative", () => {
|
|
74
|
+
expect(formatElapsed(10_000, 0)).toBe("0s");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("formatTokens", () => {
|
|
79
|
+
it("returns empty for undefined / non-positive", () => {
|
|
80
|
+
expect(formatTokens(undefined)).toBe("");
|
|
81
|
+
expect(formatTokens(0)).toBe("");
|
|
82
|
+
});
|
|
83
|
+
it("renders raw numbers under 1000", () => {
|
|
84
|
+
expect(formatTokens(500)).toBe("500");
|
|
85
|
+
expect(formatTokens(999)).toBe("999");
|
|
86
|
+
});
|
|
87
|
+
it("renders thousands with a k suffix", () => {
|
|
88
|
+
expect(formatTokens(1500)).toBe("1.5k");
|
|
89
|
+
expect(formatTokens(85_400)).toBe("85.4k");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("truncate", () => {
|
|
94
|
+
it("trims and returns short text as-is", () => {
|
|
95
|
+
expect(truncate(" hi ", 10)).toBe("hi");
|
|
96
|
+
});
|
|
97
|
+
it("truncates with an ellipsis when too long", () => {
|
|
98
|
+
expect(truncate("hello world", 5)).toBe("hell…");
|
|
99
|
+
});
|
|
100
|
+
it("handles exact length without truncation", () => {
|
|
101
|
+
expect(truncate("hello", 5)).toBe("hello");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("isTerminal", () => {
|
|
106
|
+
it("treats done and error as terminal", () => {
|
|
107
|
+
expect(isTerminal("done")).toBe(true);
|
|
108
|
+
expect(isTerminal("error")).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
it("treats running and idle as non-terminal", () => {
|
|
111
|
+
expect(isTerminal("running")).toBe(false);
|
|
112
|
+
expect(isTerminal("idle")).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("toRow", () => {
|
|
117
|
+
it("maps status to a glyph", () => {
|
|
118
|
+
expect(toRow(sub({ status: "running" }), 0, 48).glyph).toBe("●");
|
|
119
|
+
expect(toRow(sub({ status: "done" }), 0, 48).glyph).toBe("✓");
|
|
120
|
+
});
|
|
121
|
+
it("shows elapsed + tokens in meta for a running subagent", () => {
|
|
122
|
+
const row = toRow(sub({ status: "running", startedAt: 0, tokens: 1500 }), 30_000, 48);
|
|
123
|
+
expect(row.meta).toBe("30s · 1.5k");
|
|
124
|
+
});
|
|
125
|
+
it("drops elapsed for terminal states", () => {
|
|
126
|
+
const row = toRow(sub({ status: "done", tokens: 500 }), 30_000, 48);
|
|
127
|
+
expect(row.meta).toBe("500");
|
|
128
|
+
});
|
|
129
|
+
it("falls back to the id when the title is empty", () => {
|
|
130
|
+
expect(toRow(sub({ title: "", id: "ses_x" }), 0, 48).label).toBe("ses_x");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("statusFromSessionStatus", () => {
|
|
135
|
+
it("maps busy and retry to running", () => {
|
|
136
|
+
expect(statusFromSessionStatus({ type: "busy" })).toBe("running");
|
|
137
|
+
expect(statusFromSessionStatus({ type: "retry", attempt: 1, message: "x", next: 1 })).toBe("running");
|
|
138
|
+
});
|
|
139
|
+
it("maps idle to idle", () => {
|
|
140
|
+
expect(statusFromSessionStatus({ type: "idle" })).toBe("idle");
|
|
141
|
+
});
|
|
142
|
+
it("maps undefined to done", () => {
|
|
143
|
+
expect(statusFromSessionStatus(undefined)).toBe("done");
|
|
144
|
+
});
|
|
145
|
+
});
|
package/src/index.tsx
CHANGED
|
@@ -80,25 +80,24 @@ const tui: TuiPlugin = async (api) => {
|
|
|
80
80
|
// Inline navigation: intercept arrows/enter while the dock is focused.
|
|
81
81
|
api.keymap.intercept(
|
|
82
82
|
"key",
|
|
83
|
-
({ event }) => {
|
|
83
|
+
({ event }: { event: InterceptEvent }) => {
|
|
84
84
|
if (!focused()) return;
|
|
85
|
-
const ev = event as InterceptEvent;
|
|
86
85
|
const rows = store.snapshot();
|
|
87
86
|
if (rows.length === 0) {
|
|
88
87
|
exitDock();
|
|
89
88
|
return;
|
|
90
89
|
}
|
|
91
|
-
switch (
|
|
90
|
+
switch (event.name) {
|
|
92
91
|
case "up":
|
|
93
92
|
if (selection() <= 0) exitDock();
|
|
94
93
|
else setSelection((current) => current - 1);
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
event.preventDefault();
|
|
95
|
+
event.stopPropagation();
|
|
97
96
|
break;
|
|
98
97
|
case "down":
|
|
99
98
|
setSelection((current) => clampSelection(current + 1, rows.length));
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
event.stopPropagation();
|
|
102
101
|
break;
|
|
103
102
|
case "return": {
|
|
104
103
|
const target = rows[clampSelection(selection(), rows.length)];
|
|
@@ -106,8 +105,8 @@ const tui: TuiPlugin = async (api) => {
|
|
|
106
105
|
exitDock();
|
|
107
106
|
api.route.navigate("session", { sessionID: target.id });
|
|
108
107
|
}
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
event.preventDefault();
|
|
109
|
+
event.stopPropagation();
|
|
111
110
|
break;
|
|
112
111
|
}
|
|
113
112
|
}
|