@op1/threads 0.1.7 → 0.2.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 +88 -10
- package/docs/dynamic-workflows-plan.md +46 -0
- package/docs/workflow-capacity-findings.md +47 -0
- package/docs/workflows-verification.md +105 -0
- package/docs/workflows.md +60 -0
- package/index.ts +2 -0
- package/package.json +21 -5
- package/skills/workflow-authoring/SKILL.md +50 -0
- package/skills/workflow-authoring/references/runtime.md +87 -0
- 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/permissions.ts +2 -2
- package/src/threads.ts +201 -32
- package/src/workflow-engine.ts +1049 -0
- package/src/workflow-rpc.ts +39 -0
- package/src/workflow-runtime-interpreter.ts +368 -0
- package/src/workflow-runtime-protocol.ts +36 -0
- package/src/workflow-runtime-worker.ts +64 -0
- package/src/workflow-runtime.ts +144 -0
- package/src/workflow-saved.ts +88 -0
- package/src/workflow-store.ts +103 -0
- package/src/workflow-types.ts +144 -0
- package/src/workflow-ui.tsx +251 -0
- package/src/workflow-worker.ts +210 -0
- package/src/workflows.ts +180 -0
- package/tui.ts +58 -8
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Workflow runtime contract
|
|
2
|
+
|
|
3
|
+
Scripts run in OpenCode's confined Code Mode interpreter inside a terminable Bun worker. The parent service enforces the deadline, including during synchronous script work. Scripts cannot import modules or use host filesystem, process, network, clock, or randomness APIs. Agents inspect the outside world and return recorded data. Use arguments for timestamps and other variable inputs.
|
|
4
|
+
|
|
5
|
+
Keep each result below 1 MiB and the combined run journal below 16 MiB. Payload admission reserves space for control metadata and bounded failure diagnostics, so usable payload capacity is lower. Return concise structured findings and artifact paths instead of complete file contents. Run lists and completion notifications omit the full result; inspect a run to read it.
|
|
6
|
+
|
|
7
|
+
## Script shape
|
|
8
|
+
|
|
9
|
+
The first statement is `export const meta = { name: "name", description: "description" }`. Metadata contains literal values. An optional `phases` array contains objects with a `title`.
|
|
10
|
+
|
|
11
|
+
The body supports top-level `await`, ordinary data transformations, branching, and bounded loops. Return a JSON value. Intermediate agent results stay in script variables and the durable journal.
|
|
12
|
+
|
|
13
|
+
## Agent steps
|
|
14
|
+
|
|
15
|
+
`await agent(prompt, options)` returns the validated `result` submitted by its worker. Options are:
|
|
16
|
+
|
|
17
|
+
| Field | Meaning |
|
|
18
|
+
| --- | --- |
|
|
19
|
+
| `key` | Required unique, stable step name. Include the item and round when using loops. |
|
|
20
|
+
| `agent` | Required configured OpenCode agent ID. |
|
|
21
|
+
| `label` | Optional display title. |
|
|
22
|
+
| `phase` | Optional phase override. |
|
|
23
|
+
| `schema` | Optional JSON Schema for the result. Invalid schemas fail before dispatch. |
|
|
24
|
+
| `access` | `read` by default: only the profile's permitted read, glob, grep, webfetch, websearch, and skill tools. `write` permits the selected profile's other tools. |
|
|
25
|
+
| `isolation` | `shared` by default, or `worktree` for a retained isolated checkout. Creating a worktree requires `access: "write"`; later readers can use its returned `directory`. |
|
|
26
|
+
| `directory` | Optional existing directory inside the owner project or one of its registered worktrees. Symlinks are resolved before containment checks. |
|
|
27
|
+
| `timeoutMs` | Optional step timeout, bounded by the run's execution policy. |
|
|
28
|
+
|
|
29
|
+
Workers submit `workflows_result({ verdict, summary, evidence, result })`. Invalid results return a validation error so the worker can repair its output. A native execution that ends without a result has no task verdict.
|
|
30
|
+
|
|
31
|
+
Profile restrictions and the coordinator's restrictions still apply. Read access blocks other plugin and MCP tools, including those with side effects, even if the selected profile permits them. Workflow workers cannot delegate. Selecting `access: "write"` does not grant permissions that the selected profile lacks.
|
|
32
|
+
|
|
33
|
+
## Composition
|
|
34
|
+
|
|
35
|
+
- `parallel(thunks)` runs async functions concurrently and returns results in input order.
|
|
36
|
+
- `pipeline(items, ...stages)` runs each item through its stages independently. A fast item does not wait for slower items between stages.
|
|
37
|
+
- `await phase(title)` records a display phase.
|
|
38
|
+
- `await log(message)` records a bounded progress message.
|
|
39
|
+
- `workflow(name, args)` invokes a saved workflow within the parent's limits.
|
|
40
|
+
- `retry(thunk, { attempts })` bounds logical or validation retries, with three attempts by default. Give each agent attempt a distinct step key. Return expected negative findings as validated data, then let the validator decide whether another attempt is useful. A native execution failure or an explicit `FAIL`/`INCONCLUSIVE` report remains an unresolved failure and prevents the run from passing, even when caught by the script. Inspect uncertain writes before starting a replacement run.
|
|
41
|
+
- `gate(thunk, validator, { attempts })` repeats until the validator accepts or attempts run out, with three attempts by default. The validator returns a boolean or `{ ok: boolean, feedback?: string }`. A truthy object without `ok: true` does not pass.
|
|
42
|
+
- `loopUntilDry({ round, key, consecutiveEmpty, maxRounds })` accumulates unique findings until discovery stops producing new items. `round` and `key` are required; `key` is a property name or identity function. Defaults are two consecutive empty rounds and ten maximum rounds. Reaching the maximum returns the accumulated findings.
|
|
43
|
+
- `checkpoint(prompt, { key })` records a question and waits for an explicit response through the run controls. The response becomes recorded input on resume. Responses are limited to 1 MiB and must fit in the aggregate journal. Rejected responses leave the checkpoint unanswered so a smaller response can be submitted.
|
|
44
|
+
|
|
45
|
+
Errors remain errors unless the script explicitly handles them. Do not discard failed items or substitute a passing result for missing evidence.
|
|
46
|
+
|
|
47
|
+
## Start and control
|
|
48
|
+
|
|
49
|
+
`workflows_start` accepts exactly one of `script` or saved `name`, plus a unique task `key`, optional JSON `args`, and limits:
|
|
50
|
+
|
|
51
|
+
| Limit | Default | Range |
|
|
52
|
+
| --- | --- | --- |
|
|
53
|
+
| `concurrency` | 3 | 1–8 |
|
|
54
|
+
| `maxAgents` | 4 | 1–1000 |
|
|
55
|
+
| `agentTimeoutMs` | 30 minutes | 1 second–7 days |
|
|
56
|
+
| `timeoutMs` | 24 hours | 1 second–7 days |
|
|
57
|
+
| `tokenBudget` | Unset | Positive integer |
|
|
58
|
+
|
|
59
|
+
The plugin options `workflowConcurrency` and `workflowMaxAgents` override the default concurrency and total agent-step limit for new runs. The registered `workflows_start` schema advertises the configured defaults. Explicit run values override them, and existing runs retain their recorded limits. To use eight concurrent agents, the total agent-step limit must also be at least eight.
|
|
60
|
+
|
|
61
|
+
`tokenBudget` measures cumulative native input and output tokens across worker turns. It is an admission threshold, not a hard generation cap: workers already running can exceed it before the next dispatch checks their usage. Multi-turn code investigation can consume much more than the final answer's token count. Choose the threshold for the whole run, including review steps, or omit it and use the agent and time limits.
|
|
62
|
+
|
|
63
|
+
The cumulative host-call cap is `maxAgents * 8 + 100`, including agent, phase, log, checkpoint, and nested-workflow calls. Nested saved workflows share that cap and the parent agent budget. Nesting is limited to four child levels.
|
|
64
|
+
|
|
65
|
+
Helper bounds (`attempts`, `maxRounds`, and `consecutiveEmpty`) must be safe positive integers no greater than 1,000. Arguments, host-call payloads, and results are each limited to 1 MiB; error diagnostics are capped at 8 KiB. The runtime module allows 64 simultaneous interpreter calls, including nested calls, and rejects excess calls immediately. This is separate from the agent concurrency limit.
|
|
66
|
+
|
|
67
|
+
The configured Threads worker limit also applies across simultaneous runs owned by one coordinator. Its default is four workers. A token budget checks recorded usage at dispatch, including failed and interrupted attempts. Already-running agents can exceed the remaining budget. Unreported usage is marked unmeasured and blocks further budgeted dispatch.
|
|
68
|
+
|
|
69
|
+
The same start key and identical input identify the same run. Start retries do not restart completed or stopped runs. Pause stops new scheduling and drains active steps. Stop interrupts active work. Resume uses the same recorded script and arguments, reconciles existing sessions, and reuses completed results. A changed step request under an existing key fails rather than returning a stale result. Save edits as a new workflow run.
|
|
70
|
+
|
|
71
|
+
A service restart leaves interrupted work available for explicit resume. Worktrees and session evidence are retained. A missing result after an interrupted write requires inspection; it does not prove that no write occurred.
|
|
72
|
+
|
|
73
|
+
For an uncertain write, inspect its retained worker and directory. Use `threads_send` to ask that same worker to verify the existing effects and submit `workflows_result` without repeating completed actions. After its native execution finishes, resume the workflow. A missing worker session is not permission to replay its writes in a new session.
|
|
74
|
+
|
|
75
|
+
Nested saved scripts are pinned to their run. Agent successes, exposed failures, and checkpoint responses replay in their original settlement order. An exposed failure cannot become a success during replay. If a selected profile's model, instructions, or permissions change, start a new run rather than treating its old result as evidence from the new profile.
|
|
76
|
+
|
|
77
|
+
Legacy journals without checkpoint settlement order cannot deterministically resume an answered checkpoint. Such runs fail with a diagnostic and require a new run key. A corrupt or already-full legacy record remains preserved and produces an owner-visible diagnostic without blocking healthy sibling runs. An exact-limit legacy record may need explicit repair before any further control metadata fits.
|
|
78
|
+
|
|
79
|
+
Checkpoint responses are stored in both checkpoint state and settlement order, so their bytes count twice toward the journal limit. A run too full to accept a response remains unanswered; use a smaller response or a new run key. An inconsistent settlement order may wait until the configured run deadline.
|
|
80
|
+
|
|
81
|
+
## Saved workflows
|
|
82
|
+
|
|
83
|
+
`workflows_save` writes a run's script to `.opencode/workflows/<name>.js` in the current directory or to `workflows/<name>.js` under the user's OpenCode configuration. Existing files are not overwritten. Project workflows take precedence over user workflows of the same name.
|
|
84
|
+
|
|
85
|
+
Use `workflows_saved` to list scripts and `/workflow-<name>` to ask the agent to invoke one with arguments. `/workflow-run <task>` asks the current agent to author a workflow. `/workflows` opens the terminal run navigator.
|
|
86
|
+
|
|
87
|
+
After editing saved files directly, use `/workflow-refresh` to reload their commands. The saved names `run` and `refresh` are reserved for built-in workflow commands.
|
|
@@ -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
|
+
}
|