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 CHANGED
@@ -1,102 +1,84 @@
1
1
  # opencode-agent-dock
2
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.
3
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
4
+ [![OpenCode Plugin](https://img.shields.io/badge/OpenCode-Plugin-green.svg)](https://opencode.ai)
5
+ [![npm version](https://img.shields.io/npm/v/opencode-agent-dock.svg)](https://www.npmjs.com/package/opencode-agent-dock)
6
+ [![GitHub repo](https://img.shields.io/badge/repo-arttttt%2Fopencode--agent--dock-181717?logo=github)](https://github.com/arttttt/opencode-agent-dock)
5
7
 
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.
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
- ▸ ● general-purpose Verify error-tile reactivity and guards 3m 25s
13
- ○ explore Audit round-3 test quality 45s
14
- ✓ librarian Lookup JWT docs
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
- ## Why
28
+ ## Features
18
29
 
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.
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 plugins are configured in `tui.json`. Add the plugin, then restart OpenCode:
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
- Local development:
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
- | `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
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
- 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.
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 runs on Bun and loads `.ts`/`.tsx` natively, so there is no build step
99
- for local file-plugin use.
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
+ }
@@ -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
- 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[]) : [];
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
- 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 => {
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
- const resolveCurrent = (): string | undefined => {
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({ sessionID: currentId });
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 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
-
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(): string | undefined {
76
+ get parentSessionId() {
112
77
  return resolveCurrent();
113
78
  },
114
- get activeId(): string | undefined {
79
+ get activeId() {
115
80
  return activeId;
116
81
  },
117
- snapshot(): Subagent[] {
82
+ snapshot() {
118
83
  return cache;
119
84
  },
120
- onChange(handler: () => void): () => void {
85
+ onChange(handler) {
121
86
  listeners.add(handler);
122
87
  return () => {
123
88
  listeners.delete(handler);
124
89
  };
125
- },
90
+ }
126
91
  };
127
92
  }
@@ -2,9 +2,8 @@
2
2
 
3
3
  export const PLUGIN_ID = "arttttt.agent-dock";
4
4
  export const SERVICE = "agent-dock";
5
-
6
5
  export const LIMITS = {
7
6
  labelMax: 48,
8
7
  maxRows: 50,
9
- pollMs: 300,
10
- } as const;
8
+ pollMs: 300
9
+ };
@@ -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: Record<SubagentStatus, string> = {
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
- export function toRow(subagent: Subagent, now: number, labelMax: number): DockRow {
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<string | undefined>(undefined);
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: (ctx) => {
45
+ app_bottom: ctx => {
56
46
  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
- },
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
- "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": {
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", { sessionID: target.id });
99
+ api.route.navigate("session", {
100
+ sessionID: target.id
101
+ });
108
102
  }
109
- ev.preventDefault();
110
- ev.stopPropagation();
103
+ event.preventDefault();
104
+ event.stopPropagation();
111
105
  break;
112
106
  }
113
- }
114
- },
115
- { priority: 1 },
116
- );
117
-
107
+ }
108
+ }, {
109
+ priority: 1
110
+ });
118
111
  api.keymap.registerLayer({
119
112
  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" }],
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
- 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 {
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.0",
4
- "description": "OpenCode TUI plugin — a Claude-Code-style bottom panel that groups the current session's subagents, with select / switch / open-session.",
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": "./src/index.tsx"
8
+ "import": "./dist/index.js"
9
9
  }
10
10
  },
11
11
  "files": [
12
- "src",
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
- "@opentui/core": ">=0.4.5",
46
- "@opentui/keymap": ">=0.4.5",
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/core": { "optional": true },
51
- "@opentui/keymap": { "optional": true },
52
- "@opentui/solid": { "optional": true }
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
- }