opencode-agent-dock 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/LICENSE +21 -0
- package/README.md +103 -0
- package/package.json +68 -0
- package/src/adapters/opencode-logger.ts +18 -0
- package/src/adapters/opencode-subagent-store.ts +127 -0
- package/src/constants.ts +10 -0
- package/src/domain.ts +87 -0
- package/src/index.tsx +143 -0
- package/src/ports.ts +24 -0
- package/src/sdk.ts +34 -0
- package/src/view/Dock.tsx +72 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Artem Bambalov
|
|
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,103 @@
|
|
|
1
|
+
# opencode-agent-dock
|
|
2
|
+
|
|
3
|
+
> Status: **skeleton / work-in-progress**. Architecture and wiring are in place;
|
|
4
|
+
> the verify-items below remain before it renders live subagents correctly.
|
|
5
|
+
|
|
6
|
+
An [OpenCode](https://opencode.ai) **TUI plugin** that adds a **Claude-Code-style
|
|
7
|
+
bottom panel** grouping the current session's **subagents** — with select,
|
|
8
|
+
switch, and open-session. Not in the right sidebar: rendered in the `app_bottom`
|
|
9
|
+
slot, which sits below the active route in the session view.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
▸ ● general-purpose Verify error-tile reactivity and guards 3m 25s
|
|
13
|
+
○ explore Audit round-3 test quality 45s
|
|
14
|
+
✓ librarian Lookup JWT docs
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Why
|
|
18
|
+
|
|
19
|
+
Subagents only surface inside the chat, and the chat scrolls away from where
|
|
20
|
+
they were spawned. The existing opencode subagent plugins put their list in the
|
|
21
|
+
**right sidebar** and are heavy. This plugin targets the **bottom** (like Claude
|
|
22
|
+
Code's agent panel) and stays as a single lean plugin.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
TUI plugins are configured in `tui.json`. Add the plugin, then restart OpenCode:
|
|
27
|
+
|
|
28
|
+
```jsonc
|
|
29
|
+
// ~/.config/opencode/tui.json
|
|
30
|
+
{
|
|
31
|
+
"plugin": ["opencode-agent-dock"]
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Local development:
|
|
36
|
+
|
|
37
|
+
```jsonc
|
|
38
|
+
{
|
|
39
|
+
"plugin": [["file:///absolute/path/to/opencode-agent-dock/src/index.tsx"]]
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Keybindings
|
|
44
|
+
|
|
45
|
+
| Key | Action |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| `alt+a` | Focus the dock |
|
|
48
|
+
| `k` / `j` | Previous / next subagent |
|
|
49
|
+
| `enter` | Open the selected subagent's session |
|
|
50
|
+
| `esc` | Leave the dock |
|
|
51
|
+
|
|
52
|
+
## Architecture
|
|
53
|
+
|
|
54
|
+
Clean Architecture, by analogy with
|
|
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.
|
|
58
|
+
|
|
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).
|
|
90
|
+
|
|
91
|
+
## Develop
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
npm install
|
|
95
|
+
npm run typecheck
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
OpenCode runs on Bun and loads `.ts`/`.tsx` natively, so there is no build step
|
|
99
|
+
for local file-plugin use.
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
MIT © Artem Bambalov
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-agent-dock",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenCode TUI plugin — a Claude-Code-style bottom panel that groups the current session's subagents, with select / switch / open-session.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
"./tui": {
|
|
8
|
+
"import": "./src/index.tsx"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"typecheck": "tsc --noEmit",
|
|
18
|
+
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
|
|
19
|
+
"format:check": "prettier --check \"src/**/*.{ts,tsx}\""
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"opencode",
|
|
23
|
+
"opencode-plugin",
|
|
24
|
+
"tui",
|
|
25
|
+
"subagent",
|
|
26
|
+
"agents",
|
|
27
|
+
"panel",
|
|
28
|
+
"dock"
|
|
29
|
+
],
|
|
30
|
+
"author": "Artem Bambalov <artem-bambalov@yandex.ru>",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/arttttt/opencode-agent-dock.git"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/arttttt/opencode-agent-dock#readme",
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/arttttt/opencode-agent-dock/issues"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@opencode-ai/plugin": "^1.18.4",
|
|
42
|
+
"@opencode-ai/sdk": "^1.18.4"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@opentui/core": ">=0.4.5",
|
|
46
|
+
"@opentui/keymap": ">=0.4.5",
|
|
47
|
+
"@opentui/solid": ">=0.4.5"
|
|
48
|
+
},
|
|
49
|
+
"peerDependenciesMeta": {
|
|
50
|
+
"@opentui/core": { "optional": true },
|
|
51
|
+
"@opentui/keymap": { "optional": true },
|
|
52
|
+
"@opentui/solid": { "optional": true }
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@opencode-ai/plugin": "^1.18.4",
|
|
56
|
+
"@opencode-ai/sdk": "^1.18.4",
|
|
57
|
+
"@opentui/core": ">=0.4.5",
|
|
58
|
+
"@opentui/keymap": ">=0.4.5",
|
|
59
|
+
"@opentui/solid": ">=0.4.5",
|
|
60
|
+
"@types/bun": "latest",
|
|
61
|
+
"prettier": "^3.8.1",
|
|
62
|
+
"solid-js": "^1.9.0",
|
|
63
|
+
"typescript": "^5.7.0"
|
|
64
|
+
},
|
|
65
|
+
"engines": {
|
|
66
|
+
"opencode": "^1.18.0"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Adapter — SubagentStore backed by the opencode TUI state + client.
|
|
2
|
+
//
|
|
3
|
+
// Two regimes:
|
|
4
|
+
// - Parent session (route has no parentID): list its RUNNING children — the
|
|
5
|
+
// at-a-glance roster for the inline dock navigation.
|
|
6
|
+
// - Subagent session (route has parentID): keep the panel up with the ONE
|
|
7
|
+
// subagent currently in focus (so navigating into a subagent does not hide
|
|
8
|
+
// the panel; native left/right between siblings updates it). `activeId`
|
|
9
|
+
// exposes that focused id for highlighting.
|
|
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
|
+
import { sortSubagents } from "../domain.js";
|
|
15
|
+
import { projectSubagent } from "../sdk.js";
|
|
16
|
+
import type { Logger, SubagentStore } from "../ports.js";
|
|
17
|
+
import { LIMITS } from "../constants.js";
|
|
18
|
+
|
|
19
|
+
type ChildrenClient = {
|
|
20
|
+
session: {
|
|
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[]) : [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isArchived(session: Session): boolean {
|
|
35
|
+
const archived = session.time.archived;
|
|
36
|
+
return typeof archived === "number" && archived > 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createSubagentStore(api: TuiPluginApi, log: Logger): SubagentStore {
|
|
40
|
+
const client = api.client as unknown as ChildrenClient;
|
|
41
|
+
const listeners = new Set<() => void>();
|
|
42
|
+
let cache: Subagent[] = [];
|
|
43
|
+
let activeId: string | undefined;
|
|
44
|
+
|
|
45
|
+
const emit = (): void => {
|
|
46
|
+
for (const handler of listeners) handler();
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const resolveCurrent = (): string | undefined => {
|
|
50
|
+
const current = api.route.current as { name: string; params?: { sessionID?: string } };
|
|
51
|
+
return current.name === "session" ? current.params?.sessionID : undefined;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const refresh = async (): Promise<void> => {
|
|
55
|
+
const currentId = resolveCurrent();
|
|
56
|
+
if (!currentId) {
|
|
57
|
+
if (cache.length !== 0 || activeId !== undefined) {
|
|
58
|
+
cache = [];
|
|
59
|
+
activeId = undefined;
|
|
60
|
+
emit();
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const session = api.state.session.get(currentId);
|
|
65
|
+
if (!session) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
if (session.parentID) {
|
|
70
|
+
// Inside a subagent: pin the panel to the one in focus.
|
|
71
|
+
cache = [projectSubagent(session, api.state.session.status(currentId))];
|
|
72
|
+
activeId = currentId;
|
|
73
|
+
} else {
|
|
74
|
+
// Parent session: list running children.
|
|
75
|
+
const result = await client.session.children({ sessionID: currentId });
|
|
76
|
+
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);
|
|
83
|
+
activeId = undefined;
|
|
84
|
+
}
|
|
85
|
+
emit();
|
|
86
|
+
} catch (error) {
|
|
87
|
+
log.error(`dock refresh failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
void refresh();
|
|
92
|
+
const interval = setInterval(() => void refresh(), LIMITS.pollMs);
|
|
93
|
+
|
|
94
|
+
const subscribe = api.event.on as unknown as (
|
|
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
|
+
|
|
105
|
+
api.lifecycle.onDispose(() => {
|
|
106
|
+
clearInterval(interval);
|
|
107
|
+
for (const unsub of unsubs) unsub();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
get parentSessionId(): string | undefined {
|
|
112
|
+
return resolveCurrent();
|
|
113
|
+
},
|
|
114
|
+
get activeId(): string | undefined {
|
|
115
|
+
return activeId;
|
|
116
|
+
},
|
|
117
|
+
snapshot(): Subagent[] {
|
|
118
|
+
return cache;
|
|
119
|
+
},
|
|
120
|
+
onChange(handler: () => void): () => void {
|
|
121
|
+
listeners.add(handler);
|
|
122
|
+
return () => {
|
|
123
|
+
listeners.delete(handler);
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
package/src/constants.ts
ADDED
package/src/domain.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Domain — pure policy. No IO, no SDK, no Solid. Fully unit-testable.
|
|
2
|
+
|
|
3
|
+
export type SubagentStatus = "running" | "idle" | "done" | "error";
|
|
4
|
+
|
|
5
|
+
/** 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
|
+
|
|
15
|
+
/** 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
|
+
|
|
23
|
+
const STATUS_GLYPH: Record<SubagentStatus, string> = {
|
|
24
|
+
running: "●",
|
|
25
|
+
idle: "○",
|
|
26
|
+
done: "✓",
|
|
27
|
+
error: "✗",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const STATUS_RANK: Record<SubagentStatus, number> = {
|
|
31
|
+
running: 0,
|
|
32
|
+
idle: 1,
|
|
33
|
+
done: 2,
|
|
34
|
+
error: 3,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function sortSubagents(list: readonly Subagent[]): Subagent[] {
|
|
38
|
+
return [...list].sort((a, b) => {
|
|
39
|
+
const r = STATUS_RANK[a.status] - STATUS_RANK[b.status];
|
|
40
|
+
return r !== 0 ? r : b.startedAt - a.startedAt;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function clampSelection(index: number, length: number): number {
|
|
45
|
+
if (length <= 0) return 0;
|
|
46
|
+
if (index < 0) return 0;
|
|
47
|
+
if (index >= length) return length - 1;
|
|
48
|
+
return index;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function formatElapsed(startedAt: number, now: number = Date.now()): string {
|
|
52
|
+
const seconds = Math.max(0, Math.floor((now - startedAt) / 1000));
|
|
53
|
+
if (seconds < 60) return `${seconds}s`;
|
|
54
|
+
const minutes = Math.floor(seconds / 60);
|
|
55
|
+
const remSeconds = seconds % 60;
|
|
56
|
+
if (minutes < 60) return `${minutes}m ${String(remSeconds).padStart(2, "0")}s`;
|
|
57
|
+
const hours = Math.floor(minutes / 60);
|
|
58
|
+
return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function formatTokens(tokens: number | undefined): string {
|
|
62
|
+
if (tokens === undefined || tokens <= 0) return "";
|
|
63
|
+
if (tokens < 1000) return `${tokens}`;
|
|
64
|
+
return `${(tokens / 1000).toFixed(1)}k`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function truncate(text: string, max: number): string {
|
|
68
|
+
const trimmed = text.trim();
|
|
69
|
+
if (trimmed.length <= max) return trimmed;
|
|
70
|
+
return trimmed.slice(0, Math.max(0, max - 1)).trimEnd() + "…";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function isTerminal(status: SubagentStatus): boolean {
|
|
74
|
+
return status === "done" || status === "error";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function toRow(subagent: Subagent, now: number, labelMax: number): DockRow {
|
|
78
|
+
const meta: string[] = [];
|
|
79
|
+
if (!isTerminal(subagent.status)) meta.push(formatElapsed(subagent.startedAt, now));
|
|
80
|
+
if (subagent.tokens !== undefined) meta.push(formatTokens(subagent.tokens));
|
|
81
|
+
return {
|
|
82
|
+
subagent,
|
|
83
|
+
glyph: STATUS_GLYPH[subagent.status],
|
|
84
|
+
label: truncate(subagent.title || subagent.id, labelMax),
|
|
85
|
+
meta: meta.join(" \u00b7 "),
|
|
86
|
+
};
|
|
87
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
// Composition root.
|
|
3
|
+
//
|
|
4
|
+
// Persistent bottom panel (app_bottom slot) of running subagents + inline arrow
|
|
5
|
+
// navigation. Why intercept instead of a dialog: the focused prompt textarea
|
|
6
|
+
// captures arrow keys, so a plugin can't navigate an inline panel via normal
|
|
7
|
+
// bindings. `api.keymap.intercept("key", ..., { priority })` runs BEFORE the
|
|
8
|
+
// textarea (same mechanism opencode's util/selection.ts uses for copy-on-select),
|
|
9
|
+
// so while the dock is focused we consume up/down/return + stopPropagation — the
|
|
10
|
+
// prompt never sees them. This is the "lock the input, navigate the panel"
|
|
11
|
+
// behavior. Escape is intentionally NOT intercepted (stays native = cancel).
|
|
12
|
+
//
|
|
13
|
+
// Toggle: `<leader>v` (ctrl+x then v) or `/subagents` — ctrl+x itself is left
|
|
14
|
+
// untouched (it is opencode's leader key).
|
|
15
|
+
|
|
16
|
+
import type { TuiPlugin, TuiPluginModule, TuiSlotPlugin } from "@opencode-ai/plugin/tui";
|
|
17
|
+
import { createSignal } from "solid-js";
|
|
18
|
+
import { PLUGIN_ID } from "./constants.js";
|
|
19
|
+
import { clampSelection } from "./domain.js";
|
|
20
|
+
import { createSubagentStore } from "./adapters/opencode-subagent-store.js";
|
|
21
|
+
import { createLogger } from "./adapters/opencode-logger.js";
|
|
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) => {
|
|
32
|
+
const log = createLogger(api.client);
|
|
33
|
+
const store = createSubagentStore(api, log);
|
|
34
|
+
log.info("agent-dock initialized");
|
|
35
|
+
|
|
36
|
+
const [selection, setSelection] = createSignal(0);
|
|
37
|
+
const [focused, setFocused] = createSignal(false);
|
|
38
|
+
const [activeId, setActiveId] = createSignal<string | undefined>(undefined);
|
|
39
|
+
|
|
40
|
+
// Mirror the store's active id + keep selection in range as rows change; leave
|
|
41
|
+
// the dock if the roster empties.
|
|
42
|
+
store.onChange(() => {
|
|
43
|
+
setActiveId(store.activeId);
|
|
44
|
+
const rows = store.snapshot();
|
|
45
|
+
if (rows.length === 0) {
|
|
46
|
+
if (focused()) setFocused(false);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const clamped = clampSelection(selection(), rows.length);
|
|
50
|
+
if (clamped !== selection()) setSelection(clamped);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const slot: TuiSlotPlugin = {
|
|
54
|
+
slots: {
|
|
55
|
+
app_bottom: (ctx) => {
|
|
56
|
+
const tone = ctx.theme.current;
|
|
57
|
+
return (
|
|
58
|
+
<Dock
|
|
59
|
+
store={store}
|
|
60
|
+
selection={selection}
|
|
61
|
+
focused={focused}
|
|
62
|
+
activeId={activeId}
|
|
63
|
+
colors={{ text: tone.text, muted: tone.textMuted, accent: tone.primary }}
|
|
64
|
+
/>
|
|
65
|
+
);
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
api.slots.register(slot);
|
|
70
|
+
|
|
71
|
+
const enterDock = () => {
|
|
72
|
+
if (store.snapshot().length === 0) return;
|
|
73
|
+
setSelection(0);
|
|
74
|
+
setFocused(true);
|
|
75
|
+
};
|
|
76
|
+
const exitDock = () => {
|
|
77
|
+
setFocused(false);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Inline navigation: intercept arrows/enter while the dock is focused.
|
|
81
|
+
api.keymap.intercept(
|
|
82
|
+
"key",
|
|
83
|
+
({ event }) => {
|
|
84
|
+
if (!focused()) return;
|
|
85
|
+
const ev = event as InterceptEvent;
|
|
86
|
+
const rows = store.snapshot();
|
|
87
|
+
if (rows.length === 0) {
|
|
88
|
+
exitDock();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
switch (ev.name) {
|
|
92
|
+
case "up":
|
|
93
|
+
if (selection() <= 0) exitDock();
|
|
94
|
+
else setSelection((current) => current - 1);
|
|
95
|
+
ev.preventDefault();
|
|
96
|
+
ev.stopPropagation();
|
|
97
|
+
break;
|
|
98
|
+
case "down":
|
|
99
|
+
setSelection((current) => clampSelection(current + 1, rows.length));
|
|
100
|
+
ev.preventDefault();
|
|
101
|
+
ev.stopPropagation();
|
|
102
|
+
break;
|
|
103
|
+
case "return": {
|
|
104
|
+
const target = rows[clampSelection(selection(), rows.length)];
|
|
105
|
+
if (target) {
|
|
106
|
+
exitDock();
|
|
107
|
+
api.route.navigate("session", { sessionID: target.id });
|
|
108
|
+
}
|
|
109
|
+
ev.preventDefault();
|
|
110
|
+
ev.stopPropagation();
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
{ priority: 1 },
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
api.keymap.registerLayer({
|
|
119
|
+
mode: "base",
|
|
120
|
+
commands: [
|
|
121
|
+
{
|
|
122
|
+
name: "agent-dock.toggle",
|
|
123
|
+
title: "Subagents",
|
|
124
|
+
category: "Plugin",
|
|
125
|
+
namespace: "palette",
|
|
126
|
+
slashName: "subagents",
|
|
127
|
+
desc: "Focus the subagent dock to navigate it",
|
|
128
|
+
run: () => {
|
|
129
|
+
if (focused()) exitDock();
|
|
130
|
+
else enterDock();
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
bindings: [{ key: "<leader>v", cmd: "agent-dock.toggle", desc: "Navigate subagents" }],
|
|
135
|
+
});
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const plugin: TuiPluginModule & { id: string } = {
|
|
139
|
+
id: PLUGIN_ID,
|
|
140
|
+
tui,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export default plugin;
|
package/src/ports.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
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/sdk.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// SDK boundary — the only place that knows about @opencode-ai/sdk shapes.
|
|
2
|
+
// Projects SDK Session/SessionStatus into the neutral domain Subagent.
|
|
3
|
+
|
|
4
|
+
import type { Session, SessionStatus } from "@opencode-ai/sdk/v2";
|
|
5
|
+
import type { Subagent, SubagentStatus } from "./domain.js";
|
|
6
|
+
|
|
7
|
+
export function statusFromSessionStatus(status: SessionStatus | undefined): SubagentStatus {
|
|
8
|
+
switch (status?.type) {
|
|
9
|
+
case "busy":
|
|
10
|
+
case "retry":
|
|
11
|
+
return "running";
|
|
12
|
+
case "idle":
|
|
13
|
+
return "idle";
|
|
14
|
+
default:
|
|
15
|
+
return "done";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function totalTokens(tokens: Session["tokens"] | undefined): number | undefined {
|
|
20
|
+
if (!tokens) return undefined;
|
|
21
|
+
const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write;
|
|
22
|
+
return total > 0 ? total : undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function projectSubagent(session: Session, status: SessionStatus | undefined): Subagent {
|
|
26
|
+
return {
|
|
27
|
+
id: session.id,
|
|
28
|
+
title: session.title || session.agent || "subagent",
|
|
29
|
+
status: statusFromSessionStatus(status),
|
|
30
|
+
startedAt: session.time.created,
|
|
31
|
+
model: session.model?.id,
|
|
32
|
+
tokens: totalTokens(session.tokens),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
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
|
+
}
|