@op1/threads 0.1.6 → 0.1.8
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 +68 -6
- package/package.json +13 -5
- package/src/activity-model.ts +152 -0
- package/src/activity-picker.tsx +175 -0
- package/src/activity-rail.ts +135 -0
- package/src/activity-theme.ts +49 -0
- package/src/activity.ts +781 -0
- package/src/threads.ts +1 -1
- package/tui.ts +64 -32
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @op1/threads
|
|
2
2
|
|
|
3
|
-
Managed top-level worker sessions for OpenCode 2.0.
|
|
3
|
+
Managed top-level worker sessions for OpenCode 2.0.7. The server entrypoint is `index.ts`; the terminal entrypoint is `tui.ts`.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -58,7 +58,7 @@ All fields are strings except `evidence`, which is an array of strings. Verdicts
|
|
|
58
58
|
|
|
59
59
|
`directory` must exist and be absolute. Without `agent`, the worker inherits the coordinator's active agent and resolved model, with agent permissions followed by session permissions.
|
|
60
60
|
|
|
61
|
-
Set `agent` to use a configured profile, such as `agent: "vera-core"` for a VERA workstream or `agent: "vera-auditor-readonly"` for an independent review. The plugin resolves the profile in the assigned directory. OpenCode supplies its system prompt and step limit. The profile's model and variant take precedence; a profile without a model inherits the coordinator's resolved model. Explicit selection supports `primary`, `all`, and `subagent` profiles on OpenCode 2.0.
|
|
61
|
+
Set `agent` to use a configured profile, such as `agent: "vera-core"` for a VERA workstream or `agent: "vera-auditor-readonly"` for an independent review. The plugin resolves the profile in the assigned directory. OpenCode supplies its system prompt and step limit. The profile's model and variant take precedence; a profile without a model inherits the coordinator's resolved model. Explicit selection supports `primary`, `all`, and `subagent` profiles on OpenCode 2.0.7.
|
|
62
62
|
|
|
63
63
|
Explicit selection requires the caller's ordered agent and session rules to allow `subagent` for that exact agent ID. A matching `deny` or `ask` rejects the request before creation. OpenCode's plugin API cannot request approval for an input-dependent agent ID.
|
|
64
64
|
|
|
@@ -90,9 +90,13 @@ Plugin option `maxWorkers` defaults to 4 and accepts integers from 1 through 32.
|
|
|
90
90
|
|
|
91
91
|
## Terminal and RPC
|
|
92
92
|
|
|
93
|
-
The terminal synchronizes workers before opening native tabs without changing focus. A TUI memory index survives plugin reloads and respects manually closed tabs. `/threads` explicitly reopens workers for open coordinator tabs. A new TUI recovers workers from durable storage. Closing the TUI does not interrupt workers.
|
|
93
|
+
The terminal synchronizes workers before opening native tabs without changing focus. A TUI memory index survives plugin reloads and respects manually closed native tabs. Conversations closed through Activity stay dismissed across restarts. `/threads` explicitly reopens workers for open coordinator tabs, including workers dismissed through Activity. A new TUI recovers the other workers from durable storage. Closing the TUI does not interrupt workers.
|
|
94
94
|
|
|
95
|
-
|
|
95
|
+
Activity shows Main and Worker roles as row subtitles. It omits a leading `[Main] ` or `[Worker] ` from known managed titles, including closed history rows and prefixes reapplied by older clients. Managed identity comes from the worker RPC, including lookups for loaded history conversations whose workers are on older pages. Unrelated prefixed titles remain unchanged.
|
|
96
|
+
|
|
97
|
+
For open managed tabs, the TUI also removes one legacy prefix from the fresh saved title. The remainder stays unchanged, and empty remainders are not saved. A durable client-storage marker makes this saved-title cleanup one-shot per session. Later intentional prefixed renames remain in saved history, while Activity omits the redundant role label from its display.
|
|
98
|
+
|
|
99
|
+
When upgrading, restart all existing TUIs. Older 0.1.7 clients still write role prefixes and can reapply them after cleanup.
|
|
96
100
|
|
|
97
101
|
Workers with `PASS` or `PASS WITH NOTES` reports hide automatically once idle. This also applies to reports saved before upgrading. Unreported workers and `FAIL` or `INCONCLUSIVE` reports stay visible. The selected tab, running workers, and tabs needing input stay open until they are inactive.
|
|
98
102
|
|
|
@@ -100,7 +104,7 @@ The coordinator can call `threads_hide` when a worker is no longer needed. Hidin
|
|
|
100
104
|
|
|
101
105
|
Reports reach the coordinator through silent synthetic messages. They remain available through `threads_list` and the session history without adding a notification row to the conversation. Older report notification rows remain in native history.
|
|
102
106
|
|
|
103
|
-
|
|
107
|
+
With Activity disabled or unable to mount, open native root-session tabs are grouped by OpenCode project ID, including sessions not managed by this plugin. This includes horizontal, narrow, and unsupported-version fallback. Projects follow their first appearance in the current tab order. Worktrees with the same project ID stay together.
|
|
104
108
|
|
|
105
109
|
Within each project, running sessions and sessions waiting for input come before idle sessions. Tabs with the same priority keep their relative order. Selecting an idle tab does not reorder it. Native tab order is shared between terminals in the same directory, so sorting by each terminal's selection would make them repeatedly undo each other's moves. Idle is a display priority, not a completion verdict. Each tab with unloaded project metadata stays in its own group until that metadata becomes available. Reconciliation moves only out-of-order tabs, without changing focus or closing and reopening them to reorder. Native tabs do not support divider rows.
|
|
106
110
|
|
|
@@ -113,12 +117,70 @@ result: { workers: WorkerView[] }
|
|
|
113
117
|
|
|
114
118
|
The input accepts at most 100 coordinator IDs. Raw HTTP RPC requests wrap the input as `{ "input": { "coordinatorIDs": ["ses_..."] } }`. Each method declares `errors: {}` and the RPC declares `events: {}`. The TUI subscribes to native session events and reconciles at most one snapshot at a time, with a three-second missed-event refresh.
|
|
115
119
|
|
|
120
|
+
## Activity sidebar
|
|
121
|
+
|
|
122
|
+
Activity replaces the contents of the native left vertical tab rail on compatible OpenCode V2 layouts. Native tabs remain enabled and retain their layout reservation, resize handle, activity state, and session navigation. The prompt and transcript remain native OpenCode UI.
|
|
123
|
+
|
|
124
|
+
Subtitles start with the project name or folder basename, followed by a distinct location or worktree basename when needed. Main and Worker labels appear only for known managed relationships; ordinary history rows show project and location without a role label.
|
|
125
|
+
|
|
126
|
+
Rows are grouped in this order:
|
|
127
|
+
|
|
128
|
+
- **Priority**: permission or form attention, then running work, then unread results or errors.
|
|
129
|
+
- **Pinned**: pinned conversations without priority activity.
|
|
130
|
+
- **Today**, **Yesterday**, recent weekdays, and older calendar dates, using the local timezone.
|
|
131
|
+
|
|
132
|
+
Divider lines separate sections. Click a heading to collapse or expand its conversations; the arrow and count show its current state. Collapsed sections stay collapsed across reloads and restarts. This changes sidebar visibility without closing tabs, and `/activities` can still find conversations in collapsed sections.
|
|
133
|
+
|
|
134
|
+
A session appears once. Priority takes precedence over pinning. Idle does not mean that a task passed. Open-tab status comes from the native tab API; closed history uses available native running and input-request state, without inventing a success status. Date groups use the last native execution's `time.idle`, falling back to `time.updated` for conversations without an execution. Title cleanup therefore does not move an older executed conversation into Today.
|
|
135
|
+
|
|
136
|
+
Running rows use OpenCode's registered animated dot spinner. Input requests take precedence over the spinner, and it is removed when the session becomes idle.
|
|
137
|
+
|
|
138
|
+
Colors follow the active theme: selected conversations and date headings use its accent, running indicators use its information color, and input requests use its warning color. Unread errors use the error color. Worker headings and pinned controls use purple, while project subtitles and inactive controls use subdued text. Blank lines separate section headings from conversations and give worker groups space above and below their heading. Long titles are truncated to keep the action buttons separate.
|
|
139
|
+
|
|
140
|
+
Managed workers appear indented under their main conversation. The stack appears in **Priority** when any member is running, needs input, or has an unread result. Otherwise, a pin on any visible member places the stack in **Pinned**; date sections use the most recent member activity. Workers with an unavailable main conversation remain standalone rows.
|
|
141
|
+
|
|
142
|
+
Each main conversation with workers has a **▾ Workers (N)** heading below its subtitle. Click the heading to collapse the worker list, or **▸ Workers (N)** to expand it. When the list is collapsed, the main row summarizes worker activity, with input requests taking precedence over running spinners. Collapsing keeps the workers running and their tabs open. Stack preferences survive reloads and restarts, and `/activities` still lists collapsed workers.
|
|
143
|
+
|
|
144
|
+
Activity fetches the 100 most recent root sessions across projects and refreshes them in the background. Open and pinned sessions are merged into the list; older pins and known main conversations are resolved individually. Archived, deleted, and native child sessions are excluded. Closed managed workers are not reintroduced by fetched history. `/threads` restores their native tabs using the existing worker visibility rules.
|
|
145
|
+
|
|
146
|
+
Left-click a row to open or focus its real session. Each row has one-click **[◇]** (pin), **[◆]** (unpin), and **[×]** (close from Activity) buttons beside its title. These controls work on both main and worker rows without selecting a background conversation. Closing removes the row and closes its native tab when open. Saved history and the pin preference remain intact. Dismissed rows stay hidden through refreshes, reloads, and restarts. Selecting a closed conversation in `/activities` restores it; `/threads` also restores dismissed managed workers. Reopening a conversation through native session navigation restores its Activity row. Right-click also opens the action menu. The mouse wheel scrolls the list, and the native rail edge remains draggable.
|
|
147
|
+
|
|
148
|
+
| Command | Action |
|
|
149
|
+
| --- | --- |
|
|
150
|
+
| `/activity` | Toggle Activity and the native rail |
|
|
151
|
+
| `/pin` | Pin or unpin the current conversation |
|
|
152
|
+
| `/activities` | Choose a loaded conversation, including closed Activity rows, and restore it if needed |
|
|
153
|
+
| `/activity-sections` | Choose a section to collapse or expand |
|
|
154
|
+
| `/activity-threads` | Choose a managed worker stack to collapse or expand |
|
|
155
|
+
| `/threads` | Restore managed worker tabs |
|
|
156
|
+
|
|
157
|
+
The same actions are available in the command palette. **+ New session** dispatches OpenCode's registered `session.new` command. Pins use plugin client storage and survive TUI reloads and restarts.
|
|
158
|
+
|
|
159
|
+
In `/activities`, **Ctrl+F** pins or unpins the highlighted conversation. The picker stays open and preserves your search and selection. This also works for conversations in **Priority**. **Enter** opens the highlighted conversation, and **Esc** closes the picker. The footer shows the current pin action. Configure `threads.activity.choose.pin` in `cli.json` to change the shortcut.
|
|
160
|
+
|
|
161
|
+
Activity requires vertical tabs. To select the layout and disable Activity by default, configure the terminal plugin in `~/.config/opencode/cli.json`:
|
|
162
|
+
|
|
163
|
+
```json
|
|
164
|
+
{
|
|
165
|
+
"tabs": { "layout": "vertical" },
|
|
166
|
+
"plugins": [{ "package": "@op1/threads", "options": { "activity": false } }]
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Omit `activity` or set it to `true` to enable the sidebar. Keep the server plugin in `opencode.json`; server plugin options are not forwarded to the terminal entrypoint. For local development, replace the package name in both files with the clone's absolute path.
|
|
171
|
+
|
|
172
|
+
The left rail has no public plugin slot in 2.0.7. `src/activity-rail.ts` is an internal compatibility adapter: it checks a bounded render-tree structure and unambiguous geometry, hides native children while remembering their visibility, and mounts only plugin-owned content. OpenTUI constructors are imported at the terminal entrypoint to preserve host identity. The adapter uses renderer pre-paint callbacks and resize events, not continuous tree polling or root monkeypatches. It restores native children on detach, toggle, and disposal. Compatibility depends on the detected rail structure and geometry, regardless of the OpenCode version string. Unrecognized rail structures, horizontal layouts, and narrow layouts fall back to native tabs. Activity never moves shared native tabs while mounted; native project and activity grouping resumes during fallback.
|
|
173
|
+
|
|
174
|
+
`src/activity-theme.ts` accepts both `base`/`muted` and legacy `default`/`subdued` theme tokens. Missing colors use a valid fallback before reaching the renderer: assigning an undefined spinner color aborts the entire frame. Accent and worker colors follow the theme's foreground brightness rather than fixed light/dark shade numbers. If a hue lacks sufficient text contrast, Activity uses the theme's normal foreground. The `@opencode/theme` development dependency keeps the host theme types available to TypeScript. Renderer regression tests cover both token shapes and missing colors, including spinner construction and subsequent color updates.
|
|
175
|
+
|
|
116
176
|
## Verification and limits
|
|
117
177
|
|
|
118
|
-
Run `bun run typecheck`, `bun test`, and `bun run verify:live`. The live check requires OpenCode
|
|
178
|
+
Run `bun run typecheck`, `bun test`, and `bun run verify:live`. The live check requires OpenCode V2, Python, and `uv`. It starts a separate local server, a deterministic model endpoint, and a terminal process with isolated configuration and data. It verifies actual tool calls, durable messages, permission restrictions, worker limits, deleted-worker cleanup, restart behavior, and native tab visibility, busy state, and focus.
|
|
119
179
|
|
|
120
180
|
Run `bun run verify:tabs` to verify project grouping across real git worktrees, activity-based ordering, permission prompts, completed and resumed workers, focus preservation, and TUI reopening.
|
|
121
181
|
|
|
182
|
+
Run `bun run verify:activity` to exercise Activity enabled against an isolated instance of the installed OpenCode version. The suite clears its own `.audit/activity` artifacts, uses the deterministic model fixture, and reads actual renderer bounds through the test-only TUI probe. It checks layout, click and keyboard navigation, one-click pin and close controls, and native prompt responses. It also covers section and worker-stack collapse, aggregated worker status, title cleanup, worker restoration, fallback layouts, reloads, and restart persistence. `verify:tabs` and `verify:idle-tabs` explicitly disable Activity to inspect native ordering.
|
|
183
|
+
|
|
122
184
|
Run `bun run verify:roles` to verify named profiles against the native server and deterministic model endpoint. It checks actual system prompts, model variants, native delegation, read-only execution, reporting, and role-aware retries.
|
|
123
185
|
|
|
124
186
|
Run `bun run verify:idle-tabs` to open two native terminals on different idle sessions in the same directory and verify that their shared tab order stays stable.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@op1/threads",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Visible top-level worker sessions for OpenCode V2, with native tabs and durable reports.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.ts",
|
|
@@ -27,19 +27,27 @@
|
|
|
27
27
|
"verify:live": "uv run --with pyte python scripts/verify-live.py",
|
|
28
28
|
"verify:roles": "python3 scripts/verify-roles.py",
|
|
29
29
|
"verify:idle-tabs": "uv run --with pyte python scripts/verify-idle-tabs.py",
|
|
30
|
-
"verify:tabs": "uv run --with pyte python scripts/verify-tab-groups.py"
|
|
30
|
+
"verify:tabs": "uv run --with pyte python scripts/verify-tab-groups.py",
|
|
31
|
+
"verify:activity": "uv run --with pyte python scripts/verify-activity.py"
|
|
31
32
|
},
|
|
32
33
|
"dependencies": {
|
|
33
|
-
"@opencode/plugin": "2.0.
|
|
34
|
-
"@opencode/schema": "2.0.
|
|
34
|
+
"@opencode/plugin": "2.0.7",
|
|
35
|
+
"@opencode/schema": "2.0.7",
|
|
36
|
+
"fuzzysort": "3.1.0",
|
|
35
37
|
"zod": "4.1.8"
|
|
36
38
|
},
|
|
37
39
|
"peerDependencies": {
|
|
40
|
+
"@opentui/core": "0.5.10",
|
|
41
|
+
"@opentui/solid": "0.5.10",
|
|
38
42
|
"solid-js": ">=1.9.0"
|
|
39
43
|
},
|
|
40
44
|
"devDependencies": {
|
|
45
|
+
"@opencode/theme": "2.0.12",
|
|
46
|
+
"@opentui/core": "0.5.10",
|
|
47
|
+
"@opentui/solid": "0.5.10",
|
|
41
48
|
"@types/bun": "1.4.0",
|
|
42
|
-
"
|
|
49
|
+
"opentui-spinner": "0.0.7",
|
|
50
|
+
"solid-js": "1.9.12",
|
|
43
51
|
"typescript": "5.9.3"
|
|
44
52
|
}
|
|
45
53
|
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
export type ActivityItem = {
|
|
2
|
+
id: string;
|
|
3
|
+
title: string;
|
|
4
|
+
subtitle: string;
|
|
5
|
+
updated: number;
|
|
6
|
+
active: boolean;
|
|
7
|
+
attention: boolean;
|
|
8
|
+
busy: boolean;
|
|
9
|
+
unread?: "activity" | "error";
|
|
10
|
+
pinned: boolean;
|
|
11
|
+
hidden: boolean;
|
|
12
|
+
open: boolean;
|
|
13
|
+
coordinatorID?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type ActivityThread = {
|
|
17
|
+
item: ActivityItem;
|
|
18
|
+
children: ActivityItem[];
|
|
19
|
+
status: Pick<ActivityItem, "attention" | "busy" | "unread">;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function cleanRoleTitle(title: string, managed: boolean) {
|
|
23
|
+
if (!managed) return title;
|
|
24
|
+
const remainder = title.replace(/^\[(?:Main|Worker)\] /, "");
|
|
25
|
+
return remainder.trim() ? remainder : title;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function activityTime(time: {
|
|
29
|
+
idle?: number;
|
|
30
|
+
updated: number;
|
|
31
|
+
created: number;
|
|
32
|
+
}) {
|
|
33
|
+
return time.idle ?? time.updated ?? time.created;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function activitySubtitle(input: {
|
|
37
|
+
directory: string;
|
|
38
|
+
project?: { name?: string; canonical: string };
|
|
39
|
+
role?: "Main" | "Worker";
|
|
40
|
+
}) {
|
|
41
|
+
const folder = (path: string) =>
|
|
42
|
+
path
|
|
43
|
+
.replace(/[\\/]+$/, "")
|
|
44
|
+
.split(/[\\/]/)
|
|
45
|
+
.pop() || path;
|
|
46
|
+
const location = folder(input.directory);
|
|
47
|
+
const project =
|
|
48
|
+
input.project?.name?.trim() ||
|
|
49
|
+
(input.project ? folder(input.project.canonical) : location);
|
|
50
|
+
const parts = [project];
|
|
51
|
+
if (input.role) parts.push(input.role);
|
|
52
|
+
if (
|
|
53
|
+
input.project &&
|
|
54
|
+
input.project.canonical !== input.directory &&
|
|
55
|
+
location !== project
|
|
56
|
+
)
|
|
57
|
+
parts.push(location);
|
|
58
|
+
return parts.join(" · ");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function dateGroup(timestamp: number, now = new Date()) {
|
|
62
|
+
const date = new Date(timestamp);
|
|
63
|
+
const day = (value: Date) =>
|
|
64
|
+
Date.UTC(value.getFullYear(), value.getMonth(), value.getDate());
|
|
65
|
+
const age = (day(now) - day(date)) / 86400000;
|
|
66
|
+
if (age === 0) return "Today";
|
|
67
|
+
if (age === 1) return "Yesterday";
|
|
68
|
+
if (age > 1 && age < 7)
|
|
69
|
+
return date.toLocaleDateString(undefined, { weekday: "long" });
|
|
70
|
+
return date.toLocaleDateString(undefined, {
|
|
71
|
+
year: "numeric",
|
|
72
|
+
month: "short",
|
|
73
|
+
day: "numeric",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function priority(item: ActivityItem) {
|
|
78
|
+
return item.attention ? 3 : item.busy ? 2 : item.unread ? 1 : 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function visible(item: ActivityItem) {
|
|
82
|
+
return !item.hidden || item.open || item.active || item.busy || item.attention;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function compare(a: ActivityItem, b: ActivityItem) {
|
|
86
|
+
return (
|
|
87
|
+
priority(b) - priority(a) ||
|
|
88
|
+
Number(b.pinned) - Number(a.pinned) ||
|
|
89
|
+
b.updated - a.updated ||
|
|
90
|
+
a.id.localeCompare(b.id)
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function activityGroups(items: ActivityItem[], now = new Date()) {
|
|
95
|
+
const groups = new Map<string, ActivityItem[]>();
|
|
96
|
+
const ordered = items.filter(visible).sort(compare);
|
|
97
|
+
for (const item of ordered) {
|
|
98
|
+
const key = priority(item)
|
|
99
|
+
? "Priority"
|
|
100
|
+
: item.pinned
|
|
101
|
+
? "Pinned"
|
|
102
|
+
: dateGroup(item.updated, now);
|
|
103
|
+
const group = groups.get(key) ?? [];
|
|
104
|
+
group.push(item);
|
|
105
|
+
groups.set(key, group);
|
|
106
|
+
}
|
|
107
|
+
return groups;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function activityThreads(items: ActivityItem[], now = new Date()) {
|
|
111
|
+
const available = new Map(items.filter(visible).map((item) => [item.id, item]));
|
|
112
|
+
const children = new Map<string, ActivityItem[]>();
|
|
113
|
+
const roots: ActivityItem[] = [];
|
|
114
|
+
for (const item of available.values()) {
|
|
115
|
+
const parent = item.coordinatorID
|
|
116
|
+
? available.get(item.coordinatorID)
|
|
117
|
+
: undefined;
|
|
118
|
+
if (parent && parent.id !== item.id && !parent.coordinatorID) {
|
|
119
|
+
const siblings = children.get(parent.id) ?? [];
|
|
120
|
+
siblings.push(item);
|
|
121
|
+
children.set(parent.id, siblings);
|
|
122
|
+
} else roots.push(item);
|
|
123
|
+
}
|
|
124
|
+
const threads = new Map<string, ActivityThread>();
|
|
125
|
+
const summaries = roots.map((item) => {
|
|
126
|
+
const workers = (children.get(item.id) ?? []).sort(compare);
|
|
127
|
+
const members = [item, ...workers];
|
|
128
|
+
const status = {
|
|
129
|
+
attention: members.some((member) => member.attention),
|
|
130
|
+
busy: members.some((member) => member.busy),
|
|
131
|
+
unread: members.some((member) => member.unread === "error")
|
|
132
|
+
? ("error" as const)
|
|
133
|
+
: members.find((member) => member.unread)?.unread,
|
|
134
|
+
};
|
|
135
|
+
threads.set(item.id, { item, children: workers, status });
|
|
136
|
+
return {
|
|
137
|
+
...item,
|
|
138
|
+
...status,
|
|
139
|
+
pinned: members.some((member) => member.pinned),
|
|
140
|
+
updated: Math.max(...members.map((member) => member.updated)),
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
return new Map(
|
|
144
|
+
[...activityGroups(summaries, now)].map(([name, group]) => [
|
|
145
|
+
name,
|
|
146
|
+
group.flatMap((item) => {
|
|
147
|
+
const thread = threads.get(item.id);
|
|
148
|
+
return thread ? [thread] : [];
|
|
149
|
+
}),
|
|
150
|
+
]),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin/tui";
|
|
2
|
+
import type { InputRenderable, RGBA, ScrollBoxRenderable } from "@opentui/core";
|
|
3
|
+
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";
|
|
4
|
+
import fuzzysort from "fuzzysort";
|
|
5
|
+
import type { ActivityItem } from "./activity-model";
|
|
6
|
+
import { themeColor, themeMuted } from "./activity-theme";
|
|
7
|
+
|
|
8
|
+
type Choice = Pick<ActivityItem, "id" | "title" | "subtitle" | "pinned"> & {
|
|
9
|
+
category: string;
|
|
10
|
+
closed: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function ActivityPicker(props: {
|
|
14
|
+
ctx: Plugin.Context;
|
|
15
|
+
fallbackColor: RGBA;
|
|
16
|
+
items: () => Choice[];
|
|
17
|
+
current: string | undefined;
|
|
18
|
+
pin: (id: string) => Promise<void>;
|
|
19
|
+
open: (id: string) => Promise<void>;
|
|
20
|
+
}) {
|
|
21
|
+
const { ctx } = props;
|
|
22
|
+
const [query, setQuery] = createSignal("");
|
|
23
|
+
const [selectedID, setSelectedID] = createSignal(props.current);
|
|
24
|
+
const [pinning, setPinning] = createSignal(false);
|
|
25
|
+
const [height, setHeight] = createSignal(ctx.renderer.height);
|
|
26
|
+
let input: InputRenderable | undefined;
|
|
27
|
+
let scroll: ScrollBoxRenderable | undefined;
|
|
28
|
+
let closed = false;
|
|
29
|
+
const foreground = () => themeColor(ctx.theme.text, props.fallbackColor);
|
|
30
|
+
const muted = () => themeMuted(ctx.theme.text, foreground());
|
|
31
|
+
const grouped = createMemo(() => {
|
|
32
|
+
const items = query()
|
|
33
|
+
? fuzzysort.go(query(), props.items(), {
|
|
34
|
+
keys: ["title", "category"],
|
|
35
|
+
scoreFn: (result) => result[0].score * 2 + result[1].score,
|
|
36
|
+
}).map((result) => result.obj)
|
|
37
|
+
: props.items();
|
|
38
|
+
const groups = new Map<string, Choice[]>();
|
|
39
|
+
for (const item of items) {
|
|
40
|
+
const group = groups.get(item.category) ?? [];
|
|
41
|
+
group.push(item);
|
|
42
|
+
groups.set(item.category, group);
|
|
43
|
+
}
|
|
44
|
+
return [...groups];
|
|
45
|
+
});
|
|
46
|
+
const choices = createMemo(() => grouped().flatMap(([, items]) => items));
|
|
47
|
+
const selected = () =>
|
|
48
|
+
choices().find((item) => item.id === selectedID()) ?? choices()[0];
|
|
49
|
+
const select = (index: number) => setSelectedID(choices()[index]?.id);
|
|
50
|
+
const move = (direction: number) => {
|
|
51
|
+
const items = choices();
|
|
52
|
+
if (!items.length) return;
|
|
53
|
+
const index = items.findIndex((item) => item.id === selected()?.id);
|
|
54
|
+
select((index + direction % items.length + items.length) % items.length);
|
|
55
|
+
};
|
|
56
|
+
const open = (id = selected()?.id) => {
|
|
57
|
+
if (!id) return;
|
|
58
|
+
ctx.ui.dialog.clear();
|
|
59
|
+
void props.open(id);
|
|
60
|
+
};
|
|
61
|
+
const togglePin = async () => {
|
|
62
|
+
const item = selected();
|
|
63
|
+
if (!item || pinning()) return;
|
|
64
|
+
setSelectedID(item.id);
|
|
65
|
+
setPinning(true);
|
|
66
|
+
try {
|
|
67
|
+
await props.pin(item.id);
|
|
68
|
+
} finally {
|
|
69
|
+
if (!closed) setPinning(false);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
ctx.keymap.layer(() => ({
|
|
73
|
+
mode: "global",
|
|
74
|
+
target: () => input,
|
|
75
|
+
priority: 100,
|
|
76
|
+
commands: [
|
|
77
|
+
{ bind: "up", run: () => move(-1) },
|
|
78
|
+
{ bind: "ctrl+p", run: () => move(-1) },
|
|
79
|
+
{ bind: "down", run: () => move(1) },
|
|
80
|
+
{ bind: "ctrl+n", run: () => move(1) },
|
|
81
|
+
{ bind: "pageup", run: () => move(-10) },
|
|
82
|
+
{ bind: "pagedown", run: () => move(10) },
|
|
83
|
+
{ bind: "home", run: () => { select(0); } },
|
|
84
|
+
{ bind: "end", run: () => { select(choices().length - 1); } },
|
|
85
|
+
{ bind: "return", run: () => open() },
|
|
86
|
+
{ bind: "escape", run: () => ctx.ui.dialog.clear() },
|
|
87
|
+
{
|
|
88
|
+
id: "threads.activity.choose.pin",
|
|
89
|
+
title: "Pin/unpin highlighted Activity conversation",
|
|
90
|
+
bind: "ctrl+f",
|
|
91
|
+
run: togglePin,
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
}));
|
|
95
|
+
let scrollTo: string | undefined;
|
|
96
|
+
createEffect(() => {
|
|
97
|
+
grouped();
|
|
98
|
+
scrollTo = selected()?.id;
|
|
99
|
+
});
|
|
100
|
+
const reveal = () => {
|
|
101
|
+
if (!scrollTo || !scroll) return;
|
|
102
|
+
scroll.scrollChildIntoView(`activity-picker-row-${scrollTo}`);
|
|
103
|
+
scrollTo = undefined;
|
|
104
|
+
};
|
|
105
|
+
const resize = () => setHeight(ctx.renderer.height);
|
|
106
|
+
ctx.renderer.addPostProcessFn(reveal);
|
|
107
|
+
ctx.renderer.on("resize", resize);
|
|
108
|
+
onCleanup(() => {
|
|
109
|
+
closed = true;
|
|
110
|
+
ctx.renderer.removePostProcessFn(reveal);
|
|
111
|
+
ctx.renderer.off("resize", resize);
|
|
112
|
+
});
|
|
113
|
+
return (
|
|
114
|
+
<box id="activity-picker" paddingX={2} paddingY={1} gap={1}>
|
|
115
|
+
<text fg={foreground()}><b>Activity</b></text>
|
|
116
|
+
<input
|
|
117
|
+
id="activity-picker-search"
|
|
118
|
+
ref={(node) => { input = node; }}
|
|
119
|
+
focused
|
|
120
|
+
placeholder="Search"
|
|
121
|
+
textColor={foreground()}
|
|
122
|
+
focusedTextColor={foreground()}
|
|
123
|
+
onInput={(value) => {
|
|
124
|
+
setQuery(value);
|
|
125
|
+
setSelectedID(undefined);
|
|
126
|
+
}}
|
|
127
|
+
/>
|
|
128
|
+
<scrollbox
|
|
129
|
+
ref={(node) => { scroll = node; }}
|
|
130
|
+
height={Math.max(1, Math.min(
|
|
131
|
+
choices().length + grouped().length * 2,
|
|
132
|
+
Math.floor(height() / 2) - 6,
|
|
133
|
+
))}
|
|
134
|
+
scrollX={false}
|
|
135
|
+
scrollbarOptions={{ visible: false }}
|
|
136
|
+
>
|
|
137
|
+
<For each={grouped()}>{([category, items]) => <>
|
|
138
|
+
<text fg={muted()} marginTop={1}>{category}</text>
|
|
139
|
+
<For each={items}>{(item) => (
|
|
140
|
+
<box
|
|
141
|
+
id={`activity-picker-row-${item.id}`}
|
|
142
|
+
height={1}
|
|
143
|
+
flexShrink={0}
|
|
144
|
+
flexDirection="row"
|
|
145
|
+
backgroundColor={selected()?.id === item.id
|
|
146
|
+
? ctx.theme.background.raised?.high ?? themeColor(ctx.theme.background, props.fallbackColor)
|
|
147
|
+
: undefined}
|
|
148
|
+
onMouseUp={(event) => {
|
|
149
|
+
event.stopPropagation();
|
|
150
|
+
if (event.button === 0) open(item.id);
|
|
151
|
+
}}
|
|
152
|
+
>
|
|
153
|
+
<text
|
|
154
|
+
id={`activity-picker-title-${item.id}`}
|
|
155
|
+
fg={foreground()}
|
|
156
|
+
width="60%"
|
|
157
|
+
wrapMode="none"
|
|
158
|
+
truncate
|
|
159
|
+
>{`${selected()?.id === item.id ? ">" : " "} ${item.pinned ? "◆" : "◇"} ${item.title}`}</text>
|
|
160
|
+
<text fg={muted()} flexGrow={1} flexShrink={1} minWidth={0} wrapMode="none" truncate>
|
|
161
|
+
{`${item.subtitle}${item.closed ? " · Closed" : ""}`}
|
|
162
|
+
</text>
|
|
163
|
+
</box>
|
|
164
|
+
)}</For>
|
|
165
|
+
</>}</For>
|
|
166
|
+
<Show when={!choices().length}>
|
|
167
|
+
<text fg={muted()}>No matching conversations</text>
|
|
168
|
+
</Show>
|
|
169
|
+
</scrollbox>
|
|
170
|
+
<text id="activity-picker-hint" fg={muted()}>
|
|
171
|
+
{`${ctx.keymap.shortcuts("threads.activity.choose.pin").join(" / ")} ${selected()?.pinned ? "Unpin" : "Pin"} · enter Open · esc Close`}
|
|
172
|
+
</text>
|
|
173
|
+
</box>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { BoxRenderable, Renderable } from "@opentui/core";
|
|
2
|
+
import type { Plugin } from "@opencode/plugin/tui";
|
|
3
|
+
|
|
4
|
+
// Compatibility boundary: OpenCode 2.0.7 has no public left-tab-rail slot.
|
|
5
|
+
export function activityRail(
|
|
6
|
+
ctx: Plugin.Context,
|
|
7
|
+
core: Pick<
|
|
8
|
+
typeof import("@opentui/core"),
|
|
9
|
+
"BoxRenderable" | "ScrollBoxRenderable"
|
|
10
|
+
>,
|
|
11
|
+
mount: (rail: BoxRenderable) => () => void,
|
|
12
|
+
) {
|
|
13
|
+
const { BoxRenderable, ScrollBoxRenderable } = core;
|
|
14
|
+
let owned:
|
|
15
|
+
| {
|
|
16
|
+
rail: BoxRenderable;
|
|
17
|
+
content: BoxRenderable;
|
|
18
|
+
restore: Map<Renderable, boolean>;
|
|
19
|
+
cleanup: () => void;
|
|
20
|
+
}
|
|
21
|
+
| undefined;
|
|
22
|
+
let enabled = true;
|
|
23
|
+
let dirty = true;
|
|
24
|
+
let geometry = "";
|
|
25
|
+
const detach = () => {
|
|
26
|
+
if (!owned) return;
|
|
27
|
+
owned.cleanup();
|
|
28
|
+
if (!owned.content.isDestroyed) owned.content.destroyRecursively();
|
|
29
|
+
for (const [child, visible] of owned.restore)
|
|
30
|
+
if (!child.isDestroyed) child.visible = visible;
|
|
31
|
+
owned = undefined;
|
|
32
|
+
};
|
|
33
|
+
const find = () => {
|
|
34
|
+
const matches: BoxRenderable[] = [];
|
|
35
|
+
const queue: { node: Renderable; depth: number }[] = [
|
|
36
|
+
{ node: ctx.renderer.root, depth: 0 },
|
|
37
|
+
];
|
|
38
|
+
let count = 0;
|
|
39
|
+
while (queue.length && count++ < 128) {
|
|
40
|
+
const entry = queue.shift();
|
|
41
|
+
if (!entry) break;
|
|
42
|
+
const children = entry.node.getChildren();
|
|
43
|
+
for (const child of children) {
|
|
44
|
+
if (
|
|
45
|
+
child instanceof BoxRenderable &&
|
|
46
|
+
child.visible &&
|
|
47
|
+
child.screenX === 0 &&
|
|
48
|
+
child.screenY === 0 &&
|
|
49
|
+
child.width >= 24 &&
|
|
50
|
+
child.width <= 60 &&
|
|
51
|
+
child.height === ctx.renderer.height &&
|
|
52
|
+
child
|
|
53
|
+
.getChildren()
|
|
54
|
+
.some((node) => node instanceof ScrollBoxRenderable) &&
|
|
55
|
+
children.filter(
|
|
56
|
+
(sibling) =>
|
|
57
|
+
sibling !== child &&
|
|
58
|
+
sibling.screenX === child.width &&
|
|
59
|
+
sibling.screenY === 0 &&
|
|
60
|
+
sibling.height === child.height &&
|
|
61
|
+
sibling.width >= 60,
|
|
62
|
+
).length === 1
|
|
63
|
+
)
|
|
64
|
+
matches.push(child);
|
|
65
|
+
if (entry.depth < 4)
|
|
66
|
+
queue.push({ node: child, depth: entry.depth + 1 });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return queue.length === 0 && matches.length === 1 ? matches[0] : undefined;
|
|
70
|
+
};
|
|
71
|
+
const frame = () => {
|
|
72
|
+
if (owned?.rail.isDestroyed || owned?.content.isDestroyed) {
|
|
73
|
+
detach();
|
|
74
|
+
dirty = true;
|
|
75
|
+
}
|
|
76
|
+
const current = `${ctx.renderer.width}:${ctx.renderer.height}:${owned?.rail.width}:${owned?.rail.height}`;
|
|
77
|
+
if (current !== geometry) {
|
|
78
|
+
geometry = current;
|
|
79
|
+
dirty = true;
|
|
80
|
+
}
|
|
81
|
+
if (!dirty) return;
|
|
82
|
+
dirty = false;
|
|
83
|
+
const rail = enabled && ctx.ui.tabs.enabled() ? find() : undefined;
|
|
84
|
+
if (owned?.rail !== rail) detach();
|
|
85
|
+
if (!rail) return;
|
|
86
|
+
if (!owned) {
|
|
87
|
+
const content = new BoxRenderable(ctx.renderer, {
|
|
88
|
+
id: "op-threads-activity",
|
|
89
|
+
position: "absolute",
|
|
90
|
+
left: 0,
|
|
91
|
+
top: 0,
|
|
92
|
+
width: "100%",
|
|
93
|
+
height: "100%",
|
|
94
|
+
paddingLeft: 1,
|
|
95
|
+
paddingRight: 2,
|
|
96
|
+
flexDirection: "column",
|
|
97
|
+
onMouseDown: (event) => event.stopPropagation(),
|
|
98
|
+
onMouseUp: (event) => event.stopPropagation(),
|
|
99
|
+
});
|
|
100
|
+
rail.add(content);
|
|
101
|
+
owned = { rail, content, restore: new Map(), cleanup: mount(content) };
|
|
102
|
+
}
|
|
103
|
+
for (const child of rail.getChildren()) {
|
|
104
|
+
if (child === owned.content) continue;
|
|
105
|
+
if (!owned.restore.has(child)) owned.restore.set(child, child.visible);
|
|
106
|
+
child.visible = false;
|
|
107
|
+
}
|
|
108
|
+
for (const child of owned.restore.keys())
|
|
109
|
+
if (child.isDestroyed) owned.restore.delete(child);
|
|
110
|
+
};
|
|
111
|
+
const invalidate = () => {
|
|
112
|
+
dirty = true;
|
|
113
|
+
ctx.renderer.requestRender();
|
|
114
|
+
};
|
|
115
|
+
const beforeFrame = async () => frame();
|
|
116
|
+
ctx.renderer.setFrameCallback(beforeFrame);
|
|
117
|
+
ctx.renderer.on("resize", invalidate);
|
|
118
|
+
invalidate();
|
|
119
|
+
return {
|
|
120
|
+
invalidate,
|
|
121
|
+
enabled: () => enabled,
|
|
122
|
+
mounted: () =>
|
|
123
|
+
Boolean(owned && !owned.rail.isDestroyed && !owned.content.isDestroyed),
|
|
124
|
+
toggle(value = !enabled) {
|
|
125
|
+
enabled = value;
|
|
126
|
+
if (!enabled) detach();
|
|
127
|
+
invalidate();
|
|
128
|
+
},
|
|
129
|
+
dispose() {
|
|
130
|
+
ctx.renderer.removeFrameCallback(beforeFrame);
|
|
131
|
+
ctx.renderer.off("resize", invalidate);
|
|
132
|
+
detach();
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ResolvedTheme } from "@opencode/theme/tui";
|
|
2
|
+
|
|
3
|
+
type Color = ResolvedTheme["text"]["base"];
|
|
4
|
+
type ColorToken =
|
|
5
|
+
| { readonly base: Color | undefined }
|
|
6
|
+
| { readonly default: Color | undefined };
|
|
7
|
+
type MutedToken =
|
|
8
|
+
| { readonly muted: Color | undefined }
|
|
9
|
+
| { readonly subdued: Color | undefined };
|
|
10
|
+
|
|
11
|
+
export function themeColor(token: ColorToken, fallback: Color) {
|
|
12
|
+
return ("base" in token ? token.base : token.default) ?? fallback;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function themeMuted(token: MutedToken, fallback: Color) {
|
|
16
|
+
return ("muted" in token ? token.muted : token.subdued) ?? fallback;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function luminance(color: Color) {
|
|
20
|
+
const linear = (value: number) =>
|
|
21
|
+
value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
22
|
+
return (
|
|
23
|
+
0.2126 * linear(color.r) +
|
|
24
|
+
0.7152 * linear(color.g) +
|
|
25
|
+
0.0722 * linear(color.b)
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function themeHue(
|
|
30
|
+
hue: Partial<ResolvedTheme["hue"]["accent"]> | undefined,
|
|
31
|
+
foreground: Color,
|
|
32
|
+
background: Color,
|
|
33
|
+
) {
|
|
34
|
+
const target = luminance(foreground);
|
|
35
|
+
let selected = foreground;
|
|
36
|
+
let distance = Infinity;
|
|
37
|
+
for (const color of Object.values(hue ?? {})) {
|
|
38
|
+
if (!color) continue;
|
|
39
|
+
const delta = Math.abs(luminance(color) - target);
|
|
40
|
+
if (delta >= distance) continue;
|
|
41
|
+
selected = color;
|
|
42
|
+
distance = delta;
|
|
43
|
+
}
|
|
44
|
+
const light = luminance(selected);
|
|
45
|
+
const base = luminance(background);
|
|
46
|
+
const contrast =
|
|
47
|
+
(Math.max(light, base) + 0.05) / (Math.min(light, base) + 0.05);
|
|
48
|
+
return contrast >= 4.5 ? selected : foreground;
|
|
49
|
+
}
|