@impactstories/pi-btw 0.1.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/LICENSE +21 -0
- package/README.md +92 -0
- package/package.json +54 -0
- package/src/index.ts +1065 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ImpactStories
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# pi-btw
|
|
2
|
+
|
|
3
|
+
`pi-btw` is a [pi](https://pi.dev) extension for parking side thoughts without derailing the current coding-agent session.
|
|
4
|
+
|
|
5
|
+
Use it when you notice something important but do not want the agent to switch tasks yet. Capture the note, keep working, then later open each note in its own linked side session.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pi install npm:@impactstories/pi-btw
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
For local development:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
git clone https://github.com/ImpactStories/pi-btw.git
|
|
17
|
+
cd pi-btw
|
|
18
|
+
npm install
|
|
19
|
+
pi -e .
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Commands and shortcuts
|
|
23
|
+
|
|
24
|
+
| Command / shortcut | What it does |
|
|
25
|
+
| --- | --- |
|
|
26
|
+
| `/btw <note>` | Capture a session-local BTW note. |
|
|
27
|
+
| `/btw` | Open the notes panel. |
|
|
28
|
+
| `/btw-back` | From a BTW side session, archive the current note and return to the parent session. |
|
|
29
|
+
| `Shift+Right` | Open the next BTW note side session. From a side session, archive the current note and continue to the next note. |
|
|
30
|
+
| `Shift+Left` | From a BTW side session, archive the current note and return to the parent session. |
|
|
31
|
+
|
|
32
|
+
Notes panel keys:
|
|
33
|
+
|
|
34
|
+
| Key | Action |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| `↑` / `↓` | Select a note. |
|
|
37
|
+
| `Enter` / `o` | Open or resume a linked side session for the selected note. |
|
|
38
|
+
| `d` | Toggle done/open. |
|
|
39
|
+
| `x` / `Delete` | Archive the note. |
|
|
40
|
+
| `r` | Return from a side session to the parent session. |
|
|
41
|
+
| `Esc` / `Ctrl+C` | Close the panel. |
|
|
42
|
+
|
|
43
|
+
## Workflow
|
|
44
|
+
|
|
45
|
+
1. Capture interruptions as they happen:
|
|
46
|
+
|
|
47
|
+
```text
|
|
48
|
+
/btw Check whether the API client should retry 429 responses
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
2. Continue the main task. The footer shows how many open BTW notes remain.
|
|
52
|
+
3. Press `Shift+Right` or run `/btw` and open a note.
|
|
53
|
+
4. pi creates a side session linked to the note and sends the note as a `BTW:` prompt.
|
|
54
|
+
5. Press `Shift+Left` or run `/btw-back` to archive the note and return to the parent session.
|
|
55
|
+
|
|
56
|
+
BTW notes are stored as custom entries in pi's session JSONL file. Side sessions point back to their parent session, so the extension can resume existing side sessions instead of duplicating them.
|
|
57
|
+
|
|
58
|
+
## Development
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
npm install
|
|
62
|
+
npm run typecheck
|
|
63
|
+
npm test
|
|
64
|
+
npm run ci
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Run against a local checkout:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pi -e /absolute/path/to/pi-btw
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Or install the local package into pi settings:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pi install /absolute/path/to/pi-btw
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Release
|
|
80
|
+
|
|
81
|
+
Releases are tag-driven. After updating `package.json` and committing the change:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
npm version patch # or minor / major
|
|
85
|
+
git push origin main --follow-tags
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The release workflow runs type checks and tests, then publishes the package to npm with provenance. It expects an `NPM_TOKEN` repository secret.
|
|
89
|
+
|
|
90
|
+
## Security
|
|
91
|
+
|
|
92
|
+
pi extensions run with your local user permissions. Review extension code before installing third-party packages.
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@impactstories/pi-btw",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A pi extension for capturing BTW side notes and exploring them in linked side sessions.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi-extension",
|
|
10
|
+
"btw",
|
|
11
|
+
"coding-agent"
|
|
12
|
+
],
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/ImpactStories/pi-btw.git"
|
|
16
|
+
},
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/ImpactStories/pi-btw/issues"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/ImpactStories/pi-btw#readme",
|
|
21
|
+
"files": [
|
|
22
|
+
"src",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "vitest run",
|
|
28
|
+
"test:watch": "vitest",
|
|
29
|
+
"typecheck": "tsc --noEmit",
|
|
30
|
+
"ci": "npm run typecheck && npm test"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
34
|
+
"@earendil-works/pi-tui": "*"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@earendil-works/pi-coding-agent": "^0.74.1",
|
|
38
|
+
"@earendil-works/pi-tui": "^0.74.1",
|
|
39
|
+
"@types/node": "^22.15.29",
|
|
40
|
+
"typescript": "^5.8.3",
|
|
41
|
+
"vitest": "^3.1.4"
|
|
42
|
+
},
|
|
43
|
+
"pi": {
|
|
44
|
+
"extensions": [
|
|
45
|
+
"./src/index.ts"
|
|
46
|
+
]
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=20"
|
|
50
|
+
},
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public"
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,1065 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import type {
|
|
6
|
+
ExtensionAPI,
|
|
7
|
+
ExtensionCommandContext,
|
|
8
|
+
ExtensionContext,
|
|
9
|
+
SessionEntry,
|
|
10
|
+
SessionManager,
|
|
11
|
+
Theme,
|
|
12
|
+
} from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
14
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
15
|
+
|
|
16
|
+
const BTW_PREFIX = "BTW:";
|
|
17
|
+
const BTW_COMMAND_DESCRIPTION = "Capture or manage session-local BTW side notes.";
|
|
18
|
+
const BTW_BACK_COMMAND = "Archive the current note and switch back to the parent /btw session.";
|
|
19
|
+
const BTW_CUSTOM_TYPE = "btw";
|
|
20
|
+
const BTW_STATUS_KEY = "btw";
|
|
21
|
+
const BTW_RETURN_WITH_NEXT_STATUS = "↩ BTW session - Shift+Left archive+back • Shift+Right archive+next";
|
|
22
|
+
const BTW_RETURN_NO_MORE_STATUS = "↩ BTW session - Shift+Left archive+back • no more notes";
|
|
23
|
+
const BTW_NOTES_STATUS_PREFIX = "BTW";
|
|
24
|
+
const BTW_NEXT_STATUS_HINT = "Shift+Right next";
|
|
25
|
+
const BTW_CAPTURE_STATUS_DURATION_MS = 7000;
|
|
26
|
+
const BTW_CAPTURE_STATUS_PREVIEW_WIDTH = 160;
|
|
27
|
+
const BTW_BACK_SHORTCUTS = ["shift+left"] as const;
|
|
28
|
+
const BTW_NEXT_SHORTCUT = "shift+right";
|
|
29
|
+
const NOTE_PANEL_MAX_VISIBLE_NOTES = 10;
|
|
30
|
+
|
|
31
|
+
export type NoteStatus = "open" | "done" | "archived";
|
|
32
|
+
|
|
33
|
+
type BtwPanelAction =
|
|
34
|
+
| { type: "open"; noteId: string }
|
|
35
|
+
| { type: "toggle"; noteId: string }
|
|
36
|
+
| { type: "archive"; noteId: string }
|
|
37
|
+
| { type: "return" }
|
|
38
|
+
| { type: "close" };
|
|
39
|
+
|
|
40
|
+
export interface BtwNote {
|
|
41
|
+
id: string;
|
|
42
|
+
text: string;
|
|
43
|
+
status: NoteStatus;
|
|
44
|
+
anchorEntryId?: string | null;
|
|
45
|
+
createdAt: string;
|
|
46
|
+
updatedAt: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface BtwSideSession {
|
|
50
|
+
noteId: string;
|
|
51
|
+
sessionPath: string;
|
|
52
|
+
noteText: string;
|
|
53
|
+
createdAt: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface BtwSideSessionInfo {
|
|
57
|
+
noteId: string;
|
|
58
|
+
noteText: string;
|
|
59
|
+
parentSession: string;
|
|
60
|
+
parentNotes?: BtwNote[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type SwitchSession = ExtensionCommandContext["switchSession"];
|
|
64
|
+
type NewSession = ExtensionCommandContext["newSession"];
|
|
65
|
+
type NewSessionOptions = NonNullable<Parameters<NewSession>[0]>;
|
|
66
|
+
type ReplacementSessionContext = Parameters<NonNullable<NewSessionOptions["withSession"]>>[0];
|
|
67
|
+
type TimeoutHandle = ReturnType<typeof setTimeout>;
|
|
68
|
+
|
|
69
|
+
interface SessionNavigationContext extends ExtensionContext {
|
|
70
|
+
switchSession?: SwitchSession;
|
|
71
|
+
newSession?: NewSession;
|
|
72
|
+
ui: ExtensionContext["ui"] & { switchSession?: SwitchSession; newSession?: NewSession };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface InteractiveModeConstructor {
|
|
76
|
+
prototype: {
|
|
77
|
+
createExtensionUIContext?: (this: InteractiveModeInstance) => ExtensionContext["ui"];
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface InteractiveModeInstance {
|
|
82
|
+
handleResumeSession?: SwitchSession;
|
|
83
|
+
runtimeHost?: { newSession?: NewSession };
|
|
84
|
+
loadingAnimation?: { stop: () => void };
|
|
85
|
+
statusContainer?: { clear: () => void };
|
|
86
|
+
renderCurrentSessionState?: () => void;
|
|
87
|
+
ui?: { requestRender?: () => void };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface InteractiveModeModule {
|
|
91
|
+
InteractiveMode?: InteractiveModeConstructor;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const BTW_INTERACTIVE_MODE_PATCHED = Symbol.for("impactstories.pi-btw.interactive-mode-patched.v2");
|
|
95
|
+
|
|
96
|
+
let captureStatusTimer: TimeoutHandle | undefined;
|
|
97
|
+
let capturedStatusText: string | undefined;
|
|
98
|
+
const nextSideSessionIndexByParent = new Map<string, number>();
|
|
99
|
+
|
|
100
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
101
|
+
return typeof value === "object" && value !== null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getText(value: unknown): string | undefined {
|
|
105
|
+
return typeof value === "string" ? value : undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function getStatus(value: unknown): NoteStatus | undefined {
|
|
109
|
+
return value === "open" || value === "done" || value === "archived" ? value : undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function getNoteSnapshot(value: unknown): BtwNote | undefined {
|
|
113
|
+
if (!isRecord(value)) {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const id = getText(value.id);
|
|
118
|
+
const text = getText(value.text);
|
|
119
|
+
const createdAt = getText(value.createdAt);
|
|
120
|
+
const updatedAt = getText(value.updatedAt);
|
|
121
|
+
if (!id || !text || !createdAt || !updatedAt) {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
id,
|
|
127
|
+
text,
|
|
128
|
+
status: getStatus(value.status) ?? "open",
|
|
129
|
+
anchorEntryId: getText(value.anchorEntryId) ?? null,
|
|
130
|
+
createdAt,
|
|
131
|
+
updatedAt,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function getNoteSnapshots(value: unknown): BtwNote[] | undefined {
|
|
136
|
+
if (!Array.isArray(value)) {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return value.flatMap((item) => {
|
|
141
|
+
const note = getNoteSnapshot(item);
|
|
142
|
+
return note ? [note] : [];
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function normalizeNoteText(message: string): string {
|
|
147
|
+
return message.trim().replace(/\s+/g, " ").trim();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function normalizeBtwMessage(message: string): string {
|
|
151
|
+
return `${BTW_PREFIX} ${normalizeNoteText(message)}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function createNoteId(): string {
|
|
155
|
+
return randomUUID().slice(0, 8);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function getParentSessionPath(ctx: ExtensionContext): string | undefined {
|
|
159
|
+
return ctx.sessionManager.getHeader()?.parentSession;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function appendNoteCreate(ctx: ExtensionContext, text: string): BtwNote {
|
|
163
|
+
const now = new Date().toISOString();
|
|
164
|
+
const note: BtwNote = {
|
|
165
|
+
id: createNoteId(),
|
|
166
|
+
text,
|
|
167
|
+
status: "open",
|
|
168
|
+
anchorEntryId: ctx.sessionManager.getLeafId(),
|
|
169
|
+
createdAt: now,
|
|
170
|
+
updatedAt: now,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
(ctx.sessionManager as SessionManager).appendCustomEntry(BTW_CUSTOM_TYPE, {
|
|
174
|
+
kind: "note",
|
|
175
|
+
version: 1,
|
|
176
|
+
op: "create",
|
|
177
|
+
id: note.id,
|
|
178
|
+
text: note.text,
|
|
179
|
+
status: note.status,
|
|
180
|
+
anchorEntryId: note.anchorEntryId,
|
|
181
|
+
createdAt: note.createdAt,
|
|
182
|
+
updatedAt: note.updatedAt,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
return note;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function appendNoteStatus(ctx: ExtensionContext, noteId: string, status: NoteStatus): void {
|
|
189
|
+
(ctx.sessionManager as SessionManager).appendCustomEntry(BTW_CUSTOM_TYPE, {
|
|
190
|
+
kind: "note",
|
|
191
|
+
version: 1,
|
|
192
|
+
op: "status",
|
|
193
|
+
id: noteId,
|
|
194
|
+
status,
|
|
195
|
+
updatedAt: new Date().toISOString(),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function appendNoteSnapshotCreate(ctx: ExtensionContext, note: BtwNote): void {
|
|
200
|
+
(ctx.sessionManager as SessionManager).appendCustomEntry(BTW_CUSTOM_TYPE, {
|
|
201
|
+
kind: "note",
|
|
202
|
+
version: 1,
|
|
203
|
+
op: "create",
|
|
204
|
+
id: note.id,
|
|
205
|
+
text: note.text,
|
|
206
|
+
status: note.status,
|
|
207
|
+
anchorEntryId: note.anchorEntryId,
|
|
208
|
+
createdAt: note.createdAt,
|
|
209
|
+
updatedAt: note.updatedAt,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function getNotesFromEntries(entries: SessionEntry[]): BtwNote[] {
|
|
214
|
+
const notes = new Map<string, BtwNote>();
|
|
215
|
+
|
|
216
|
+
for (const entry of entries) {
|
|
217
|
+
if (entry.type !== "custom" || entry.customType !== BTW_CUSTOM_TYPE || !isRecord(entry.data)) {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (entry.data.kind !== "note") {
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const id = getText(entry.data.id);
|
|
226
|
+
const op = getText(entry.data.op);
|
|
227
|
+
if (!id || !op) {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (op === "create") {
|
|
232
|
+
const text = getText(entry.data.text);
|
|
233
|
+
if (!text) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
notes.set(id, {
|
|
238
|
+
id,
|
|
239
|
+
text,
|
|
240
|
+
status: getStatus(entry.data.status) ?? "open",
|
|
241
|
+
anchorEntryId: getText(entry.data.anchorEntryId) ?? null,
|
|
242
|
+
createdAt: getText(entry.data.createdAt) ?? entry.timestamp,
|
|
243
|
+
updatedAt: getText(entry.data.updatedAt) ?? entry.timestamp,
|
|
244
|
+
});
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const note = notes.get(id);
|
|
249
|
+
if (!note) {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (op === "status") {
|
|
254
|
+
const status = getStatus(entry.data.status);
|
|
255
|
+
if (status) {
|
|
256
|
+
note.status = status;
|
|
257
|
+
note.updatedAt = getText(entry.data.updatedAt) ?? entry.timestamp;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return [...notes.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function getSessionNotes(ctx: ExtensionContext): BtwNote[] {
|
|
266
|
+
return getNotesFromEntries(ctx.sessionManager.getEntries());
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function getVisibleNotes(ctx: ExtensionContext): BtwNote[] {
|
|
270
|
+
return getSessionNotes(ctx).filter((note) => note.status !== "archived");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function getOpenNoteCount(ctx: ExtensionContext): number {
|
|
274
|
+
return getSessionNotes(ctx).filter((note) => note.status === "open").length;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function getSideSessionInfoFromEntries(entries: SessionEntry[]): BtwSideSessionInfo | undefined {
|
|
278
|
+
for (const entry of entries) {
|
|
279
|
+
if (entry.type !== "custom" || entry.customType !== BTW_CUSTOM_TYPE || !isRecord(entry.data)) {
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (entry.data.kind !== "side-session") {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const noteId = getText(entry.data.noteId);
|
|
288
|
+
const noteText = getText(entry.data.noteText);
|
|
289
|
+
const parentSession = getText(entry.data.parentSession);
|
|
290
|
+
if (noteId && noteText && parentSession) {
|
|
291
|
+
return { noteId, noteText, parentSession, parentNotes: getNoteSnapshots(entry.data.parentNotes) };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return undefined;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function getCurrentSideSessionInfo(ctx: ExtensionContext): BtwSideSessionInfo | undefined {
|
|
299
|
+
return getSideSessionInfoFromEntries(ctx.sessionManager.getEntries());
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function parseSessionFile(filePath: string): { header?: Record<string, unknown>; entries: SessionEntry[] } {
|
|
303
|
+
const lines = readFileSync(filePath, "utf8").split("\n").filter((line) => line.trim());
|
|
304
|
+
const parsed = lines.flatMap((line) => {
|
|
305
|
+
try {
|
|
306
|
+
return [JSON.parse(line) as unknown];
|
|
307
|
+
} catch {
|
|
308
|
+
return [];
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
const header = parsed.find((entry) => isRecord(entry) && entry.type === "session") as Record<string, unknown> | undefined;
|
|
312
|
+
const entries = parsed.filter((entry): entry is SessionEntry => isRecord(entry) && entry.type !== "session") as SessionEntry[];
|
|
313
|
+
return { header, entries };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function listLinkedSideSessions(ctx: ExtensionContext): BtwSideSession[] {
|
|
317
|
+
const currentSession = ctx.sessionManager.getSessionFile();
|
|
318
|
+
if (!currentSession) {
|
|
319
|
+
return [];
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
let filenames: string[];
|
|
323
|
+
try {
|
|
324
|
+
filenames = readdirSync(ctx.sessionManager.getSessionDir());
|
|
325
|
+
} catch {
|
|
326
|
+
return [];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const sideSessions: BtwSideSession[] = [];
|
|
330
|
+
for (const filename of filenames) {
|
|
331
|
+
if (!filename.endsWith(".jsonl")) {
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const sessionPath = join(ctx.sessionManager.getSessionDir(), filename);
|
|
336
|
+
if (sessionPath === currentSession) {
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
const { header, entries } = parseSessionFile(sessionPath);
|
|
342
|
+
if (header?.parentSession !== currentSession) {
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const sideSession = getSideSessionInfoFromEntries(entries);
|
|
347
|
+
if (sideSession?.parentSession === currentSession) {
|
|
348
|
+
sideSessions.push({
|
|
349
|
+
noteId: sideSession.noteId,
|
|
350
|
+
sessionPath,
|
|
351
|
+
noteText: sideSession.noteText,
|
|
352
|
+
createdAt: getText(header.timestamp) ?? "",
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
} catch {
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return sideSessions.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function getSideSessionByNoteId(sideSessions: BtwSideSession[]): Map<string, BtwSideSession> {
|
|
364
|
+
const byNoteId = new Map<string, BtwSideSession>();
|
|
365
|
+
for (const sideSession of sideSessions) {
|
|
366
|
+
if (!byNoteId.has(sideSession.noteId)) {
|
|
367
|
+
byNoteId.set(sideSession.noteId, sideSession);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return byNoteId;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function getSwitchSession(ctx: ExtensionContext): SwitchSession | undefined {
|
|
374
|
+
const navigationContext = ctx as SessionNavigationContext;
|
|
375
|
+
return navigationContext.switchSession ?? navigationContext.ui.switchSession;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function getNewSession(ctx: ExtensionContext): NewSession | undefined {
|
|
379
|
+
const navigationContext = ctx as SessionNavigationContext;
|
|
380
|
+
return navigationContext.newSession ?? navigationContext.ui.newSession;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function getNavigationNotesFromEntries(entries: SessionEntry[]): BtwNote[] {
|
|
384
|
+
return getNotesFromEntries(entries)
|
|
385
|
+
.filter((note) => note.status !== "archived")
|
|
386
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function getNavigationNotes(ctx: ExtensionContext): BtwNote[] {
|
|
390
|
+
return getNavigationNotesFromEntries(ctx.sessionManager.getEntries());
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function hasLaterNavigationNote(notes: BtwNote[], noteId: string): boolean {
|
|
394
|
+
const currentIndex = notes.findIndex((note) => note.id === noteId);
|
|
395
|
+
return currentIndex >= 0 && currentIndex < notes.length - 1;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function sideSessionHasLaterNote(sideSession: BtwSideSessionInfo): boolean {
|
|
399
|
+
try {
|
|
400
|
+
const { entries } = parseSessionFile(sideSession.parentSession);
|
|
401
|
+
const notes = getNavigationNotesFromEntries(entries);
|
|
402
|
+
return notes.length > 0
|
|
403
|
+
? hasLaterNavigationNote(notes, sideSession.noteId)
|
|
404
|
+
: hasLaterNavigationNote(sideSession.parentNotes ?? [], sideSession.noteId);
|
|
405
|
+
} catch {
|
|
406
|
+
return sideSession.parentNotes ? hasLaterNavigationNote(sideSession.parentNotes, sideSession.noteId) : true;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function restoreMissingParentNotes(ctx: ExtensionContext, notes: BtwNote[] | undefined): void {
|
|
411
|
+
if (!notes?.length) {
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const existingNoteIds = new Set(getSessionNotes(ctx).map((note) => note.id));
|
|
416
|
+
for (const note of notes) {
|
|
417
|
+
if (existingNoteIds.has(note.id)) {
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
appendNoteSnapshotCreate(ctx, note);
|
|
422
|
+
existingNoteIds.add(note.id);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function archiveSideSessionNote(ctx: ExtensionContext, sideSession: BtwSideSessionInfo): void {
|
|
427
|
+
const parentSession = ctx.sessionManager.getSessionFile() ?? sideSession.parentSession;
|
|
428
|
+
const visibleBeforeArchive = getNavigationNotes(ctx);
|
|
429
|
+
const currentIndex = visibleBeforeArchive.findIndex((note) => note.id === sideSession.noteId);
|
|
430
|
+
|
|
431
|
+
appendNoteStatus(ctx, sideSession.noteId, "archived");
|
|
432
|
+
|
|
433
|
+
const remainingNoteCount = getNavigationNotes(ctx).length;
|
|
434
|
+
if (remainingNoteCount === 0) {
|
|
435
|
+
nextSideSessionIndexByParent.delete(parentSession);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
nextSideSessionIndexByParent.set(parentSession, Math.max(0, currentIndex) % remainingNoteCount);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function clearBtwCaptureIndicator(): void {
|
|
443
|
+
if (captureStatusTimer) {
|
|
444
|
+
clearTimeout(captureStatusTimer);
|
|
445
|
+
captureStatusTimer = undefined;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
capturedStatusText = undefined;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function updateBtwStatus(ctx: ExtensionContext): void {
|
|
452
|
+
if (!ctx.hasUI) {
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const parentSession = getParentSessionPath(ctx);
|
|
457
|
+
if (parentSession) {
|
|
458
|
+
const sideSession = getCurrentSideSessionInfo(ctx);
|
|
459
|
+
const status = sideSession && !sideSessionHasLaterNote(sideSession) ? BTW_RETURN_NO_MORE_STATUS : BTW_RETURN_WITH_NEXT_STATUS;
|
|
460
|
+
ctx.ui.setStatus(BTW_STATUS_KEY, ctx.ui.theme.bold(ctx.ui.theme.fg("warning", status)));
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const openCount = getOpenNoteCount(ctx);
|
|
465
|
+
if (openCount === 0 && !capturedStatusText) {
|
|
466
|
+
ctx.ui.setStatus(BTW_STATUS_KEY, undefined);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const nextSuffix = getVisibleNotes(ctx).length > 0 ? ` - ${BTW_NEXT_STATUS_HINT}` : "";
|
|
471
|
+
const capturedSuffix = capturedStatusText ? ` - captured "${capturedStatusText}"` : "";
|
|
472
|
+
ctx.ui.setStatus(
|
|
473
|
+
BTW_STATUS_KEY,
|
|
474
|
+
ctx.ui.theme.bold(ctx.ui.theme.fg("warning", `${BTW_NOTES_STATUS_PREFIX}: ${openCount} open${nextSuffix}${capturedSuffix}`)),
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function showBtwCaptureIndicator(ctx: ExtensionContext, note: BtwNote): void {
|
|
479
|
+
if (!ctx.hasUI) {
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (captureStatusTimer) {
|
|
484
|
+
clearTimeout(captureStatusTimer);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
capturedStatusText = truncateToWidth(note.text.replace(/"/g, '\\"'), BTW_CAPTURE_STATUS_PREVIEW_WIDTH);
|
|
488
|
+
updateBtwStatus(ctx);
|
|
489
|
+
|
|
490
|
+
captureStatusTimer = setTimeout(() => {
|
|
491
|
+
capturedStatusText = undefined;
|
|
492
|
+
captureStatusTimer = undefined;
|
|
493
|
+
try {
|
|
494
|
+
updateBtwStatus(ctx);
|
|
495
|
+
} catch {
|
|
496
|
+
// The captured context may be stale after session replacement.
|
|
497
|
+
}
|
|
498
|
+
}, BTW_CAPTURE_STATUS_DURATION_MS);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function getInteractiveModePath(): string | undefined {
|
|
502
|
+
const candidateEntryPoints = [process.argv[1], "/opt/homebrew/bin/pi", "/usr/local/bin/pi"];
|
|
503
|
+
for (const entryPoint of candidateEntryPoints) {
|
|
504
|
+
if (!entryPoint || !existsSync(entryPoint)) {
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const realEntryPoint = realpathSync(entryPoint);
|
|
509
|
+
const candidate = join(dirname(realEntryPoint), "modes", "interactive", "interactive-mode.js");
|
|
510
|
+
if (existsSync(candidate)) {
|
|
511
|
+
return candidate;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return undefined;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function installInteractiveModeMonkeyPatch(): Promise<void> {
|
|
519
|
+
const interactiveModePath = getInteractiveModePath();
|
|
520
|
+
if (!interactiveModePath) {
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const module = (await import(pathToFileURL(interactiveModePath).href)) as InteractiveModeModule;
|
|
525
|
+
const prototype = module.InteractiveMode?.prototype;
|
|
526
|
+
const originalCreateExtensionUIContext = prototype?.createExtensionUIContext;
|
|
527
|
+
if (!prototype || !originalCreateExtensionUIContext || BTW_INTERACTIVE_MODE_PATCHED in prototype) {
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
Object.defineProperty(prototype, BTW_INTERACTIVE_MODE_PATCHED, { value: true });
|
|
532
|
+
prototype.createExtensionUIContext = function createBtwExtensionUIContext(this: InteractiveModeInstance) {
|
|
533
|
+
const ui = originalCreateExtensionUIContext.call(this);
|
|
534
|
+
const switchSession = this.handleResumeSession;
|
|
535
|
+
if (switchSession) {
|
|
536
|
+
Object.defineProperty(ui, "switchSession", {
|
|
537
|
+
value: switchSession.bind(this),
|
|
538
|
+
configurable: true,
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const newSession = this.runtimeHost?.newSession;
|
|
543
|
+
if (newSession) {
|
|
544
|
+
Object.defineProperty(ui, "newSession", {
|
|
545
|
+
value: async (options?: NewSessionOptions) => {
|
|
546
|
+
if (this.loadingAnimation) {
|
|
547
|
+
this.loadingAnimation.stop();
|
|
548
|
+
this.loadingAnimation = undefined;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
this.statusContainer?.clear();
|
|
552
|
+
try {
|
|
553
|
+
const result = await newSession.call(this.runtimeHost, options);
|
|
554
|
+
if (!result.cancelled) {
|
|
555
|
+
this.renderCurrentSessionState?.();
|
|
556
|
+
this.ui?.requestRender?.();
|
|
557
|
+
}
|
|
558
|
+
return result;
|
|
559
|
+
} catch (error) {
|
|
560
|
+
ui.notify(`Could not create BTW session: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
561
|
+
return { cancelled: true };
|
|
562
|
+
}
|
|
563
|
+
},
|
|
564
|
+
configurable: true,
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return ui;
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async function returnToParentSession(ctx: ExtensionContext): Promise<void> {
|
|
573
|
+
if (!ctx.hasUI) {
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const parentSession = getParentSessionPath(ctx);
|
|
578
|
+
if (!parentSession) {
|
|
579
|
+
ctx.ui.notify("No parent /btw session to return to.", "warning");
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (!ctx.isIdle()) {
|
|
584
|
+
ctx.ui.notify("Wait for the assistant to finish before switching sessions.", "warning");
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const switchSession = getSwitchSession(ctx);
|
|
589
|
+
if (!switchSession) {
|
|
590
|
+
ctx.ui.notify("Session switching is unavailable here. Use /btw-back.", "warning");
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const sideSession = getCurrentSideSessionInfo(ctx);
|
|
595
|
+
|
|
596
|
+
try {
|
|
597
|
+
const result = await switchSession(
|
|
598
|
+
parentSession,
|
|
599
|
+
sideSession
|
|
600
|
+
? {
|
|
601
|
+
withSession: async (parentCtx) => {
|
|
602
|
+
restoreMissingParentNotes(parentCtx, sideSession.parentNotes);
|
|
603
|
+
archiveSideSessionNote(parentCtx, sideSession);
|
|
604
|
+
updateBtwStatus(parentCtx);
|
|
605
|
+
parentCtx.ui.notify("Archived BTW note.", "info");
|
|
606
|
+
},
|
|
607
|
+
}
|
|
608
|
+
: undefined,
|
|
609
|
+
);
|
|
610
|
+
if (result.cancelled) {
|
|
611
|
+
ctx.ui.notify("Switch back was cancelled.", "warning");
|
|
612
|
+
}
|
|
613
|
+
} catch (error) {
|
|
614
|
+
ctx.ui.notify(`Could not switch back: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
async function openNextBtwSideSession(ctx: ExtensionContext): Promise<void> {
|
|
619
|
+
if (!ctx.hasUI) {
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (getParentSessionPath(ctx)) {
|
|
624
|
+
await archiveCurrentBtwAndNext(ctx);
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (!ctx.isIdle()) {
|
|
629
|
+
ctx.ui.notify("Wait for the assistant to finish before switching BTW sessions.", "warning");
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const parentSession = ctx.sessionManager.getSessionFile();
|
|
634
|
+
if (!parentSession) {
|
|
635
|
+
ctx.ui.notify("Cannot switch BTW sessions from an unsaved session.", "warning");
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const navigationNotes = getNavigationNotes(ctx);
|
|
640
|
+
if (navigationNotes.length === 0) {
|
|
641
|
+
ctx.ui.notify("No BTW notes in this session.", "info");
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const index = (nextSideSessionIndexByParent.get(parentSession) ?? 0) % navigationNotes.length;
|
|
646
|
+
const note = navigationNotes[index];
|
|
647
|
+
if (!note) {
|
|
648
|
+
ctx.ui.notify("No BTW notes available.", "info");
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const opened = await openNoteSideSession(ctx, note);
|
|
653
|
+
if (opened) {
|
|
654
|
+
nextSideSessionIndexByParent.set(parentSession, (index + 1) % navigationNotes.length);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async function archiveCurrentBtwAndNext(ctx: ExtensionContext): Promise<void> {
|
|
659
|
+
if (!ctx.hasUI) {
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const sideSession = getCurrentSideSessionInfo(ctx);
|
|
664
|
+
const parentSession = getParentSessionPath(ctx) ?? sideSession?.parentSession;
|
|
665
|
+
if (!sideSession || !parentSession) {
|
|
666
|
+
ctx.ui.notify("No current BTW side session to archive.", "warning");
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (!ctx.isIdle()) {
|
|
671
|
+
ctx.ui.notify("Wait for the assistant to finish before archiving this BTW note.", "warning");
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
const switchSession = getSwitchSession(ctx);
|
|
676
|
+
if (!switchSession) {
|
|
677
|
+
ctx.ui.notify("Session switching is unavailable here. Use /btw-back.", "warning");
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
try {
|
|
682
|
+
const result = await switchSession(parentSession, {
|
|
683
|
+
withSession: async (parentCtx) => {
|
|
684
|
+
restoreMissingParentNotes(parentCtx, sideSession.parentNotes);
|
|
685
|
+
const hasNext = hasLaterNavigationNote(getNavigationNotes(parentCtx), sideSession.noteId);
|
|
686
|
+
archiveSideSessionNote(parentCtx, sideSession);
|
|
687
|
+
updateBtwStatus(parentCtx);
|
|
688
|
+
if (hasNext) {
|
|
689
|
+
await openNextBtwSideSession(parentCtx);
|
|
690
|
+
} else {
|
|
691
|
+
parentCtx.ui.notify("No more BTW notes.", "info");
|
|
692
|
+
}
|
|
693
|
+
},
|
|
694
|
+
});
|
|
695
|
+
if (result.cancelled) {
|
|
696
|
+
ctx.ui.notify("BTW session switch was cancelled.", "warning");
|
|
697
|
+
}
|
|
698
|
+
} catch (error) {
|
|
699
|
+
ctx.ui.notify(`Could not archive BTW note: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function formatDateTime(value: string): string {
|
|
704
|
+
const date = new Date(value);
|
|
705
|
+
if (Number.isNaN(date.getTime())) {
|
|
706
|
+
return value;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
return date.toLocaleString(undefined, {
|
|
710
|
+
month: "short",
|
|
711
|
+
day: "numeric",
|
|
712
|
+
hour: "2-digit",
|
|
713
|
+
minute: "2-digit",
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function padToWidth(text: string, width: number): string {
|
|
718
|
+
const currentWidth = visibleWidth(text);
|
|
719
|
+
return currentWidth >= width ? text : `${text}${" ".repeat(width - currentWidth)}`;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function frameLine(text: string, width: number, theme: Theme): string {
|
|
723
|
+
const innerWidth = Math.max(0, width - 4);
|
|
724
|
+
return theme.fg("borderAccent", "│ ") + padToWidth(truncateToWidth(text, innerWidth), innerWidth) + theme.fg("borderAccent", " │");
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function frameEmpty(width: number, theme: Theme): string {
|
|
728
|
+
return frameLine("", width, theme);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function frameBorder(width: number, title: string | undefined, theme: Theme): string {
|
|
732
|
+
const innerWidth = Math.max(0, width - 2);
|
|
733
|
+
const label = title ? ` ${title} ` : "";
|
|
734
|
+
const left = label ? "─" : "";
|
|
735
|
+
const remaining = Math.max(0, innerWidth - label.length - left.length);
|
|
736
|
+
return theme.fg("borderAccent", `┌${left}${label}${"─".repeat(remaining)}┐`);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function frameBottom(width: number, theme: Theme): string {
|
|
740
|
+
return theme.fg("borderAccent", `└${"─".repeat(Math.max(0, width - 2))}┘`);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
class BtwNotesPanel implements Component {
|
|
744
|
+
private selectedIndex = 0;
|
|
745
|
+
private scrollOffset = 0;
|
|
746
|
+
|
|
747
|
+
constructor(
|
|
748
|
+
private readonly notes: BtwNote[],
|
|
749
|
+
private readonly sideSessionByNoteId: Map<string, BtwSideSession>,
|
|
750
|
+
private readonly currentSideSession: BtwSideSessionInfo | undefined,
|
|
751
|
+
private readonly theme: Theme,
|
|
752
|
+
private readonly done: (action: BtwPanelAction) => void,
|
|
753
|
+
) {}
|
|
754
|
+
|
|
755
|
+
handleInput(data: string): void {
|
|
756
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
757
|
+
this.done({ type: "close" });
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (this.currentSideSession && data.toLowerCase() === "r") {
|
|
762
|
+
this.done({ type: "return" });
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (this.notes.length === 0) {
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
if (matchesKey(data, Key.up)) {
|
|
771
|
+
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
772
|
+
this.syncScrollOffset();
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
if (matchesKey(data, Key.down)) {
|
|
777
|
+
this.selectedIndex = Math.min(this.notes.length - 1, this.selectedIndex + 1);
|
|
778
|
+
this.syncScrollOffset();
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const selectedNote = this.notes[this.selectedIndex];
|
|
783
|
+
if (matchesKey(data, Key.enter) || data.toLowerCase() === "o") {
|
|
784
|
+
this.done({ type: "open", noteId: selectedNote.id });
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
if (data.toLowerCase() === "d") {
|
|
789
|
+
this.done({ type: "toggle", noteId: selectedNote.id });
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
if (data.toLowerCase() === "x" || matchesKey(data, Key.delete)) {
|
|
794
|
+
this.done({ type: "archive", noteId: selectedNote.id });
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
render(width: number): string[] {
|
|
799
|
+
const panelWidth = Math.max(44, width);
|
|
800
|
+
const lines = [frameBorder(panelWidth, "BTW notes", this.theme)];
|
|
801
|
+
|
|
802
|
+
if (this.currentSideSession) {
|
|
803
|
+
lines.push(frameLine(this.theme.fg("warning", "Side session"), panelWidth, this.theme));
|
|
804
|
+
for (const wrappedLine of wrapTextWithAnsi(`Note: ${this.currentSideSession.noteText}`, Math.max(10, panelWidth - 4))) {
|
|
805
|
+
lines.push(frameLine(wrappedLine, panelWidth, this.theme));
|
|
806
|
+
}
|
|
807
|
+
lines.push(frameLine(this.theme.fg("dim", "r return to parent session"), panelWidth, this.theme));
|
|
808
|
+
lines.push(frameEmpty(panelWidth, this.theme));
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (this.notes.length === 0) {
|
|
812
|
+
lines.push(frameLine(this.theme.fg("dim", "No notes in this session."), panelWidth, this.theme));
|
|
813
|
+
lines.push(frameLine(this.theme.fg("dim", "Use /btw <note> to capture one."), panelWidth, this.theme));
|
|
814
|
+
} else {
|
|
815
|
+
const visibleNotes = this.notes.slice(this.scrollOffset, this.scrollOffset + NOTE_PANEL_MAX_VISIBLE_NOTES);
|
|
816
|
+
for (const [visibleIndex, note] of visibleNotes.entries()) {
|
|
817
|
+
const index = this.scrollOffset + visibleIndex;
|
|
818
|
+
const selected = index === this.selectedIndex;
|
|
819
|
+
const status = note.status === "done" ? this.theme.fg("success", "✓") : this.theme.fg("warning", "○");
|
|
820
|
+
const linked = this.sideSessionByNoteId.has(note.id) ? this.theme.fg("accent", "↪") : " ";
|
|
821
|
+
const prefix = selected ? this.theme.fg("accent", ">") : " ";
|
|
822
|
+
const text = selected ? this.theme.fg("accent", note.text) : note.text;
|
|
823
|
+
lines.push(frameLine(`${prefix} ${status} ${linked} ${text}`, panelWidth, this.theme));
|
|
824
|
+
if (selected) {
|
|
825
|
+
lines.push(frameLine(this.theme.fg("dim", ` ${formatDateTime(note.createdAt)}`), panelWidth, this.theme));
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
if (this.notes.length > NOTE_PANEL_MAX_VISIBLE_NOTES) {
|
|
830
|
+
lines.push(
|
|
831
|
+
frameLine(
|
|
832
|
+
this.theme.fg("dim", `${this.selectedIndex + 1}/${this.notes.length} • ↑↓ scroll`),
|
|
833
|
+
panelWidth,
|
|
834
|
+
this.theme,
|
|
835
|
+
),
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
lines.push(frameEmpty(panelWidth, this.theme));
|
|
841
|
+
lines.push(
|
|
842
|
+
frameLine(
|
|
843
|
+
this.theme.fg("dim", "enter/o discuss • d done/open • x archive • esc close"),
|
|
844
|
+
panelWidth,
|
|
845
|
+
this.theme,
|
|
846
|
+
),
|
|
847
|
+
);
|
|
848
|
+
lines.push(frameBottom(panelWidth, this.theme));
|
|
849
|
+
return lines;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
invalidate(): void {}
|
|
853
|
+
|
|
854
|
+
private syncScrollOffset(): void {
|
|
855
|
+
if (this.selectedIndex < this.scrollOffset) {
|
|
856
|
+
this.scrollOffset = this.selectedIndex;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const visibleEnd = this.scrollOffset + NOTE_PANEL_MAX_VISIBLE_NOTES - 1;
|
|
860
|
+
if (this.selectedIndex > visibleEnd) {
|
|
861
|
+
this.scrollOffset = this.selectedIndex - NOTE_PANEL_MAX_VISIBLE_NOTES + 1;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
async function showBtwPanel(ctx: ExtensionCommandContext): Promise<BtwPanelAction> {
|
|
867
|
+
const notes = getVisibleNotes(ctx);
|
|
868
|
+
const sideSessionByNoteId = getSideSessionByNoteId(listLinkedSideSessions(ctx));
|
|
869
|
+
const currentSideSession = getCurrentSideSessionInfo(ctx);
|
|
870
|
+
|
|
871
|
+
return ctx.ui.custom<BtwPanelAction>(
|
|
872
|
+
(tui, theme, _keybindings, done) => {
|
|
873
|
+
const panel = new BtwNotesPanel(notes, sideSessionByNoteId, currentSideSession, theme as Theme, done);
|
|
874
|
+
return {
|
|
875
|
+
render: (width: number) => panel.render(width),
|
|
876
|
+
invalidate: () => panel.invalidate(),
|
|
877
|
+
handleInput: (data: string) => {
|
|
878
|
+
panel.handleInput(data);
|
|
879
|
+
tui.requestRender();
|
|
880
|
+
},
|
|
881
|
+
};
|
|
882
|
+
},
|
|
883
|
+
{
|
|
884
|
+
overlay: true,
|
|
885
|
+
overlayOptions: {
|
|
886
|
+
width: "70%",
|
|
887
|
+
minWidth: 52,
|
|
888
|
+
maxHeight: "80%",
|
|
889
|
+
anchor: "center",
|
|
890
|
+
margin: 1,
|
|
891
|
+
},
|
|
892
|
+
},
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function getSideSessionIntro(note: BtwNote, parentSession: string): string {
|
|
897
|
+
return [
|
|
898
|
+
"This is a /btw side session linked to a BTW note.",
|
|
899
|
+
`Parent session: ${parentSession}`,
|
|
900
|
+
`Note: ${note.text}`,
|
|
901
|
+
"Use /btw-back or Shift+Left to archive and return, or Shift+Right to archive and open the next BTW session.",
|
|
902
|
+
].join("\n");
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
async function openNoteSideSession(ctx: ExtensionContext, note: BtwNote): Promise<boolean> {
|
|
906
|
+
if (!ctx.isIdle()) {
|
|
907
|
+
ctx.ui.notify("Wait for the assistant to finish before opening a BTW side session.", "warning");
|
|
908
|
+
return false;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
const linkedSession = getSideSessionByNoteId(listLinkedSideSessions(ctx)).get(note.id);
|
|
912
|
+
if (linkedSession) {
|
|
913
|
+
const switchSession = getSwitchSession(ctx);
|
|
914
|
+
if (!switchSession) {
|
|
915
|
+
ctx.ui.notify("Session switching is unavailable here. Use /btw.", "warning");
|
|
916
|
+
return false;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
clearBtwCaptureIndicator();
|
|
920
|
+
updateBtwStatus(ctx);
|
|
921
|
+
|
|
922
|
+
const result = await switchSession(linkedSession.sessionPath);
|
|
923
|
+
if (result.cancelled) {
|
|
924
|
+
ctx.ui.notify("BTW session switch was cancelled.", "warning");
|
|
925
|
+
return false;
|
|
926
|
+
}
|
|
927
|
+
return true;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
const parentSession = ctx.sessionManager.getSessionFile();
|
|
931
|
+
if (!parentSession) {
|
|
932
|
+
ctx.ui.notify("Cannot create a BTW side session from an unsaved session.", "warning");
|
|
933
|
+
return false;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
const newSession = getNewSession(ctx);
|
|
937
|
+
if (!newSession) {
|
|
938
|
+
ctx.ui.notify("Session creation is unavailable here. Use /btw to open the note.", "warning");
|
|
939
|
+
return false;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
const noteSnapshot = { ...note };
|
|
943
|
+
const parentNotesSnapshot = getNavigationNotes(ctx).map((candidate) => ({ ...candidate }));
|
|
944
|
+
const options: NewSessionOptions = {
|
|
945
|
+
parentSession,
|
|
946
|
+
setup: async (sideSessionManager: SessionManager) => {
|
|
947
|
+
sideSessionManager.appendCustomEntry(BTW_CUSTOM_TYPE, {
|
|
948
|
+
kind: "side-session",
|
|
949
|
+
version: 1,
|
|
950
|
+
noteId: noteSnapshot.id,
|
|
951
|
+
noteText: noteSnapshot.text,
|
|
952
|
+
parentSession,
|
|
953
|
+
parentNotes: parentNotesSnapshot,
|
|
954
|
+
createdAt: new Date().toISOString(),
|
|
955
|
+
});
|
|
956
|
+
sideSessionManager.appendCustomMessageEntry(BTW_CUSTOM_TYPE, getSideSessionIntro(noteSnapshot, parentSession), true, {
|
|
957
|
+
kind: "side-session",
|
|
958
|
+
noteId: noteSnapshot.id,
|
|
959
|
+
parentSession,
|
|
960
|
+
});
|
|
961
|
+
},
|
|
962
|
+
withSession: async (sideCtx: ReplacementSessionContext) => {
|
|
963
|
+
await sideCtx.sendUserMessage(normalizeBtwMessage(noteSnapshot.text));
|
|
964
|
+
sideCtx.ui.notify("Opened BTW side session.", "info");
|
|
965
|
+
},
|
|
966
|
+
};
|
|
967
|
+
|
|
968
|
+
clearBtwCaptureIndicator();
|
|
969
|
+
updateBtwStatus(ctx);
|
|
970
|
+
|
|
971
|
+
const result = await newSession(options);
|
|
972
|
+
if (result.cancelled) {
|
|
973
|
+
ctx.ui.notify("BTW side session creation was cancelled.", "warning");
|
|
974
|
+
return false;
|
|
975
|
+
}
|
|
976
|
+
return true;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
async function runBtwPanel(ctx: ExtensionCommandContext): Promise<void> {
|
|
980
|
+
while (true) {
|
|
981
|
+
const action = await showBtwPanel(ctx);
|
|
982
|
+
if (action.type === "close") {
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
if (action.type === "return") {
|
|
987
|
+
await returnToParentSession(ctx);
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
const note = getVisibleNotes(ctx).find((candidate) => candidate.id === action.noteId);
|
|
992
|
+
if (!note) {
|
|
993
|
+
ctx.ui.notify("BTW note no longer exists.", "warning");
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
if (action.type === "toggle") {
|
|
998
|
+
appendNoteStatus(ctx, note.id, note.status === "done" ? "open" : "done");
|
|
999
|
+
updateBtwStatus(ctx);
|
|
1000
|
+
continue;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
if (action.type === "archive") {
|
|
1004
|
+
appendNoteStatus(ctx, note.id, "archived");
|
|
1005
|
+
updateBtwStatus(ctx);
|
|
1006
|
+
continue;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
if (action.type === "open") {
|
|
1010
|
+
await openNoteSideSession(ctx, note);
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
export default async function (pi: ExtensionAPI) {
|
|
1017
|
+
await installInteractiveModeMonkeyPatch();
|
|
1018
|
+
|
|
1019
|
+
pi.on("session_start", (_event, ctx) => {
|
|
1020
|
+
updateBtwStatus(ctx);
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
pi.on("session_shutdown", () => {
|
|
1024
|
+
clearBtwCaptureIndicator();
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
pi.registerCommand("btw", {
|
|
1028
|
+
description: BTW_COMMAND_DESCRIPTION,
|
|
1029
|
+
handler: async (args, ctx) => {
|
|
1030
|
+
const raw = normalizeNoteText(args);
|
|
1031
|
+
if (!raw) {
|
|
1032
|
+
await runBtwPanel(ctx);
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
const note = appendNoteCreate(ctx, raw);
|
|
1037
|
+
updateBtwStatus(ctx);
|
|
1038
|
+
showBtwCaptureIndicator(ctx, note);
|
|
1039
|
+
ctx.ui.notify("BTW note captured. Run /btw to manage notes.", "info");
|
|
1040
|
+
},
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
pi.registerCommand("btw-back", {
|
|
1044
|
+
description: BTW_BACK_COMMAND,
|
|
1045
|
+
handler: async (_args, ctx) => {
|
|
1046
|
+
await returnToParentSession(ctx);
|
|
1047
|
+
},
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
pi.registerShortcut(BTW_NEXT_SHORTCUT, {
|
|
1051
|
+
description: "Open the next /btw note session",
|
|
1052
|
+
handler: async (ctx) => {
|
|
1053
|
+
await openNextBtwSideSession(ctx);
|
|
1054
|
+
},
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
for (const shortcut of BTW_BACK_SHORTCUTS) {
|
|
1058
|
+
pi.registerShortcut(shortcut, {
|
|
1059
|
+
description: "Archive the current note and return to the parent /btw session",
|
|
1060
|
+
handler: async (ctx) => {
|
|
1061
|
+
await returnToParentSession(ctx);
|
|
1062
|
+
},
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
}
|