opencode-agent-dock 0.1.0 → 1.0.1
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 +48 -66
- package/dist/adapters/opencode-logger.js +17 -0
- package/{src/adapters/opencode-subagent-store.ts → dist/adapters/opencode-subagent-store.js} +24 -59
- package/{src/constants.ts → dist/constants.js} +2 -3
- package/{src/domain.ts → dist/domain.js} +13 -37
- package/{src/index.tsx → dist/index.js} +69 -77
- package/dist/ports.js +1 -0
- package/{src/sdk.ts → dist/sdk.js} +4 -9
- package/dist/view/Dock.js +88 -0
- package/package.json +19 -15
- package/src/adapters/opencode-logger.ts +0 -18
- package/src/ports.ts +0 -24
- package/src/view/Dock.tsx +0 -72
package/README.md
CHANGED
|
@@ -1,102 +1,84 @@
|
|
|
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
|
-
| `
|
|
51
|
-
|
|
52
|
-
## Architecture
|
|
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 |
|
|
53
60
|
|
|
54
|
-
|
|
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.
|
|
61
|
+
The toggle key (`<leader>v`) can be remapped in `tui.json` under the `agent-dock.toggle` command if you prefer a different one.
|
|
58
62
|
|
|
59
|
-
|
|
60
|
-
src/
|
|
61
|
-
domain.ts pure: Subagent, selection math, formatting
|
|
62
|
-
ports.ts SubagentStore, SessionNavigator, Logger
|
|
63
|
-
sdk.ts SDK Session/SessionStatus -> domain Subagent
|
|
64
|
-
constants.ts plugin id, slot name, keybinds, limits
|
|
65
|
-
adapters/
|
|
66
|
-
opencode-subagent-store.ts SubagentStore via client.session.children + events
|
|
67
|
-
opencode-navigator.ts SessionNavigator via route.navigate
|
|
68
|
-
opencode-logger.ts Logger via client.app.log
|
|
69
|
-
view/
|
|
70
|
-
Dock.tsx Solid render into the app_bottom slot
|
|
71
|
-
index.tsx composition root + tui entry ({ id, tui })
|
|
72
|
-
```
|
|
73
|
-
|
|
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).
|
|
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.
|
|
90
64
|
|
|
91
65
|
## Develop
|
|
92
66
|
|
|
93
67
|
```sh
|
|
94
68
|
npm install
|
|
69
|
+
npm test # builds dist/, then vitest: domain unit tests + the packed tarball loaded as OpenCode loads it
|
|
95
70
|
npm run typecheck
|
|
96
71
|
```
|
|
97
72
|
|
|
98
|
-
OpenCode
|
|
99
|
-
|
|
73
|
+
The package ships `dist/`, compiled by `npm run build` with the same Solid transform OpenCode applies to local plugins. Raw `.tsx` can't ship: OpenCode compiles none of the files under `node_modules`, where an npm install lives (1.0.0 shipped `src/` and never loaded from npm).
|
|
74
|
+
|
|
75
|
+
For local development a file plugin needs no build, since it is not under `node_modules`:
|
|
76
|
+
|
|
77
|
+
```jsonc
|
|
78
|
+
{ "plugin": [["file:///absolute/path/to/opencode-agent-dock/src/index.tsx"]] }
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
CI runs the typecheck and tests on every pull request and every push to `main`.
|
|
100
82
|
|
|
101
83
|
## License
|
|
102
84
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Adapter — Logger over the opencode client app log endpoint.
|
|
2
|
+
|
|
3
|
+
import { SERVICE } from "../constants.js";
|
|
4
|
+
export function createLogger(client) {
|
|
5
|
+
const write = (level, message) => {
|
|
6
|
+
client.app.log({
|
|
7
|
+
service: SERVICE,
|
|
8
|
+
level,
|
|
9
|
+
message
|
|
10
|
+
}).catch(() => {});
|
|
11
|
+
};
|
|
12
|
+
return {
|
|
13
|
+
info: m => write("info", m),
|
|
14
|
+
warn: m => write("warn", m),
|
|
15
|
+
error: m => write("error", m)
|
|
16
|
+
};
|
|
17
|
+
}
|
package/{src/adapters/opencode-subagent-store.ts → dist/adapters/opencode-subagent-store.js}
RENAMED
|
@@ -8,50 +8,30 @@
|
|
|
8
8
|
// the panel; native left/right between siblings updates it). `activeId`
|
|
9
9
|
// exposes that focused id for highlighting.
|
|
10
10
|
|
|
11
|
-
import type { Session } from "@opencode-ai/sdk/v2";
|
|
12
|
-
import type { TuiPluginApi } from "@opencode-ai/plugin/tui";
|
|
13
|
-
import type { Subagent } from "../domain.js";
|
|
14
11
|
import { sortSubagents } from "../domain.js";
|
|
15
12
|
import { projectSubagent } from "../sdk.js";
|
|
16
|
-
import type { Logger, SubagentStore } from "../ports.js";
|
|
17
13
|
import { LIMITS } from "../constants.js";
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
children(parameters: {
|
|
22
|
-
sessionID: string;
|
|
23
|
-
directory?: string;
|
|
24
|
-
workspace?: string;
|
|
25
|
-
}): Promise<unknown>;
|
|
26
|
-
};
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
function extractSessions(result: unknown): Session[] {
|
|
30
|
-
const data = (result as { data?: unknown } | null | undefined)?.data;
|
|
31
|
-
return Array.isArray(data) ? (data as Session[]) : [];
|
|
14
|
+
function extractSessions(result) {
|
|
15
|
+
const data = result?.data;
|
|
16
|
+
return Array.isArray(data) ? data : [];
|
|
32
17
|
}
|
|
33
|
-
|
|
34
|
-
function isArchived(session: Session): boolean {
|
|
18
|
+
function isArchived(session) {
|
|
35
19
|
const archived = session.time.archived;
|
|
36
20
|
return typeof archived === "number" && archived > 0;
|
|
37
21
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
let
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const emit = (): void => {
|
|
22
|
+
export function createSubagentStore(api, log) {
|
|
23
|
+
const client = api.client;
|
|
24
|
+
const listeners = new Set();
|
|
25
|
+
let cache = [];
|
|
26
|
+
let activeId;
|
|
27
|
+
const emit = () => {
|
|
46
28
|
for (const handler of listeners) handler();
|
|
47
29
|
};
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const current = api.route.current as { name: string; params?: { sessionID?: string } };
|
|
30
|
+
const resolveCurrent = () => {
|
|
31
|
+
const current = api.route.current;
|
|
51
32
|
return current.name === "session" ? current.params?.sessionID : undefined;
|
|
52
33
|
};
|
|
53
|
-
|
|
54
|
-
const refresh = async (): Promise<void> => {
|
|
34
|
+
const refresh = async () => {
|
|
55
35
|
const currentId = resolveCurrent();
|
|
56
36
|
if (!currentId) {
|
|
57
37
|
if (cache.length !== 0 || activeId !== undefined) {
|
|
@@ -72,14 +52,11 @@ export function createSubagentStore(api: TuiPluginApi, log: Logger): SubagentSto
|
|
|
72
52
|
activeId = currentId;
|
|
73
53
|
} else {
|
|
74
54
|
// Parent session: list running children.
|
|
75
|
-
const result = await client.session.children({
|
|
55
|
+
const result = await client.session.children({
|
|
56
|
+
sessionID: currentId
|
|
57
|
+
});
|
|
76
58
|
const sessions = extractSessions(result);
|
|
77
|
-
cache = sortSubagents(
|
|
78
|
-
sessions
|
|
79
|
-
.filter((item) => !isArchived(item))
|
|
80
|
-
.map((item) => projectSubagent(item, api.state.session.status(item.id)))
|
|
81
|
-
.filter((subagent) => subagent.status === "running"),
|
|
82
|
-
).slice(0, LIMITS.maxRows);
|
|
59
|
+
cache = sortSubagents(sessions.filter(item => !isArchived(item)).map(item => projectSubagent(item, api.state.session.status(item.id))).filter(subagent => subagent.status === "running")).slice(0, LIMITS.maxRows);
|
|
83
60
|
activeId = undefined;
|
|
84
61
|
}
|
|
85
62
|
emit();
|
|
@@ -87,41 +64,29 @@ export function createSubagentStore(api: TuiPluginApi, log: Logger): SubagentSto
|
|
|
87
64
|
log.error(`dock refresh failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
88
65
|
}
|
|
89
66
|
};
|
|
90
|
-
|
|
91
67
|
void refresh();
|
|
92
68
|
const interval = setInterval(() => void refresh(), LIMITS.pollMs);
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
type: string,
|
|
96
|
-
handler: () => void,
|
|
97
|
-
) => () => void;
|
|
98
|
-
const unsubs = [
|
|
99
|
-
subscribe("session.created", () => void refresh()),
|
|
100
|
-
subscribe("session.idle", () => void refresh()),
|
|
101
|
-
subscribe("session.updated", () => void refresh()),
|
|
102
|
-
subscribe("session.removed", () => void refresh()),
|
|
103
|
-
];
|
|
104
|
-
|
|
69
|
+
const subscribe = api.event.on;
|
|
70
|
+
const unsubs = [subscribe("session.created", () => void refresh()), subscribe("session.idle", () => void refresh()), subscribe("session.updated", () => void refresh()), subscribe("session.removed", () => void refresh())];
|
|
105
71
|
api.lifecycle.onDispose(() => {
|
|
106
72
|
clearInterval(interval);
|
|
107
73
|
for (const unsub of unsubs) unsub();
|
|
108
74
|
});
|
|
109
|
-
|
|
110
75
|
return {
|
|
111
|
-
get parentSessionId()
|
|
76
|
+
get parentSessionId() {
|
|
112
77
|
return resolveCurrent();
|
|
113
78
|
},
|
|
114
|
-
get activeId()
|
|
79
|
+
get activeId() {
|
|
115
80
|
return activeId;
|
|
116
81
|
},
|
|
117
|
-
snapshot()
|
|
82
|
+
snapshot() {
|
|
118
83
|
return cache;
|
|
119
84
|
},
|
|
120
|
-
onChange(handler
|
|
85
|
+
onChange(handler) {
|
|
121
86
|
listeners.add(handler);
|
|
122
87
|
return () => {
|
|
123
88
|
listeners.delete(handler);
|
|
124
89
|
};
|
|
125
|
-
}
|
|
90
|
+
}
|
|
126
91
|
};
|
|
127
92
|
}
|
|
@@ -1,54 +1,34 @@
|
|
|
1
1
|
// Domain — pure policy. No IO, no SDK, no Solid. Fully unit-testable.
|
|
2
2
|
|
|
3
|
-
export type SubagentStatus = "running" | "idle" | "done" | "error";
|
|
4
|
-
|
|
5
3
|
/** Neutral projection of a child session. SDK-shape mapping lives in sdk.ts. */
|
|
6
|
-
export type Subagent = {
|
|
7
|
-
id: string;
|
|
8
|
-
title: string;
|
|
9
|
-
status: SubagentStatus;
|
|
10
|
-
startedAt: number;
|
|
11
|
-
model?: string;
|
|
12
|
-
tokens?: number;
|
|
13
|
-
};
|
|
14
4
|
|
|
15
5
|
/** View-model row derived from a Subagent at a point in time. */
|
|
16
|
-
export type DockRow = {
|
|
17
|
-
subagent: Subagent;
|
|
18
|
-
glyph: string;
|
|
19
|
-
label: string;
|
|
20
|
-
meta: string;
|
|
21
|
-
};
|
|
22
6
|
|
|
23
|
-
const STATUS_GLYPH
|
|
7
|
+
const STATUS_GLYPH = {
|
|
24
8
|
running: "●",
|
|
25
9
|
idle: "○",
|
|
26
10
|
done: "✓",
|
|
27
|
-
error: "✗"
|
|
11
|
+
error: "✗"
|
|
28
12
|
};
|
|
29
|
-
|
|
30
|
-
const STATUS_RANK: Record<SubagentStatus, number> = {
|
|
13
|
+
const STATUS_RANK = {
|
|
31
14
|
running: 0,
|
|
32
15
|
idle: 1,
|
|
33
16
|
done: 2,
|
|
34
|
-
error: 3
|
|
17
|
+
error: 3
|
|
35
18
|
};
|
|
36
|
-
|
|
37
|
-
export function sortSubagents(list: readonly Subagent[]): Subagent[] {
|
|
19
|
+
export function sortSubagents(list) {
|
|
38
20
|
return [...list].sort((a, b) => {
|
|
39
21
|
const r = STATUS_RANK[a.status] - STATUS_RANK[b.status];
|
|
40
22
|
return r !== 0 ? r : b.startedAt - a.startedAt;
|
|
41
23
|
});
|
|
42
24
|
}
|
|
43
|
-
|
|
44
|
-
export function clampSelection(index: number, length: number): number {
|
|
25
|
+
export function clampSelection(index, length) {
|
|
45
26
|
if (length <= 0) return 0;
|
|
46
27
|
if (index < 0) return 0;
|
|
47
28
|
if (index >= length) return length - 1;
|
|
48
29
|
return index;
|
|
49
30
|
}
|
|
50
|
-
|
|
51
|
-
export function formatElapsed(startedAt: number, now: number = Date.now()): string {
|
|
31
|
+
export function formatElapsed(startedAt, now = Date.now()) {
|
|
52
32
|
const seconds = Math.max(0, Math.floor((now - startedAt) / 1000));
|
|
53
33
|
if (seconds < 60) return `${seconds}s`;
|
|
54
34
|
const minutes = Math.floor(seconds / 60);
|
|
@@ -57,31 +37,27 @@ export function formatElapsed(startedAt: number, now: number = Date.now()): stri
|
|
|
57
37
|
const hours = Math.floor(minutes / 60);
|
|
58
38
|
return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
|
|
59
39
|
}
|
|
60
|
-
|
|
61
|
-
export function formatTokens(tokens: number | undefined): string {
|
|
40
|
+
export function formatTokens(tokens) {
|
|
62
41
|
if (tokens === undefined || tokens <= 0) return "";
|
|
63
42
|
if (tokens < 1000) return `${tokens}`;
|
|
64
43
|
return `${(tokens / 1000).toFixed(1)}k`;
|
|
65
44
|
}
|
|
66
|
-
|
|
67
|
-
export function truncate(text: string, max: number): string {
|
|
45
|
+
export function truncate(text, max) {
|
|
68
46
|
const trimmed = text.trim();
|
|
69
47
|
if (trimmed.length <= max) return trimmed;
|
|
70
48
|
return trimmed.slice(0, Math.max(0, max - 1)).trimEnd() + "…";
|
|
71
49
|
}
|
|
72
|
-
|
|
73
|
-
export function isTerminal(status: SubagentStatus): boolean {
|
|
50
|
+
export function isTerminal(status) {
|
|
74
51
|
return status === "done" || status === "error";
|
|
75
52
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const meta: string[] = [];
|
|
53
|
+
export function toRow(subagent, now, labelMax) {
|
|
54
|
+
const meta = [];
|
|
79
55
|
if (!isTerminal(subagent.status)) meta.push(formatElapsed(subagent.startedAt, now));
|
|
80
56
|
if (subagent.tokens !== undefined) meta.push(formatTokens(subagent.tokens));
|
|
81
57
|
return {
|
|
82
58
|
subagent,
|
|
83
59
|
glyph: STATUS_GLYPH[subagent.status],
|
|
84
60
|
label: truncate(subagent.title || subagent.id, labelMax),
|
|
85
|
-
meta: meta.join(" \u00b7 ")
|
|
61
|
+
meta: meta.join(" \u00b7 ")
|
|
86
62
|
};
|
|
87
63
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createComponent as _$createComponent } from "@opentui/solid";
|
|
1
2
|
/** @jsxImportSource @opentui/solid */
|
|
2
3
|
// Composition root.
|
|
3
4
|
//
|
|
@@ -13,29 +14,19 @@
|
|
|
13
14
|
// Toggle: `<leader>v` (ctrl+x then v) or `/subagents` — ctrl+x itself is left
|
|
14
15
|
// untouched (it is opencode's leader key).
|
|
15
16
|
|
|
16
|
-
import type { TuiPlugin, TuiPluginModule, TuiSlotPlugin } from "@opencode-ai/plugin/tui";
|
|
17
17
|
import { createSignal } from "solid-js";
|
|
18
18
|
import { PLUGIN_ID } from "./constants.js";
|
|
19
19
|
import { clampSelection } from "./domain.js";
|
|
20
20
|
import { createSubagentStore } from "./adapters/opencode-subagent-store.js";
|
|
21
21
|
import { createLogger } from "./adapters/opencode-logger.js";
|
|
22
22
|
import { Dock } from "./view/Dock.js";
|
|
23
|
-
|
|
24
|
-
type InterceptEvent = {
|
|
25
|
-
name?: string;
|
|
26
|
-
ctrl?: boolean;
|
|
27
|
-
preventDefault: () => void;
|
|
28
|
-
stopPropagation: () => void;
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
const tui: TuiPlugin = async (api) => {
|
|
23
|
+
const tui = async api => {
|
|
32
24
|
const log = createLogger(api.client);
|
|
33
25
|
const store = createSubagentStore(api, log);
|
|
34
26
|
log.info("agent-dock initialized");
|
|
35
|
-
|
|
36
27
|
const [selection, setSelection] = createSignal(0);
|
|
37
28
|
const [focused, setFocused] = createSignal(false);
|
|
38
|
-
const [activeId, setActiveId] = createSignal
|
|
29
|
+
const [activeId, setActiveId] = createSignal(undefined);
|
|
39
30
|
|
|
40
31
|
// Mirror the store's active id + keep selection in range as rows change; leave
|
|
41
32
|
// the dock if the roster empties.
|
|
@@ -49,25 +40,27 @@ const tui: TuiPlugin = async (api) => {
|
|
|
49
40
|
const clamped = clampSelection(selection(), rows.length);
|
|
50
41
|
if (clamped !== selection()) setSelection(clamped);
|
|
51
42
|
});
|
|
52
|
-
|
|
53
|
-
const slot: TuiSlotPlugin = {
|
|
43
|
+
const slot = {
|
|
54
44
|
slots: {
|
|
55
|
-
app_bottom:
|
|
45
|
+
app_bottom: ctx => {
|
|
56
46
|
const tone = ctx.theme.current;
|
|
57
|
-
return (
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
47
|
+
return _$createComponent(Dock, {
|
|
48
|
+
store: store,
|
|
49
|
+
selection: selection,
|
|
50
|
+
focused: focused,
|
|
51
|
+
activeId: activeId,
|
|
52
|
+
get colors() {
|
|
53
|
+
return {
|
|
54
|
+
text: tone.text,
|
|
55
|
+
muted: tone.textMuted,
|
|
56
|
+
accent: tone.primary
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
68
62
|
};
|
|
69
63
|
api.slots.register(slot);
|
|
70
|
-
|
|
71
64
|
const enterDock = () => {
|
|
72
65
|
if (store.snapshot().length === 0) return;
|
|
73
66
|
setSelection(0);
|
|
@@ -78,66 +71,65 @@ const tui: TuiPlugin = async (api) => {
|
|
|
78
71
|
};
|
|
79
72
|
|
|
80
73
|
// Inline navigation: intercept arrows/enter while the dock is focused.
|
|
81
|
-
api.keymap.intercept(
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
case "return": {
|
|
74
|
+
api.keymap.intercept("key", ({
|
|
75
|
+
event
|
|
76
|
+
}) => {
|
|
77
|
+
if (!focused()) return;
|
|
78
|
+
const rows = store.snapshot();
|
|
79
|
+
if (rows.length === 0) {
|
|
80
|
+
exitDock();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
switch (event.name) {
|
|
84
|
+
case "up":
|
|
85
|
+
if (selection() <= 0) exitDock();else setSelection(current => current - 1);
|
|
86
|
+
event.preventDefault();
|
|
87
|
+
event.stopPropagation();
|
|
88
|
+
break;
|
|
89
|
+
case "down":
|
|
90
|
+
setSelection(current => clampSelection(current + 1, rows.length));
|
|
91
|
+
event.preventDefault();
|
|
92
|
+
event.stopPropagation();
|
|
93
|
+
break;
|
|
94
|
+
case "return":
|
|
95
|
+
{
|
|
104
96
|
const target = rows[clampSelection(selection(), rows.length)];
|
|
105
97
|
if (target) {
|
|
106
98
|
exitDock();
|
|
107
|
-
api.route.navigate("session", {
|
|
99
|
+
api.route.navigate("session", {
|
|
100
|
+
sessionID: target.id
|
|
101
|
+
});
|
|
108
102
|
}
|
|
109
|
-
|
|
110
|
-
|
|
103
|
+
event.preventDefault();
|
|
104
|
+
event.stopPropagation();
|
|
111
105
|
break;
|
|
112
106
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
);
|
|
117
|
-
|
|
107
|
+
}
|
|
108
|
+
}, {
|
|
109
|
+
priority: 1
|
|
110
|
+
});
|
|
118
111
|
api.keymap.registerLayer({
|
|
119
112
|
mode: "base",
|
|
120
|
-
commands: [
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
113
|
+
commands: [{
|
|
114
|
+
name: "agent-dock.toggle",
|
|
115
|
+
title: "Subagents",
|
|
116
|
+
category: "Plugin",
|
|
117
|
+
namespace: "palette",
|
|
118
|
+
slashName: "subagents",
|
|
119
|
+
desc: "Focus the subagent dock to navigate it",
|
|
120
|
+
run: () => {
|
|
121
|
+
if (focused()) exitDock();else enterDock();
|
|
122
|
+
}
|
|
123
|
+
}],
|
|
124
|
+
bindings: [{
|
|
125
|
+
key: "<leader>v",
|
|
126
|
+
cmd: "agent-dock.toggle",
|
|
127
|
+
desc: "Navigate subagents"
|
|
128
|
+
}]
|
|
135
129
|
});
|
|
136
130
|
};
|
|
137
|
-
|
|
138
|
-
const plugin: TuiPluginModule & { id: string } = {
|
|
131
|
+
const plugin = {
|
|
139
132
|
id: PLUGIN_ID,
|
|
140
|
-
tui
|
|
133
|
+
tui
|
|
141
134
|
};
|
|
142
|
-
|
|
143
135
|
export default plugin;
|
package/dist/ports.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
// SDK boundary — the only place that knows about @opencode-ai/sdk shapes.
|
|
2
2
|
// Projects SDK Session/SessionStatus into the neutral domain Subagent.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
import type { Subagent, SubagentStatus } from "./domain.js";
|
|
6
|
-
|
|
7
|
-
export function statusFromSessionStatus(status: SessionStatus | undefined): SubagentStatus {
|
|
4
|
+
export function statusFromSessionStatus(status) {
|
|
8
5
|
switch (status?.type) {
|
|
9
6
|
case "busy":
|
|
10
7
|
case "retry":
|
|
@@ -15,20 +12,18 @@ export function statusFromSessionStatus(status: SessionStatus | undefined): Suba
|
|
|
15
12
|
return "done";
|
|
16
13
|
}
|
|
17
14
|
}
|
|
18
|
-
|
|
19
|
-
function totalTokens(tokens: Session["tokens"] | undefined): number | undefined {
|
|
15
|
+
function totalTokens(tokens) {
|
|
20
16
|
if (!tokens) return undefined;
|
|
21
17
|
const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write;
|
|
22
18
|
return total > 0 ? total : undefined;
|
|
23
19
|
}
|
|
24
|
-
|
|
25
|
-
export function projectSubagent(session: Session, status: SessionStatus | undefined): Subagent {
|
|
20
|
+
export function projectSubagent(session, status) {
|
|
26
21
|
return {
|
|
27
22
|
id: session.id,
|
|
28
23
|
title: session.title || session.agent || "subagent",
|
|
29
24
|
status: statusFromSessionStatus(status),
|
|
30
25
|
startedAt: session.time.created,
|
|
31
26
|
model: session.model?.id,
|
|
32
|
-
tokens: totalTokens(session.tokens)
|
|
27
|
+
tokens: totalTokens(session.tokens)
|
|
33
28
|
};
|
|
34
29
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { effect as _$effect } from "@opentui/solid";
|
|
2
|
+
import { insertNode as _$insertNode } from "@opentui/solid";
|
|
3
|
+
import { memo as _$memo } from "@opentui/solid";
|
|
4
|
+
import { insert as _$insert } from "@opentui/solid";
|
|
5
|
+
import { createComponent as _$createComponent } from "@opentui/solid";
|
|
6
|
+
import { setProp as _$setProp } from "@opentui/solid";
|
|
7
|
+
import { createElement as _$createElement } from "@opentui/solid";
|
|
8
|
+
/** @jsxImportSource @opentui/solid */
|
|
9
|
+
// View — the persistent bottom panel (app_bottom slot). Claude-Code-style:
|
|
10
|
+
// plain text rows under the prompt, no panel chrome. Stays visible in both the
|
|
11
|
+
// parent session (running subagents) and inside a subagent (the focused one,
|
|
12
|
+
// highlighted via `activeId`). When the dock is focused for inline navigation,
|
|
13
|
+
// the selected row gets a `▸` cursor.
|
|
14
|
+
//
|
|
15
|
+
// Reactivity: `selection`, `focused`, `activeId` are real Solid signals (owned
|
|
16
|
+
// by the composition root), so per-row selected/active checks are tracked
|
|
17
|
+
// directly — no signal-reading hacks. The rows memo additionally reads the
|
|
18
|
+
// `version` signal (bumped on every store change) so elapsed times stay fresh.
|
|
19
|
+
|
|
20
|
+
import { For, Show, createMemo, createSignal, onCleanup } from "solid-js";
|
|
21
|
+
import { toRow } from "../domain.js";
|
|
22
|
+
import { LIMITS } from "../constants.js";
|
|
23
|
+
export function Dock(props) {
|
|
24
|
+
// The store emits on every refresh (data change + elapsed tick), which bumps
|
|
25
|
+
// `version` and re-runs the rows memo. No separate timer needed here.
|
|
26
|
+
const [version, setVersion] = createSignal(0);
|
|
27
|
+
const unsubscribe = props.store.onChange(() => setVersion(value => value + 1));
|
|
28
|
+
onCleanup(unsubscribe);
|
|
29
|
+
const rows = createMemo(() => {
|
|
30
|
+
version();
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
return props.store.snapshot().map(subagent => toRow(subagent, now, LIMITS.labelMax));
|
|
33
|
+
});
|
|
34
|
+
return _$createComponent(Show, {
|
|
35
|
+
get when() {
|
|
36
|
+
return rows().length > 0;
|
|
37
|
+
},
|
|
38
|
+
get children() {
|
|
39
|
+
var _el$ = _$createElement("box");
|
|
40
|
+
_$setProp(_el$, "flexDirection", "column");
|
|
41
|
+
_$setProp(_el$, "paddingLeft", 2);
|
|
42
|
+
_$setProp(_el$, "paddingRight", 2);
|
|
43
|
+
_$insert(_el$, _$createComponent(For, {
|
|
44
|
+
get each() {
|
|
45
|
+
return rows();
|
|
46
|
+
},
|
|
47
|
+
children: (row, index) => {
|
|
48
|
+
const selected = () => props.focused() && index() === props.selection();
|
|
49
|
+
const active = () => props.activeId() === row.subagent.id;
|
|
50
|
+
const hot = () => selected() || active();
|
|
51
|
+
return (() => {
|
|
52
|
+
var _el$2 = _$createElement("text"),
|
|
53
|
+
_el$3 = _$createElement("span"),
|
|
54
|
+
_el$4 = _$createElement("span"),
|
|
55
|
+
_el$5 = _$createElement("span");
|
|
56
|
+
_$insertNode(_el$2, _el$3);
|
|
57
|
+
_$insertNode(_el$2, _el$4);
|
|
58
|
+
_$insertNode(_el$2, _el$5);
|
|
59
|
+
_$insert(_el$3, () => selected() ? "\u25b8 " : " ");
|
|
60
|
+
_$insert(_el$4, () => row.glyph);
|
|
61
|
+
_$insert(_el$5, () => ` ${row.label}${row.meta ? " " + row.meta : ""}`);
|
|
62
|
+
_$effect(_p$ => {
|
|
63
|
+
var _v$ = {
|
|
64
|
+
fg: hot() ? props.colors.accent : props.colors.muted
|
|
65
|
+
},
|
|
66
|
+
_v$2 = {
|
|
67
|
+
fg: row.subagent.status === "running" ? props.colors.accent : props.colors.muted
|
|
68
|
+
},
|
|
69
|
+
_v$3 = {
|
|
70
|
+
fg: hot() ? props.colors.text : props.colors.muted
|
|
71
|
+
};
|
|
72
|
+
_v$ !== _p$.e && (_p$.e = _$setProp(_el$3, "style", _v$, _p$.e));
|
|
73
|
+
_v$2 !== _p$.t && (_p$.t = _$setProp(_el$4, "style", _v$2, _p$.t));
|
|
74
|
+
_v$3 !== _p$.a && (_p$.a = _$setProp(_el$5, "style", _v$3, _p$.a));
|
|
75
|
+
return _p$;
|
|
76
|
+
}, {
|
|
77
|
+
e: undefined,
|
|
78
|
+
t: undefined,
|
|
79
|
+
a: undefined
|
|
80
|
+
});
|
|
81
|
+
return _el$2;
|
|
82
|
+
})();
|
|
83
|
+
}
|
|
84
|
+
}));
|
|
85
|
+
return _el$;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
package/package.json
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-agent-dock",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "OpenCode TUI plugin — a Claude-Code-style bottom panel
|
|
3
|
+
"version": "1.0.1",
|
|
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": {
|
|
8
|
-
"import": "./
|
|
8
|
+
"import": "./dist/index.js"
|
|
9
9
|
}
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
|
-
"
|
|
12
|
+
"dist",
|
|
13
13
|
"README.md",
|
|
14
14
|
"LICENSE"
|
|
15
15
|
],
|
|
16
16
|
"scripts": {
|
|
17
|
+
"build": "node scripts/build.mjs",
|
|
18
|
+
"prepack": "npm run build",
|
|
19
|
+
"pretest": "npm run build",
|
|
20
|
+
"test": "vitest run",
|
|
17
21
|
"typecheck": "tsc --noEmit",
|
|
18
22
|
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
|
|
19
23
|
"format:check": "prettier --check \"src/**/*.{ts,tsx}\""
|
|
@@ -37,30 +41,30 @@
|
|
|
37
41
|
"bugs": {
|
|
38
42
|
"url": "https://github.com/arttttt/opencode-agent-dock/issues"
|
|
39
43
|
},
|
|
40
|
-
"dependencies": {
|
|
41
|
-
"@opencode-ai/plugin": "^1.18.4",
|
|
42
|
-
"@opencode-ai/sdk": "^1.18.4"
|
|
43
|
-
},
|
|
44
44
|
"peerDependencies": {
|
|
45
|
-
"@
|
|
46
|
-
"@
|
|
45
|
+
"@opencode-ai/plugin": ">=1.18.0",
|
|
46
|
+
"@opencode-ai/sdk": ">=1.18.0",
|
|
47
47
|
"@opentui/solid": ">=0.4.5"
|
|
48
48
|
},
|
|
49
49
|
"peerDependenciesMeta": {
|
|
50
|
-
"@opentui/
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
"@opentui/solid": {
|
|
51
|
+
"optional": true
|
|
52
|
+
}
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
|
+
"@babel/core": "7.28.0",
|
|
56
|
+
"@babel/preset-typescript": "7.27.1",
|
|
55
57
|
"@opencode-ai/plugin": "^1.18.4",
|
|
56
58
|
"@opencode-ai/sdk": "^1.18.4",
|
|
57
59
|
"@opentui/core": ">=0.4.5",
|
|
58
|
-
"@opentui/keymap": ">=0.4.5",
|
|
59
60
|
"@opentui/solid": ">=0.4.5",
|
|
60
61
|
"@types/bun": "latest",
|
|
62
|
+
"babel-preset-solid": "1.9.12",
|
|
63
|
+
"bun": "^1.4.4",
|
|
61
64
|
"prettier": "^3.8.1",
|
|
62
65
|
"solid-js": "^1.9.0",
|
|
63
|
-
"typescript": "^5.7.0"
|
|
66
|
+
"typescript": "^5.7.0",
|
|
67
|
+
"vitest": "^2.1.0"
|
|
64
68
|
},
|
|
65
69
|
"engines": {
|
|
66
70
|
"opencode": "^1.18.0"
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
// Adapter — Logger over the opencode client app log endpoint.
|
|
2
|
-
|
|
3
|
-
import type { OpencodeClient } from "@opencode-ai/sdk/v2";
|
|
4
|
-
import type { Logger } from "../ports.js";
|
|
5
|
-
import { SERVICE } from "../constants.js";
|
|
6
|
-
|
|
7
|
-
export function createLogger(client: OpencodeClient): Logger {
|
|
8
|
-
const write = (level: "info" | "warn" | "error", message: string): void => {
|
|
9
|
-
client.app
|
|
10
|
-
.log({ service: SERVICE, level, message })
|
|
11
|
-
.catch(() => {});
|
|
12
|
-
};
|
|
13
|
-
return {
|
|
14
|
-
info: (m) => write("info", m),
|
|
15
|
-
warn: (m) => write("warn", m),
|
|
16
|
-
error: (m) => write("error", m),
|
|
17
|
-
};
|
|
18
|
-
}
|
package/src/ports.ts
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
// Ports — abstractions the application layer depends on.
|
|
2
|
-
// (Dependency Inversion: high-level policy depends on these contracts, not on
|
|
3
|
-
// concrete IO or the opencode SDK. Adapters in src/adapters implement them.)
|
|
4
|
-
|
|
5
|
-
import type { Subagent } from "./domain.js";
|
|
6
|
-
|
|
7
|
-
/** Source of the current session's subagents. */
|
|
8
|
-
export interface SubagentStore {
|
|
9
|
-
/** The session whose subagents we render (the active session route). */
|
|
10
|
-
readonly parentSessionId: string | undefined;
|
|
11
|
-
/** When the active route is a subagent session — its id, so the panel can
|
|
12
|
-
* highlight the one currently in focus. Undefined in the parent session. */
|
|
13
|
-
readonly activeId: string | undefined;
|
|
14
|
-
/** Snapshot of subagents, already sorted and capped for display. */
|
|
15
|
-
snapshot(): Subagent[];
|
|
16
|
-
/** Subscribe to store changes; returns an unsubscribe function. */
|
|
17
|
-
onChange(handler: () => void): () => void;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface Logger {
|
|
21
|
-
info(message: string): void;
|
|
22
|
-
warn(message: string): void;
|
|
23
|
-
error(message: string): void;
|
|
24
|
-
}
|
package/src/view/Dock.tsx
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
/** @jsxImportSource @opentui/solid */
|
|
2
|
-
// View — the persistent bottom panel (app_bottom slot). Claude-Code-style:
|
|
3
|
-
// plain text rows under the prompt, no panel chrome. Stays visible in both the
|
|
4
|
-
// parent session (running subagents) and inside a subagent (the focused one,
|
|
5
|
-
// highlighted via `activeId`). When the dock is focused for inline navigation,
|
|
6
|
-
// the selected row gets a `▸` cursor.
|
|
7
|
-
//
|
|
8
|
-
// Reactivity: `selection`, `focused`, `activeId` are real Solid signals (owned
|
|
9
|
-
// by the composition root), so per-row selected/active checks are tracked
|
|
10
|
-
// directly — no signal-reading hacks. The rows memo additionally reads the
|
|
11
|
-
// `version` signal (bumped on every store change) so elapsed times stay fresh.
|
|
12
|
-
|
|
13
|
-
import { For, Show, createMemo, createSignal, onCleanup, type JSX } from "solid-js";
|
|
14
|
-
import type { RGBA } from "@opentui/core";
|
|
15
|
-
import type { SubagentStore } from "../ports.js";
|
|
16
|
-
import { toRow } from "../domain.js";
|
|
17
|
-
import { LIMITS } from "../constants.js";
|
|
18
|
-
|
|
19
|
-
export type DockColors = {
|
|
20
|
-
text: RGBA | string;
|
|
21
|
-
muted: RGBA | string;
|
|
22
|
-
accent: RGBA | string;
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
export type DockProps = {
|
|
26
|
-
store: SubagentStore;
|
|
27
|
-
selection: () => number;
|
|
28
|
-
focused: () => boolean;
|
|
29
|
-
activeId: () => string | undefined;
|
|
30
|
-
colors: DockColors;
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
export function Dock(props: DockProps): JSX.Element {
|
|
34
|
-
// The store emits on every refresh (data change + elapsed tick), which bumps
|
|
35
|
-
// `version` and re-runs the rows memo. No separate timer needed here.
|
|
36
|
-
const [version, setVersion] = createSignal(0);
|
|
37
|
-
const unsubscribe = props.store.onChange(() => setVersion((value) => value + 1));
|
|
38
|
-
onCleanup(unsubscribe);
|
|
39
|
-
|
|
40
|
-
const rows = createMemo(() => {
|
|
41
|
-
version();
|
|
42
|
-
const now = Date.now();
|
|
43
|
-
return props.store.snapshot().map((subagent) => toRow(subagent, now, LIMITS.labelMax));
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
return (
|
|
47
|
-
<Show when={rows().length > 0}>
|
|
48
|
-
<box flexDirection="column" paddingLeft={2} paddingRight={2}>
|
|
49
|
-
<For each={rows()}>
|
|
50
|
-
{(row, index) => {
|
|
51
|
-
const selected = () => props.focused() && index() === props.selection();
|
|
52
|
-
const active = () => props.activeId() === row.subagent.id;
|
|
53
|
-
const hot = () => selected() || active();
|
|
54
|
-
return (
|
|
55
|
-
<text>
|
|
56
|
-
<span style={{ fg: hot() ? props.colors.accent : props.colors.muted }}>
|
|
57
|
-
{selected() ? "\u25b8 " : " "}
|
|
58
|
-
</span>
|
|
59
|
-
<span style={{ fg: row.subagent.status === "running" ? props.colors.accent : props.colors.muted }}>
|
|
60
|
-
{row.glyph}
|
|
61
|
-
</span>
|
|
62
|
-
<span style={{ fg: hot() ? props.colors.text : props.colors.muted }}>
|
|
63
|
-
{` ${row.label}${row.meta ? " " + row.meta : ""}`}
|
|
64
|
-
</span>
|
|
65
|
-
</text>
|
|
66
|
-
);
|
|
67
|
-
}}
|
|
68
|
-
</For>
|
|
69
|
-
</box>
|
|
70
|
-
</Show>
|
|
71
|
-
);
|
|
72
|
-
}
|