@mtayfur/opencode-chat-tree 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 +123 -0
- package/dist/adapters/opencode-gateway.d.ts +50 -0
- package/dist/adapters/tree-repository.d.ts +25 -0
- package/dist/configuration.d.ts +9 -0
- package/dist/core/branching.d.ts +23 -0
- package/dist/core/projection.d.ts +74 -0
- package/dist/core/transcript.d.ts +23 -0
- package/dist/core/tree.d.ts +20 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +2405 -0
- package/dist/ui/branch-workflow.d.ts +30 -0
- package/dist/ui/controls.d.ts +36 -0
- package/dist/ui/theme.d.ts +15 -0
- package/dist/ui/tree-route.d.ts +13 -0
- package/dist/ui/tree-view.d.ts +13 -0
- package/package.json +75 -0
package/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# OpenCode Chat Tree
|
|
2
|
+
|
|
3
|
+
`@mtayfur/opencode-chat-tree` adds persistent conversation branching to the OpenCode TUI. It presents related sessions as a navigable tree, creates branches from any visible message, and can carry a generated handoff into the new branch.
|
|
4
|
+
|
|
5
|
+
## Capabilities
|
|
6
|
+
|
|
7
|
+
- Open the current conversation tree with `/tree`.
|
|
8
|
+
- Navigate parent and child sessions from a single view.
|
|
9
|
+
- Collapse sessions or calendar-day groups.
|
|
10
|
+
- Branch from user and assistant messages without changing OpenCode's fork semantics.
|
|
11
|
+
- Replay a selected user prompt in the new branch.
|
|
12
|
+
- Generate an optional branch handoff.
|
|
13
|
+
- Store tree metadata globally or inside the project.
|
|
14
|
+
- Detect deleted sessions while preserving their descendants in the tree.
|
|
15
|
+
|
|
16
|
+
## Requirements
|
|
17
|
+
|
|
18
|
+
- OpenCode `1.18.16` or newer compatible 1.x release
|
|
19
|
+
- Bun `1.3.14` or newer for local development
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
Add the package to `~/.config/opencode/tui.json`:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"plugin": ["@mtayfur/opencode-chat-tree"]
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
OpenCode installs package plugins automatically. Restart the TUI after changing the configuration.
|
|
32
|
+
|
|
33
|
+
### Local checkout
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
bash ./install.sh
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The installer resolves dependencies, builds `dist/index.js`, and replaces the package entry with a local file URL. Restore the published package entry with:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
bash ./install.sh --uninstall
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Configuration
|
|
46
|
+
|
|
47
|
+
The plugin intentionally keeps its configuration small:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"plugin": [
|
|
52
|
+
[
|
|
53
|
+
"@mtayfur/opencode-chat-tree",
|
|
54
|
+
{
|
|
55
|
+
"storageScope": "global",
|
|
56
|
+
"model": "openai/gpt-5.6-luna-fast",
|
|
57
|
+
"variant": "high"
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
| Option | Default | Description |
|
|
65
|
+
| -------------- | ---------------------- | --------------------------------------- |
|
|
66
|
+
| `storageScope` | `global` | Use `global` or `local` tree metadata. |
|
|
67
|
+
| `model` | Current OpenCode model | Model used to generate branch handoffs. |
|
|
68
|
+
| `variant` | Model default | Model variant used for branch handoffs. |
|
|
69
|
+
|
|
70
|
+
Unknown options are ignored so removing an obsolete option does not prevent the plugin from loading.
|
|
71
|
+
|
|
72
|
+
## Controls
|
|
73
|
+
|
|
74
|
+
| Keys | Action |
|
|
75
|
+
| ---------------- | ------------------------------------------------ |
|
|
76
|
+
| `↑` / `k` | Move up |
|
|
77
|
+
| `↓` / `j` | Move down |
|
|
78
|
+
| `←` / `h` | Collapse the selected group |
|
|
79
|
+
| `→` / `l` | Expand the selected group |
|
|
80
|
+
| `Enter` | Open a session, toggle a day, or create a branch |
|
|
81
|
+
| `Esc` / `Ctrl+C` | Return to the originating session |
|
|
82
|
+
|
|
83
|
+
Controls are fixed by design. OpenCode activates them only while the tree has focus.
|
|
84
|
+
|
|
85
|
+
## Branch behavior
|
|
86
|
+
|
|
87
|
+
- Selecting a session opens it.
|
|
88
|
+
- Selecting a user message forks at that message and restores its prompt in the input area.
|
|
89
|
+
- Selecting an assistant message forks at the following message boundary.
|
|
90
|
+
- Selecting the last assistant message returns to its session because no later fork boundary exists.
|
|
91
|
+
- A generated handoff is written into the new session as context without requesting a reply.
|
|
92
|
+
|
|
93
|
+
## Storage
|
|
94
|
+
|
|
95
|
+
- Global: `<OpenCode state>/plugins/opencode-chat-tree/projects/<project-slug-hash>/`
|
|
96
|
+
- Local: `<project>/.opencode/opencode-chat-tree/`
|
|
97
|
+
|
|
98
|
+
Each project has a `registry.json` and immutable-style tree snapshots under `trees/<tree-id>/snapshot.json`. Writes use temporary files and atomic renames. The plugin validates ownership, parent-child symmetry, graph reachability, and path-safe tree IDs before using persisted data.
|
|
99
|
+
|
|
100
|
+
## Architecture
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
src/
|
|
104
|
+
├── adapters/ OpenCode and filesystem boundaries
|
|
105
|
+
├── core/ Tree, transcript, projection, and branch rules
|
|
106
|
+
├── ui/ TUI controls, workflow, route, and rendering
|
|
107
|
+
├── configuration.ts
|
|
108
|
+
└── index.ts Plugin entrypoint
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Core modules are side-effect free. `TreeRepository` owns persistence, while `OpenCodeTreeGateway` owns SDK calls and fork cleanup. The UI only coordinates reactive state and user interaction.
|
|
112
|
+
|
|
113
|
+
## Development
|
|
114
|
+
|
|
115
|
+
From the repository root:
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
bun install --frozen-lockfile
|
|
119
|
+
bun run --filter @mtayfur/opencode-chat-tree fmt:check
|
|
120
|
+
bun run --filter @mtayfur/opencode-chat-tree typecheck
|
|
121
|
+
bun run --filter @mtayfur/opencode-chat-tree build
|
|
122
|
+
npm pack ./packages/chat-tree --dry-run
|
|
123
|
+
```
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { OpencodeClient } from "@opencode-ai/sdk/v2";
|
|
2
|
+
import type { BranchPlan } from "../core/branching";
|
|
3
|
+
import type { TreeSnapshot } from "../core/tree";
|
|
4
|
+
import { type TranscriptMap, type ConversationEntry } from "../core/transcript";
|
|
5
|
+
import type { TreeRepository } from "./tree-repository";
|
|
6
|
+
export type SummaryModel = {
|
|
7
|
+
readonly providerID: string;
|
|
8
|
+
readonly modelID: string;
|
|
9
|
+
};
|
|
10
|
+
export type SummaryRequest = {
|
|
11
|
+
readonly messages: readonly ConversationEntry[];
|
|
12
|
+
readonly customInstructions?: string;
|
|
13
|
+
readonly signal?: AbortSignal;
|
|
14
|
+
readonly model?: SummaryModel;
|
|
15
|
+
readonly variant?: string;
|
|
16
|
+
};
|
|
17
|
+
export type CreatedBranch = {
|
|
18
|
+
readonly sessionId: string;
|
|
19
|
+
readonly replayPrompt?: string;
|
|
20
|
+
};
|
|
21
|
+
export declare class OpenCodeTreeGateway {
|
|
22
|
+
private readonly client;
|
|
23
|
+
private readonly projectRoot;
|
|
24
|
+
private readonly repository;
|
|
25
|
+
constructor(input: {
|
|
26
|
+
readonly client: OpencodeClient;
|
|
27
|
+
readonly projectRoot: string;
|
|
28
|
+
readonly repository: TreeRepository;
|
|
29
|
+
});
|
|
30
|
+
loadTranscripts(snapshot: TreeSnapshot): Promise<TranscriptMap>;
|
|
31
|
+
createBranch(snapshot: TreeSnapshot, plan: BranchPlan, summary?: SummaryRequest): Promise<CreatedBranch>;
|
|
32
|
+
enterBranch(branch: CreatedBranch, navigate: (sessionId: string) => void | Promise<void>): Promise<void>;
|
|
33
|
+
private loadTranscript;
|
|
34
|
+
private normalizeEntry;
|
|
35
|
+
private generateSummary;
|
|
36
|
+
private promptSummary;
|
|
37
|
+
private buildSummaryPrompt;
|
|
38
|
+
private injectSummary;
|
|
39
|
+
private abortSession;
|
|
40
|
+
private deleteSession;
|
|
41
|
+
private cleanupForkedSession;
|
|
42
|
+
private isNotFoundResult;
|
|
43
|
+
private isNotFoundError;
|
|
44
|
+
private createSdkError;
|
|
45
|
+
private getErrorMessage;
|
|
46
|
+
private toError;
|
|
47
|
+
private toSummaryGenerationError;
|
|
48
|
+
private isAbortError;
|
|
49
|
+
private createSummaryCancellationError;
|
|
50
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type TreeSnapshot } from "../core/tree";
|
|
2
|
+
export type StorageScope = "global" | "local";
|
|
3
|
+
export type TreeContext = {
|
|
4
|
+
readonly kind: "missing-session";
|
|
5
|
+
readonly projectRoot: string;
|
|
6
|
+
} | {
|
|
7
|
+
readonly kind: "ready";
|
|
8
|
+
readonly projectRoot: string;
|
|
9
|
+
readonly snapshot: TreeSnapshot;
|
|
10
|
+
};
|
|
11
|
+
export declare function resolveTreeStorageRoot(input: {
|
|
12
|
+
readonly projectRoot: string;
|
|
13
|
+
readonly stateRoot: string;
|
|
14
|
+
readonly scope: StorageScope;
|
|
15
|
+
}): string;
|
|
16
|
+
export declare class TreeRepository {
|
|
17
|
+
private readonly storageRoot;
|
|
18
|
+
constructor(storageRoot: string);
|
|
19
|
+
open(projectRoot: string, sessionId?: string): Promise<TreeContext>;
|
|
20
|
+
saveBranch(snapshot: TreeSnapshot, input: {
|
|
21
|
+
readonly sessionId: string;
|
|
22
|
+
readonly parentSessionId: string;
|
|
23
|
+
readonly anchorMessageId: string;
|
|
24
|
+
}): Promise<void>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { SummaryModel } from "./adapters/opencode-gateway";
|
|
2
|
+
import type { StorageScope } from "./adapters/tree-repository";
|
|
3
|
+
export type PluginConfiguration = {
|
|
4
|
+
readonly storageScope: StorageScope;
|
|
5
|
+
readonly model?: SummaryModel;
|
|
6
|
+
readonly variant?: string;
|
|
7
|
+
};
|
|
8
|
+
export declare function readPluginConfiguration(value: unknown): PluginConfiguration;
|
|
9
|
+
export declare function readSummaryModel(value: string | undefined): SummaryModel | undefined;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type TranscriptMap, type ConversationEntry } from "./transcript";
|
|
2
|
+
import type { TreeRow } from "./projection";
|
|
3
|
+
export type BranchPlan = {
|
|
4
|
+
readonly sessionId: string;
|
|
5
|
+
readonly anchorMessageId: string;
|
|
6
|
+
readonly forkMessageId: string;
|
|
7
|
+
readonly appendPromptText?: string;
|
|
8
|
+
};
|
|
9
|
+
export type BranchIntent = {
|
|
10
|
+
readonly kind: "none";
|
|
11
|
+
} | {
|
|
12
|
+
readonly kind: "notice";
|
|
13
|
+
readonly message: string;
|
|
14
|
+
readonly variant: "info" | "error";
|
|
15
|
+
} | {
|
|
16
|
+
readonly kind: "navigate";
|
|
17
|
+
readonly sessionId: string;
|
|
18
|
+
} | {
|
|
19
|
+
readonly kind: "fork";
|
|
20
|
+
readonly plan: BranchPlan;
|
|
21
|
+
};
|
|
22
|
+
export declare function planBranchIntent(row: TreeRow | undefined, transcripts: TranscriptMap): BranchIntent;
|
|
23
|
+
export declare function collectSummaryMessages(row: TreeRow | undefined, transcripts: TranscriptMap): readonly ConversationEntry[];
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { TreeSnapshot } from "./tree";
|
|
2
|
+
import { type SessionTranscript, type TranscriptMap, type ConversationEntry } from "./transcript";
|
|
3
|
+
export type ProjectedMessage = {
|
|
4
|
+
readonly kind: "message";
|
|
5
|
+
readonly sessionId: string;
|
|
6
|
+
readonly messageId: string;
|
|
7
|
+
readonly entry: ConversationEntry;
|
|
8
|
+
readonly childSessions: readonly ProjectedSession[];
|
|
9
|
+
};
|
|
10
|
+
export type ProjectedSession = {
|
|
11
|
+
readonly kind: "session";
|
|
12
|
+
readonly sessionId: string;
|
|
13
|
+
readonly status: SessionTranscript["status"];
|
|
14
|
+
readonly childSessions: readonly ProjectedSession[];
|
|
15
|
+
readonly messages: readonly ProjectedMessage[];
|
|
16
|
+
};
|
|
17
|
+
export declare function projectConversationTree(snapshot: TreeSnapshot, transcripts: TranscriptMap): ProjectedSession;
|
|
18
|
+
export type SessionRowId = `session:${string}`;
|
|
19
|
+
export type MessageRowId = `message:${string}:${string}`;
|
|
20
|
+
export type DayRowId = `day:${string}:${string}:${string}`;
|
|
21
|
+
export type TreeRowId = SessionRowId | MessageRowId | DayRowId;
|
|
22
|
+
export declare function getSessionRowId(sessionId: string): SessionRowId;
|
|
23
|
+
export declare function getMessageRowId(sessionId: string, messageId: string): MessageRowId;
|
|
24
|
+
export declare function getDayRowId(day: string, sessionId: string, messageId: string): DayRowId;
|
|
25
|
+
type SessionRow = {
|
|
26
|
+
readonly kind: "session";
|
|
27
|
+
readonly id: SessionRowId;
|
|
28
|
+
readonly depth: number;
|
|
29
|
+
readonly sessionId: string;
|
|
30
|
+
readonly title: string;
|
|
31
|
+
readonly isDeleted: boolean;
|
|
32
|
+
readonly isCollapsible: boolean;
|
|
33
|
+
readonly isCollapsed: boolean;
|
|
34
|
+
};
|
|
35
|
+
type DayRow = {
|
|
36
|
+
readonly kind: "day";
|
|
37
|
+
readonly id: DayRowId;
|
|
38
|
+
readonly depth: number;
|
|
39
|
+
readonly sessionId: string;
|
|
40
|
+
readonly day: string;
|
|
41
|
+
readonly isCollapsible: true;
|
|
42
|
+
readonly isCollapsed: boolean;
|
|
43
|
+
};
|
|
44
|
+
type MessageRow = {
|
|
45
|
+
readonly kind: "message";
|
|
46
|
+
readonly id: MessageRowId;
|
|
47
|
+
readonly depth: number;
|
|
48
|
+
readonly sessionId: string;
|
|
49
|
+
readonly messageId: string;
|
|
50
|
+
readonly role: ConversationEntry["metadata"]["role"];
|
|
51
|
+
readonly createdAt?: number;
|
|
52
|
+
readonly preview: string;
|
|
53
|
+
};
|
|
54
|
+
export type TreeRow = SessionRow | DayRow | MessageRow;
|
|
55
|
+
export type TreePresentation = {
|
|
56
|
+
readonly rows: readonly TreeRow[];
|
|
57
|
+
readonly rowIndexById: Readonly<Record<TreeRowId, number>>;
|
|
58
|
+
readonly lastRowIndexBySessionId: Readonly<Record<string, number>>;
|
|
59
|
+
readonly parentRowIdById: ReadonlyMap<TreeRowId, TreeRowId | undefined>;
|
|
60
|
+
readonly sessionById: Readonly<Record<string, ProjectedSession>>;
|
|
61
|
+
};
|
|
62
|
+
type PresentationOptions = {
|
|
63
|
+
readonly collapsedSessionIds?: ReadonlySet<string>;
|
|
64
|
+
readonly collapsedDays?: ReadonlySet<string>;
|
|
65
|
+
};
|
|
66
|
+
export declare function buildTreePresentation(root: ProjectedSession, options?: PresentationOptions): TreePresentation;
|
|
67
|
+
export declare function resolveVisibleRowId(input: {
|
|
68
|
+
readonly presentation: TreePresentation;
|
|
69
|
+
readonly currentSessionId?: string;
|
|
70
|
+
readonly preferredRowId?: TreeRowId;
|
|
71
|
+
}): TreeRowId | undefined;
|
|
72
|
+
export declare function moveRowSelection(rows: readonly TreeRow[], currentIndex: number | undefined, direction: -1 | 1): number | undefined;
|
|
73
|
+
export declare function formatLocalDay(createdAt: number): string | undefined;
|
|
74
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Message, Part } from "@opencode-ai/sdk/v2";
|
|
2
|
+
export type ConversationEntry = {
|
|
3
|
+
readonly id: string;
|
|
4
|
+
readonly metadata: Message;
|
|
5
|
+
readonly parts: readonly Part[];
|
|
6
|
+
};
|
|
7
|
+
export type SessionTranscript = {
|
|
8
|
+
readonly sessionId: string;
|
|
9
|
+
readonly status: "available" | "deleted";
|
|
10
|
+
readonly messages: readonly ConversationEntry[];
|
|
11
|
+
readonly byId: ReadonlyMap<string, ConversationEntry>;
|
|
12
|
+
readonly indexById: ReadonlyMap<string, number>;
|
|
13
|
+
};
|
|
14
|
+
export type TranscriptMap = Readonly<Record<string, SessionTranscript>>;
|
|
15
|
+
export declare function createSessionTranscript(input: {
|
|
16
|
+
readonly sessionId: string;
|
|
17
|
+
readonly status: SessionTranscript["status"];
|
|
18
|
+
readonly messages: readonly ConversationEntry[];
|
|
19
|
+
}): SessionTranscript;
|
|
20
|
+
export declare function getVisibleText(parts: readonly Part[]): string | undefined;
|
|
21
|
+
export declare function getMessagePreview(input: Pick<ConversationEntry, "parts">): string;
|
|
22
|
+
export declare function isInternalAssistantMessage(message: ConversationEntry): boolean;
|
|
23
|
+
export declare function serializeTranscriptForSummary(messages: readonly ConversationEntry[]): string;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export declare const STORAGE_FORMAT_VERSION: 1;
|
|
2
|
+
export type TreeNode = {
|
|
3
|
+
readonly sessionId: string;
|
|
4
|
+
readonly parentSessionId: string | null;
|
|
5
|
+
readonly anchorMessageId: string | null;
|
|
6
|
+
readonly children: readonly string[];
|
|
7
|
+
};
|
|
8
|
+
export type TreeSnapshot = {
|
|
9
|
+
readonly version: typeof STORAGE_FORMAT_VERSION;
|
|
10
|
+
readonly treeId: string;
|
|
11
|
+
readonly rootSessionId: string;
|
|
12
|
+
readonly sessions: Readonly<Record<string, TreeNode>>;
|
|
13
|
+
};
|
|
14
|
+
export declare function createTreeId(generateUuid?: () => string): string;
|
|
15
|
+
export declare function createRootSnapshot(treeId: string, sessionId: string): TreeSnapshot;
|
|
16
|
+
export declare function attachBranch(snapshot: TreeSnapshot, input: {
|
|
17
|
+
readonly sessionId: string;
|
|
18
|
+
readonly parentSessionId: string;
|
|
19
|
+
readonly anchorMessageId: string;
|
|
20
|
+
}): TreeSnapshot;
|