pi-tau-web-server 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +303 -0
- package/bin/auth.js +96 -0
- package/bin/config.js +99 -0
- package/bin/model-utils.js +134 -0
- package/bin/server-main.js +1189 -0
- package/bin/sessions.js +492 -0
- package/bin/tau.js +8 -0
- package/bin/tree.js +248 -0
- package/bin/types.js +2 -0
- package/package.json +74 -0
- package/public/app-main.js +2025 -0
- package/public/app-types.js +1 -0
- package/public/app.js +1 -0
- package/public/command-palette.js +42 -0
- package/public/dialogs.js +199 -0
- package/public/file-browser.js +196 -0
- package/public/icons/apple-touch-icon.png +0 -0
- package/public/icons/favicon-16.png +0 -0
- package/public/icons/favicon-32.png +0 -0
- package/public/icons/tau-192.png +0 -0
- package/public/icons/tau-512.png +0 -0
- package/public/icons/tau-logo.png +0 -0
- package/public/icons/tau-logo.svg +13 -0
- package/public/icons/tau-maskable-512.png +0 -0
- package/public/icons/tau-new.png +0 -0
- package/public/index.html +264 -0
- package/public/launcher-panel.js +54 -0
- package/public/launcher.js +84 -0
- package/public/manifest.json +28 -0
- package/public/markdown.js +336 -0
- package/public/message-renderer.js +268 -0
- package/public/model-picker.js +478 -0
- package/public/session-sidebar.js +460 -0
- package/public/session-stats-card.js +123 -0
- package/public/state.js +81 -0
- package/public/style.css +3864 -0
- package/public/sw.js +66 -0
- package/public/themes.js +72 -0
- package/public/tool-card.js +317 -0
- package/public/tree-view.js +474 -0
- package/public/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/public/vendor/katex/katex.min.css +1 -0
- package/public/vendor/katex/katex.min.js +1 -0
- package/public/voice-input.js +74 -0
- package/public/websocket-client.js +156 -0
- package/scripts/copy-katex.mjs +32 -0
- package/src/pi-extension/tau-tree.ts +71 -0
- package/src/public/app-main.ts +2190 -0
- package/src/public/app-types.ts +96 -0
- package/src/public/app.ts +1 -0
- package/src/public/command-palette.ts +53 -0
- package/src/public/dialogs.ts +251 -0
- package/src/public/file-browser.ts +224 -0
- package/src/public/launcher-panel.ts +68 -0
- package/src/public/launcher.ts +101 -0
- package/src/public/legacy-dom.d.ts +29 -0
- package/src/public/markdown.ts +372 -0
- package/src/public/message-renderer.ts +311 -0
- package/src/public/model-picker.ts +500 -0
- package/src/public/session-sidebar.ts +522 -0
- package/src/public/session-stats-card.ts +176 -0
- package/src/public/state.ts +96 -0
- package/src/public/sw.ts +79 -0
- package/src/public/themes.ts +73 -0
- package/src/public/tool-card.ts +375 -0
- package/src/public/tree-view.ts +527 -0
- package/src/public/voice-input.ts +98 -0
- package/src/public/websocket-client.ts +165 -0
- package/src/server/auth.ts +88 -0
- package/src/server/config.ts +88 -0
- package/src/server/model-utils.ts +122 -0
- package/src/server/server-main.ts +1004 -0
- package/src/server/sessions.ts +481 -0
- package/src/server/tau.ts +9 -0
- package/src/server/tree.ts +288 -0
- package/src/server/types.ts +68 -0
- package/tsconfig.json +3 -0
- package/tsconfig.public.json +15 -0
- package/tsconfig.server.json +16 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export function setupVoiceInput(micBtn, messageInput) {
|
|
2
|
+
let recognition = null;
|
|
3
|
+
let isRecording = false;
|
|
4
|
+
if ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window) {
|
|
5
|
+
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
6
|
+
if (!SpeechRecognition) {
|
|
7
|
+
micBtn.style.display = 'none';
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
recognition = new SpeechRecognition();
|
|
11
|
+
recognition.continuous = true;
|
|
12
|
+
recognition.interimResults = true;
|
|
13
|
+
recognition.lang = 'en-AU';
|
|
14
|
+
let finalTranscript = '';
|
|
15
|
+
let interimTranscript = '';
|
|
16
|
+
recognition.addEventListener('result', (e) => {
|
|
17
|
+
const ev = e;
|
|
18
|
+
interimTranscript = '';
|
|
19
|
+
for (let i = ev.resultIndex; i < ev.results.length; i++) {
|
|
20
|
+
if (ev.results[i].isFinal) {
|
|
21
|
+
finalTranscript += ev.results[i][0].transcript;
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
interimTranscript += ev.results[i][0].transcript;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
messageInput.value = finalTranscript + interimTranscript;
|
|
28
|
+
messageInput.dispatchEvent(new Event('input'));
|
|
29
|
+
});
|
|
30
|
+
recognition.addEventListener('end', () => {
|
|
31
|
+
if (isRecording)
|
|
32
|
+
stopRecording();
|
|
33
|
+
});
|
|
34
|
+
recognition.addEventListener('error', (e) => {
|
|
35
|
+
const ev = e;
|
|
36
|
+
console.error('[Voice] Error:', ev.error);
|
|
37
|
+
stopRecording();
|
|
38
|
+
});
|
|
39
|
+
micBtn.addEventListener('click', () => {
|
|
40
|
+
if (isRecording) {
|
|
41
|
+
stopRecording();
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
startRecording();
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
function startRecording() {
|
|
48
|
+
if (!recognition)
|
|
49
|
+
return;
|
|
50
|
+
finalTranscript = messageInput.value;
|
|
51
|
+
interimTranscript = '';
|
|
52
|
+
isRecording = true;
|
|
53
|
+
micBtn.classList.add('recording');
|
|
54
|
+
micBtn.title = 'Stop recording';
|
|
55
|
+
recognition.start();
|
|
56
|
+
messageInput.focus();
|
|
57
|
+
}
|
|
58
|
+
function stopRecording() {
|
|
59
|
+
isRecording = false;
|
|
60
|
+
micBtn.classList.remove('recording');
|
|
61
|
+
micBtn.title = 'Voice input';
|
|
62
|
+
try {
|
|
63
|
+
recognition?.stop();
|
|
64
|
+
}
|
|
65
|
+
catch { }
|
|
66
|
+
messageInput.value = finalTranscript;
|
|
67
|
+
messageInput.dispatchEvent(new Event('input'));
|
|
68
|
+
messageInput.focus();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
micBtn.style.display = 'none';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket Client - Handles connection to backend WebSocket server
|
|
3
|
+
*/
|
|
4
|
+
export class WebSocketClient extends EventTarget {
|
|
5
|
+
url;
|
|
6
|
+
ws;
|
|
7
|
+
reconnectAttempts;
|
|
8
|
+
maxReconnectAttempts;
|
|
9
|
+
reconnectDelay;
|
|
10
|
+
maxReconnectDelay;
|
|
11
|
+
isIntentionallyClosed;
|
|
12
|
+
reconnectTimer;
|
|
13
|
+
connectionState;
|
|
14
|
+
constructor(url) {
|
|
15
|
+
super();
|
|
16
|
+
this.url = url;
|
|
17
|
+
this.ws = null;
|
|
18
|
+
this.reconnectAttempts = 0;
|
|
19
|
+
this.maxReconnectAttempts = Infinity;
|
|
20
|
+
this.reconnectDelay = 1000;
|
|
21
|
+
this.maxReconnectDelay = 10000;
|
|
22
|
+
this.isIntentionallyClosed = false;
|
|
23
|
+
this.reconnectTimer = null;
|
|
24
|
+
this.connectionState = 'idle';
|
|
25
|
+
}
|
|
26
|
+
connect() {
|
|
27
|
+
if (this.connectionState === 'connecting')
|
|
28
|
+
return;
|
|
29
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN)
|
|
30
|
+
return;
|
|
31
|
+
if (this.ws && this.ws.readyState === WebSocket.CONNECTING)
|
|
32
|
+
return;
|
|
33
|
+
this.isIntentionallyClosed = false;
|
|
34
|
+
this.connectionState = 'connecting';
|
|
35
|
+
if (this.reconnectTimer) {
|
|
36
|
+
clearTimeout(this.reconnectTimer);
|
|
37
|
+
this.reconnectTimer = null;
|
|
38
|
+
}
|
|
39
|
+
// Close only fully stale sockets before reconnecting
|
|
40
|
+
if (this.ws && (this.ws.readyState === WebSocket.CLOSING || this.ws.readyState === WebSocket.CLOSED)) {
|
|
41
|
+
this.ws = null;
|
|
42
|
+
}
|
|
43
|
+
this.ws = new WebSocket(this.url);
|
|
44
|
+
this.ws.onopen = () => {
|
|
45
|
+
console.log('[WS] Connected');
|
|
46
|
+
this.reconnectAttempts = 0;
|
|
47
|
+
this.connectionState = 'open';
|
|
48
|
+
this.dispatchEvent(new CustomEvent('connected'));
|
|
49
|
+
};
|
|
50
|
+
this.ws.onmessage = (event) => {
|
|
51
|
+
try {
|
|
52
|
+
const message = JSON.parse(event.data);
|
|
53
|
+
this.handleMessage(message);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
console.error('[WS] Failed to parse message:', error);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
this.ws.onerror = (error) => {
|
|
60
|
+
console.error('[WS] Error:', error);
|
|
61
|
+
this.dispatchEvent(new CustomEvent('error', { detail: error }));
|
|
62
|
+
};
|
|
63
|
+
this.ws.onclose = (event) => {
|
|
64
|
+
console.log(`[WS] Disconnected (code=${event.code}, reason=${event.reason || 'n/a'})`);
|
|
65
|
+
this.connectionState = 'closed';
|
|
66
|
+
this.dispatchEvent(new CustomEvent('disconnected'));
|
|
67
|
+
if (!this.isIntentionallyClosed) {
|
|
68
|
+
this.attemptReconnect();
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
disconnect() {
|
|
73
|
+
this.isIntentionallyClosed = true;
|
|
74
|
+
this.connectionState = 'closed';
|
|
75
|
+
if (this.reconnectTimer) {
|
|
76
|
+
clearTimeout(this.reconnectTimer);
|
|
77
|
+
this.reconnectTimer = null;
|
|
78
|
+
}
|
|
79
|
+
if (this.ws) {
|
|
80
|
+
this.ws.close();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Force reconnect — resets attempt counter and connects fresh
|
|
84
|
+
forceReconnect() {
|
|
85
|
+
this.reconnectAttempts = 0;
|
|
86
|
+
this.isIntentionallyClosed = false;
|
|
87
|
+
this.connectionState = 'closed';
|
|
88
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
89
|
+
try {
|
|
90
|
+
this.ws.close(1000, 'force reconnect');
|
|
91
|
+
}
|
|
92
|
+
catch (e) { }
|
|
93
|
+
}
|
|
94
|
+
if (this.reconnectTimer) {
|
|
95
|
+
clearTimeout(this.reconnectTimer);
|
|
96
|
+
this.reconnectTimer = null;
|
|
97
|
+
}
|
|
98
|
+
this.connect();
|
|
99
|
+
}
|
|
100
|
+
attemptReconnect() {
|
|
101
|
+
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
102
|
+
console.error('[WS] Max reconnection attempts reached');
|
|
103
|
+
this.dispatchEvent(new CustomEvent('reconnectFailed'));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
this.reconnectAttempts++;
|
|
107
|
+
const delay = Math.min(this.maxReconnectDelay, this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1));
|
|
108
|
+
console.log(`[WS] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
|
109
|
+
this.reconnectTimer = setTimeout(() => {
|
|
110
|
+
this.reconnectTimer = null;
|
|
111
|
+
this.connect();
|
|
112
|
+
}, delay);
|
|
113
|
+
}
|
|
114
|
+
send(data) {
|
|
115
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
116
|
+
this.ws.send(JSON.stringify(data));
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
console.error('[WS] Cannot send, not connected');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
handleMessage(message) {
|
|
123
|
+
// Emit events based on message type
|
|
124
|
+
switch (message.type) {
|
|
125
|
+
case 'event':
|
|
126
|
+
this.dispatchEvent(new CustomEvent('rpcEvent', { detail: { sessionId: message.sessionId, event: message.event } }));
|
|
127
|
+
break;
|
|
128
|
+
case 'state':
|
|
129
|
+
this.dispatchEvent(new CustomEvent('stateUpdate', { detail: message }));
|
|
130
|
+
break;
|
|
131
|
+
case 'error':
|
|
132
|
+
this.dispatchEvent(new CustomEvent('serverError', { detail: message }));
|
|
133
|
+
break;
|
|
134
|
+
case 'session_switch':
|
|
135
|
+
this.dispatchEvent(new CustomEvent('sessionSwitch'));
|
|
136
|
+
break;
|
|
137
|
+
case 'live_session_snapshot':
|
|
138
|
+
this.dispatchEvent(new CustomEvent('liveSessionSnapshot', { detail: message }));
|
|
139
|
+
break;
|
|
140
|
+
case 'response':
|
|
141
|
+
this.dispatchEvent(new CustomEvent('rpcResponse', { detail: message }));
|
|
142
|
+
break;
|
|
143
|
+
case 'live_session_created':
|
|
144
|
+
this.dispatchEvent(new CustomEvent('liveSessionCreated', { detail: message.session }));
|
|
145
|
+
break;
|
|
146
|
+
case 'live_session_updated':
|
|
147
|
+
this.dispatchEvent(new CustomEvent('liveSessionUpdated', { detail: message.session }));
|
|
148
|
+
break;
|
|
149
|
+
case 'live_session_closed':
|
|
150
|
+
this.dispatchEvent(new CustomEvent('liveSessionClosed', { detail: message }));
|
|
151
|
+
break;
|
|
152
|
+
default:
|
|
153
|
+
console.warn('[WS] Unknown message type:', message.type);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Copies the KaTeX runtime assets from node_modules into public/vendor/katex/
|
|
2
|
+
// so the served UI is self-contained and works offline. Only woff2 fonts are
|
|
3
|
+
// copied: KaTeX's CSS lists woff2 first in every @font-face src(), so browsers
|
|
4
|
+
// that can run this app never request the woff/ttf fallbacks.
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
|
|
12
|
+
let katexDist;
|
|
13
|
+
try {
|
|
14
|
+
katexDist = path.join(path.dirname(require.resolve('katex/package.json')), 'dist');
|
|
15
|
+
} catch {
|
|
16
|
+
console.error('copy-katex: cannot resolve the "katex" package — run `npm install` first.');
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const projectRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
21
|
+
const vendorDir = path.join(projectRoot, 'public', 'vendor', 'katex');
|
|
22
|
+
const fontsDir = path.join(vendorDir, 'fonts');
|
|
23
|
+
|
|
24
|
+
fs.mkdirSync(fontsDir, { recursive: true });
|
|
25
|
+
for (const file of ['katex.min.js', 'katex.min.css']) {
|
|
26
|
+
fs.copyFileSync(path.join(katexDist, file), path.join(vendorDir, file));
|
|
27
|
+
}
|
|
28
|
+
for (const font of fs.readdirSync(path.join(katexDist, 'fonts'))) {
|
|
29
|
+
if (font.endsWith('.woff2')) {
|
|
30
|
+
fs.copyFileSync(path.join(katexDist, 'fonts', font), path.join(fontsDir, font));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* pi extension bundled with tau and loaded into every `pi --mode rpc` child
|
|
3
|
+
* via `--extension <this file>` (see PiRpcSession.start in
|
|
4
|
+
* src/server/sessions.ts). It gives tau's backend a way to MOVE the session
|
|
5
|
+
* leaf: pi's native RPC surface only exposes read-only tree commands
|
|
6
|
+
* (get_tree / get_entries), so tau sends
|
|
7
|
+
*
|
|
8
|
+
* {"type": "prompt", "message": "/tau-tree-navigate <entryId>"}
|
|
9
|
+
*
|
|
10
|
+
* and this command handler performs the navigation in-process with
|
|
11
|
+
* ctx.navigateTree() — the same API pi's own TUI /tree uses, including its
|
|
12
|
+
* selection semantics (user/custom message → leaf moves to the entry's
|
|
13
|
+
* PARENT; anything else → leaf moves to the entry itself). Matched extension
|
|
14
|
+
* commands never reach the LLM and never append a user message, so the
|
|
15
|
+
* /tau-tree-navigate text is invisible to the model and to the session file.
|
|
16
|
+
*
|
|
17
|
+
* Persistence: navigateTree({summarize: false}) only moves the in-memory
|
|
18
|
+
* leaf pointer, and on load pi derives the leaf from the LAST entry in the
|
|
19
|
+
* file — so a bare navigation would not survive a pi restart. When the leaf
|
|
20
|
+
* actually moved, the handler appends a small `custom` marker entry (same
|
|
21
|
+
* `tau:navigate-tree` customType tau has always used) whose parentId is the
|
|
22
|
+
* new leaf. Custom entries never participate in LLM context, so the marker
|
|
23
|
+
* is invisible to the model too.
|
|
24
|
+
*
|
|
25
|
+
* Errors thrown here do NOT fail the RPC `prompt` response; pi reports them
|
|
26
|
+
* as an `extension_error` event (extensionPath "command:tau-tree-navigate").
|
|
27
|
+
* tau's navigateTree (src/server/tree.ts) therefore verifies the move with a
|
|
28
|
+
* follow-up get_tree and surfaces the captured extension_error on mismatch.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
// Type-only reference to pi's extension API. Written as an inline import()
|
|
32
|
+
// type (not an `import type` statement) because this file is type-checked as
|
|
33
|
+
// CommonJS by tsconfig.test.json while pi's package is ESM-only, and at
|
|
34
|
+
// runtime it is loaded by pi's jiti / node's type stripping, both of which
|
|
35
|
+
// erase pure type positions.
|
|
36
|
+
type ExtensionAPI = import('@earendil-works/pi-coding-agent', { with: { 'resolution-mode': 'import' } }).ExtensionAPI;
|
|
37
|
+
|
|
38
|
+
/** Command name tau's backend invokes; keep in sync with src/server/tree.ts. */
|
|
39
|
+
export const NAVIGATE_COMMAND = 'tau-tree-navigate';
|
|
40
|
+
|
|
41
|
+
/** customType of the marker entries that persist a leaf move across reloads. */
|
|
42
|
+
export const NAVIGATION_MARKER_TYPE = 'tau:navigate-tree';
|
|
43
|
+
|
|
44
|
+
export default function tauTreeExtension(pi: ExtensionAPI) {
|
|
45
|
+
pi.registerCommand(NAVIGATE_COMMAND, {
|
|
46
|
+
description: 'Move the session leaf to an entry (used by the tau web UI tree view)',
|
|
47
|
+
handler: async (args, ctx) => {
|
|
48
|
+
const entryId = args.trim();
|
|
49
|
+
if (!entryId) throw new Error(`/${NAVIGATE_COMMAND}: missing target entry id`);
|
|
50
|
+
if (!ctx.isIdle()) {
|
|
51
|
+
throw new Error('Cannot navigate the session tree while the agent is streaming; abort or wait for the turn to finish');
|
|
52
|
+
}
|
|
53
|
+
if (!ctx.sessionManager.getEntry(entryId)) {
|
|
54
|
+
throw new Error(`Entry ${entryId} not found in the session tree`);
|
|
55
|
+
}
|
|
56
|
+
const previousLeafId = ctx.sessionManager.getLeafId();
|
|
57
|
+
// Pass the ORIGINAL entry id: navigateTree applies the user-message →
|
|
58
|
+
// parent selection rule itself, so pre-resolving the target here would
|
|
59
|
+
// land the leaf one node too high.
|
|
60
|
+
const result = await ctx.navigateTree(entryId, { summarize: false });
|
|
61
|
+
if (result.cancelled) {
|
|
62
|
+
throw new Error('A pi extension cancelled the tree navigation');
|
|
63
|
+
}
|
|
64
|
+
// Persist the move only when the leaf actually changed (navigating to
|
|
65
|
+
// the current position appends nothing, mirroring pi's own /tree).
|
|
66
|
+
if (ctx.sessionManager.getLeafId() !== previousLeafId) {
|
|
67
|
+
pi.appendEntry(NAVIGATION_MARKER_TYPE, { navigatedTo: entryId });
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|