opencode-agent-dock 0.1.0 → 1.0.0

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