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.
Files changed (98) hide show
  1. package/README.md +303 -0
  2. package/bin/auth.js +96 -0
  3. package/bin/config.js +99 -0
  4. package/bin/model-utils.js +134 -0
  5. package/bin/server-main.js +1189 -0
  6. package/bin/sessions.js +492 -0
  7. package/bin/tau.js +8 -0
  8. package/bin/tree.js +248 -0
  9. package/bin/types.js +2 -0
  10. package/package.json +74 -0
  11. package/public/app-main.js +2025 -0
  12. package/public/app-types.js +1 -0
  13. package/public/app.js +1 -0
  14. package/public/command-palette.js +42 -0
  15. package/public/dialogs.js +199 -0
  16. package/public/file-browser.js +196 -0
  17. package/public/icons/apple-touch-icon.png +0 -0
  18. package/public/icons/favicon-16.png +0 -0
  19. package/public/icons/favicon-32.png +0 -0
  20. package/public/icons/tau-192.png +0 -0
  21. package/public/icons/tau-512.png +0 -0
  22. package/public/icons/tau-logo.png +0 -0
  23. package/public/icons/tau-logo.svg +13 -0
  24. package/public/icons/tau-maskable-512.png +0 -0
  25. package/public/icons/tau-new.png +0 -0
  26. package/public/index.html +264 -0
  27. package/public/launcher-panel.js +54 -0
  28. package/public/launcher.js +84 -0
  29. package/public/manifest.json +28 -0
  30. package/public/markdown.js +336 -0
  31. package/public/message-renderer.js +268 -0
  32. package/public/model-picker.js +478 -0
  33. package/public/session-sidebar.js +460 -0
  34. package/public/session-stats-card.js +123 -0
  35. package/public/state.js +81 -0
  36. package/public/style.css +3864 -0
  37. package/public/sw.js +66 -0
  38. package/public/themes.js +72 -0
  39. package/public/tool-card.js +317 -0
  40. package/public/tree-view.js +474 -0
  41. package/public/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  42. package/public/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  43. package/public/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  44. package/public/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  45. package/public/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  46. package/public/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  47. package/public/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  48. package/public/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  49. package/public/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  50. package/public/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  51. package/public/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  52. package/public/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  53. package/public/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  54. package/public/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  55. package/public/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  56. package/public/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  57. package/public/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  58. package/public/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  59. package/public/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  60. package/public/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  61. package/public/vendor/katex/katex.min.css +1 -0
  62. package/public/vendor/katex/katex.min.js +1 -0
  63. package/public/voice-input.js +74 -0
  64. package/public/websocket-client.js +156 -0
  65. package/scripts/copy-katex.mjs +32 -0
  66. package/src/pi-extension/tau-tree.ts +71 -0
  67. package/src/public/app-main.ts +2190 -0
  68. package/src/public/app-types.ts +96 -0
  69. package/src/public/app.ts +1 -0
  70. package/src/public/command-palette.ts +53 -0
  71. package/src/public/dialogs.ts +251 -0
  72. package/src/public/file-browser.ts +224 -0
  73. package/src/public/launcher-panel.ts +68 -0
  74. package/src/public/launcher.ts +101 -0
  75. package/src/public/legacy-dom.d.ts +29 -0
  76. package/src/public/markdown.ts +372 -0
  77. package/src/public/message-renderer.ts +311 -0
  78. package/src/public/model-picker.ts +500 -0
  79. package/src/public/session-sidebar.ts +522 -0
  80. package/src/public/session-stats-card.ts +176 -0
  81. package/src/public/state.ts +96 -0
  82. package/src/public/sw.ts +79 -0
  83. package/src/public/themes.ts +73 -0
  84. package/src/public/tool-card.ts +375 -0
  85. package/src/public/tree-view.ts +527 -0
  86. package/src/public/voice-input.ts +98 -0
  87. package/src/public/websocket-client.ts +165 -0
  88. package/src/server/auth.ts +88 -0
  89. package/src/server/config.ts +88 -0
  90. package/src/server/model-utils.ts +122 -0
  91. package/src/server/server-main.ts +1004 -0
  92. package/src/server/sessions.ts +481 -0
  93. package/src/server/tau.ts +9 -0
  94. package/src/server/tree.ts +288 -0
  95. package/src/server/types.ts +68 -0
  96. package/tsconfig.json +3 -0
  97. package/tsconfig.public.json +15 -0
  98. package/tsconfig.server.json +16 -0
@@ -0,0 +1,96 @@
1
+ export type ModelRecord = {
2
+ provider?: string;
3
+ id?: string;
4
+ name?: string;
5
+ model?: string;
6
+ label?: string;
7
+ context?: string | number;
8
+ contextWindow?: string | number;
9
+ context_window?: string | number;
10
+ maxOutput?: string | number;
11
+ max_output?: string | number;
12
+ maxOut?: string | number;
13
+ thinking?: boolean | string;
14
+ images?: boolean | string;
15
+ [key: string]: unknown;
16
+ };
17
+
18
+ export type LiveSession = {
19
+ id: string;
20
+ cwd?: string;
21
+ sessionFile?: string | null;
22
+ sessionName?: string | null;
23
+ modelSpec?: string;
24
+ modelLabel?: string;
25
+ model?: ModelRecord | string | null;
26
+ thinkingLevel?: string;
27
+ isStreaming?: boolean;
28
+ createdAt?: string;
29
+ lastActiveAt?: string;
30
+ contextUsage?: UsageRecord;
31
+ };
32
+
33
+ export type LiveInstance = {
34
+ sessionFile?: string | null;
35
+ cwd?: string;
36
+ port: string;
37
+ };
38
+
39
+ export type UsageRecord = {
40
+ input?: number;
41
+ output?: number;
42
+ cacheRead?: number;
43
+ cacheWrite?: number;
44
+ cost?: { total?: number };
45
+ [key: string]: unknown;
46
+ };
47
+
48
+ export type MessageContentBlock = {
49
+ type?: string;
50
+ text?: string;
51
+ thinking?: string;
52
+ source?: { data?: string; media_type?: string };
53
+ data?: string;
54
+ media_type?: string;
55
+ id?: string;
56
+ name?: string;
57
+ arguments?: Record<string, unknown>;
58
+ };
59
+
60
+ export type AppMessage = {
61
+ id?: string;
62
+ role?: string;
63
+ content?: string | MessageContentBlock[];
64
+ usage?: UsageRecord;
65
+ images?: PendingImage[];
66
+ toolCallId?: string;
67
+ isError?: boolean;
68
+ };
69
+
70
+ export type AppEvent = {
71
+ type?: string;
72
+ sessionId?: string;
73
+ session?: LiveSession;
74
+ message?: AppMessage | string;
75
+ assistantMessageEvent?: { type?: string; delta?: string };
76
+ toolCallId?: string;
77
+ toolName?: string;
78
+ args?: Record<string, unknown>;
79
+ partialResult?: unknown;
80
+ result?: unknown;
81
+ isError?: boolean;
82
+ method?: string;
83
+ id?: string;
84
+ name?: string;
85
+ error?: string;
86
+ summary?: string;
87
+ contextUsage?: UsageRecord;
88
+ sessionFile?: string;
89
+ [key: string]: unknown;
90
+ };
91
+
92
+ export type PendingImage = { data: string; mimeType: string };
93
+ export type PendingFilePath = { path: string; name: string; ext: string; sessionId?: string | null };
94
+ export type QueuedCommand = { type: string; message?: string; images?: PendingImage[]; sessionId?: string };
95
+ export type ExtensionUIRequest = { sessionId: string; event: AppEvent };
96
+ export type RpcCommand = { type: string; sessionId?: string; filePath?: string; [key: string]: unknown };
@@ -0,0 +1 @@
1
+ import './app-main.js';
@@ -0,0 +1,53 @@
1
+ type CommandPaletteItem = {
2
+ icon: string;
3
+ label: string;
4
+ desc: string;
5
+ action(): void;
6
+ };
7
+
8
+ export function setupCommandPalette(commands: CommandPaletteItem[]) {
9
+ // These elements are part of the app's static index.html shell, so they
10
+ // are present when this setup function runs; assert non-null at the query.
11
+ const commandBtn = document.getElementById('command-btn')!;
12
+ const commandPalette = document.getElementById('command-palette')!;
13
+ const commandPaletteOverlay = document.getElementById('command-palette-overlay')!;
14
+ const commandList = document.getElementById('command-list')!;
15
+
16
+ function open() {
17
+ commandList.innerHTML = '';
18
+ commands.forEach(cmd => {
19
+ const el = document.createElement('div');
20
+ el.className = 'command-item';
21
+ el.innerHTML = `
22
+ <div class="command-icon">${cmd.icon}</div>
23
+ <div>
24
+ <div class="command-label">${cmd.label}</div>
25
+ <div class="command-desc">${cmd.desc}</div>
26
+ </div>
27
+ `;
28
+ el.addEventListener('click', () => {
29
+ close();
30
+ cmd.action();
31
+ });
32
+ commandList.appendChild(el);
33
+ });
34
+ commandPalette.classList.remove('hidden');
35
+ commandPaletteOverlay.classList.remove('hidden');
36
+ }
37
+
38
+ function close() {
39
+ commandPalette.classList.add('hidden');
40
+ commandPaletteOverlay.classList.add('hidden');
41
+ }
42
+
43
+ function closeIfOpen() {
44
+ if (commandPalette.classList.contains('hidden')) return false;
45
+ close();
46
+ return true;
47
+ }
48
+
49
+ commandBtn.addEventListener('click', open);
50
+ commandPaletteOverlay.addEventListener('click', close);
51
+
52
+ return { close, closeIfOpen };
53
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Dialogs - Handles extension UI dialogs
3
+ */
4
+
5
+ export type DialogRequest = {
6
+ id?: string;
7
+ title?: string;
8
+ message?: string;
9
+ options?: string[];
10
+ placeholder?: string;
11
+ prefill?: string;
12
+ timeout?: number;
13
+ sessionId?: string;
14
+ method?: string;
15
+ [key: string]: unknown;
16
+ };
17
+
18
+ export class DialogHandler {
19
+ container: HTMLElement;
20
+ wsClient: { send(data: unknown): void };
21
+ getSessionId: (() => string | null) | null;
22
+ currentDialog: HTMLElement | null;
23
+ currentRequest: ({ sessionId?: string | null; request?: DialogRequest | null } & Record<string, unknown>) | null;
24
+ timeoutId: ReturnType<typeof setTimeout> | null;
25
+ onIdle: (() => void) | null;
26
+
27
+ constructor(container: HTMLElement, wsClient: { send(data: unknown): void }, getSessionId: (() => string | null) | null = null) {
28
+ this.container = container;
29
+ this.wsClient = wsClient;
30
+ this.getSessionId = getSessionId;
31
+ this.currentDialog = null;
32
+ this.currentRequest = null;
33
+ this.timeoutId = null;
34
+ this.onIdle = null;
35
+ }
36
+
37
+ showSelect(request: DialogRequest) {
38
+ this.cancelCurrentDialog(true);
39
+
40
+ const { id, title, options, timeout, sessionId } = request;
41
+
42
+ const dialog = document.createElement('div');
43
+ dialog.className = 'dialog';
44
+ dialog.innerHTML = `
45
+ <div class="dialog-title">${this.escapeHtml(title || 'Select an option')}</div>
46
+ <div class="dialog-options" id="dialog-options"></div>
47
+ <div class="dialog-actions">
48
+ <button id="dialog-cancel">Cancel</button>
49
+ </div>
50
+ `;
51
+
52
+ const optionsContainer = dialog.querySelector('#dialog-options')!;
53
+
54
+ (options || []).forEach((option: string) => {
55
+ const optionDiv = document.createElement('div');
56
+ optionDiv.className = 'dialog-option';
57
+ optionDiv.textContent = option;
58
+ optionDiv.onclick = () => {
59
+ this.respond(id, { value: option }, sessionId);
60
+ };
61
+ optionsContainer.appendChild(optionDiv);
62
+ });
63
+
64
+ dialog.querySelector('#dialog-cancel')!.onclick = () => {
65
+ this.respond(id, { cancelled: true }, sessionId);
66
+ };
67
+
68
+ this.showDialog(dialog, timeout, id, sessionId, request);
69
+ }
70
+
71
+ showConfirm(request: DialogRequest) {
72
+ this.cancelCurrentDialog(true);
73
+
74
+ const { id, title, message, timeout, sessionId } = request;
75
+
76
+ const dialog = document.createElement('div');
77
+ dialog.className = 'dialog';
78
+ dialog.innerHTML = `
79
+ <div class="dialog-title">${this.escapeHtml(title || 'Confirm')}</div>
80
+ ${message ? `<div class="dialog-message">${this.escapeHtml(message)}</div>` : ''}
81
+ <div class="dialog-actions">
82
+ <button id="dialog-no">No</button>
83
+ <button id="dialog-yes">Yes</button>
84
+ </div>
85
+ `;
86
+
87
+ dialog.querySelector('#dialog-yes')!.onclick = () => {
88
+ this.respond(id, { confirmed: true }, sessionId);
89
+ };
90
+
91
+ dialog.querySelector('#dialog-no')!.onclick = () => {
92
+ this.respond(id, { confirmed: false }, sessionId);
93
+ };
94
+
95
+ this.showDialog(dialog, timeout, id, sessionId, request);
96
+ }
97
+
98
+ showInput(request: DialogRequest) {
99
+ this.cancelCurrentDialog(true);
100
+
101
+ const { id, title, placeholder, timeout, sessionId } = request;
102
+
103
+ const dialog = document.createElement('div');
104
+ dialog.className = 'dialog';
105
+ dialog.innerHTML = `
106
+ <div class="dialog-title">${this.escapeHtml(title || 'Input')}</div>
107
+ <input type="text" class="dialog-input" id="dialog-input" placeholder="${this.escapeHtml(placeholder || '')}" />
108
+ <div class="dialog-actions">
109
+ <button id="dialog-cancel">Cancel</button>
110
+ <button id="dialog-submit">Submit</button>
111
+ </div>
112
+ `;
113
+
114
+ const input = dialog.querySelector<HTMLInputElement>('#dialog-input');
115
+ if (!input) return;
116
+
117
+ const submit = () => {
118
+ const value = input.value.trim();
119
+ this.respond(id, value ? { value } : { cancelled: true }, sessionId);
120
+ };
121
+
122
+ input.addEventListener('keypress', (e: KeyboardEvent) => {
123
+ if (e.key === 'Enter') submit();
124
+ });
125
+
126
+ dialog.querySelector('#dialog-submit')!.onclick = submit;
127
+ dialog.querySelector('#dialog-cancel')!.onclick = () => {
128
+ this.respond(id, { cancelled: true }, sessionId);
129
+ };
130
+
131
+ this.showDialog(dialog, timeout, id, sessionId, request);
132
+
133
+ // Focus input after a short delay
134
+ setTimeout(() => input.focus(), 100);
135
+ }
136
+
137
+ showEditor(request: DialogRequest) {
138
+ this.cancelCurrentDialog(true);
139
+
140
+ const { id, title, prefill, timeout, sessionId } = request;
141
+
142
+ const dialog = document.createElement('div');
143
+ dialog.className = 'dialog';
144
+ dialog.innerHTML = `
145
+ <div class="dialog-title">${this.escapeHtml(title || 'Editor')}</div>
146
+ <textarea class="dialog-textarea" id="dialog-textarea">${this.escapeHtml(prefill || '')}</textarea>
147
+ <div class="dialog-actions">
148
+ <button id="dialog-cancel">Cancel</button>
149
+ <button id="dialog-save">Save</button>
150
+ </div>
151
+ `;
152
+
153
+ const textarea = dialog.querySelector<HTMLTextAreaElement>('#dialog-textarea');
154
+ if (!textarea) return;
155
+
156
+ dialog.querySelector('#dialog-save')!.onclick = () => {
157
+ const value = textarea.value;
158
+ this.respond(id, value ? { value } : { cancelled: true }, sessionId);
159
+ };
160
+
161
+ dialog.querySelector('#dialog-cancel')!.onclick = () => {
162
+ this.respond(id, { cancelled: true }, sessionId);
163
+ };
164
+
165
+ this.showDialog(dialog, timeout, id, sessionId, request);
166
+
167
+ // Focus textarea after a short delay
168
+ setTimeout(() => textarea.focus(), 100);
169
+ }
170
+
171
+ showNotification(request: DialogRequest) {
172
+ const { message, notifyType } = request;
173
+
174
+ // Create a temporary notification element
175
+ const notification = document.createElement('div');
176
+ notification.className = 'error-message';
177
+ notification.textContent = `${notifyType === 'error' ? '⚠️' : notifyType === 'warning' ? '⚠️' : 'ℹ️'} ${message}`;
178
+
179
+ // Add to messages container temporarily
180
+ const messagesContainer = document.getElementById('messages');
181
+ if (messagesContainer) {
182
+ messagesContainer.appendChild(notification);
183
+ messagesContainer.scrollTop = messagesContainer.scrollHeight;
184
+
185
+ // Remove after 5 seconds
186
+ setTimeout(() => {
187
+ notification.remove();
188
+ }, 5000);
189
+ }
190
+ }
191
+
192
+ showDialog(dialogElement: HTMLElement, timeout: number | undefined, requestId: string | undefined, sessionId: string | null = null, request: DialogRequest | null = null) {
193
+ this.currentDialog = dialogElement;
194
+ this.currentRequest = { id: requestId, sessionId, request };
195
+ this.container.innerHTML = '';
196
+ this.container.appendChild(dialogElement);
197
+ this.container.classList.remove('hidden');
198
+
199
+ // Set up timeout if specified
200
+ if (timeout) {
201
+ this.timeoutId = setTimeout(() => {
202
+ this.respond(requestId, { cancelled: true }, sessionId);
203
+ }, timeout);
204
+ }
205
+ }
206
+
207
+ cancelCurrentDialog(suppressIdle = false) {
208
+ if (!this.currentRequest?.id) {
209
+ this.clearCurrentDialog();
210
+ return;
211
+ }
212
+ const { id, sessionId } = this.currentRequest;
213
+ this.clearCurrentDialog();
214
+ this.wsClient.send({
215
+ type: 'extension_ui_response',
216
+ id,
217
+ sessionId: sessionId || this.getSessionId?.(),
218
+ cancelled: true,
219
+ });
220
+ if (!suppressIdle) this.onIdle?.();
221
+ }
222
+
223
+ clearCurrentDialog() {
224
+ if (this.timeoutId) {
225
+ clearTimeout(this.timeoutId);
226
+ this.timeoutId = null;
227
+ }
228
+
229
+ this.container.innerHTML = '';
230
+ this.container.classList.add('hidden');
231
+ this.currentDialog = null;
232
+ this.currentRequest = null;
233
+ }
234
+
235
+ respond(id: string | undefined, response: Record<string, unknown>, sessionId: string | null = null) {
236
+ this.clearCurrentDialog();
237
+ this.wsClient.send({
238
+ type: 'extension_ui_response',
239
+ id,
240
+ sessionId: sessionId || this.getSessionId?.(),
241
+ ...response
242
+ });
243
+ this.onIdle?.();
244
+ }
245
+
246
+ escapeHtml(text: string) {
247
+ const div = document.createElement('div');
248
+ div.textContent = text;
249
+ return div.innerHTML;
250
+ }
251
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * File Browser — right sidebar file tree with drag-and-drop
3
+ */
4
+
5
+ const FILE_ICONS: Record<string, string> = {
6
+ // Folders
7
+ directory: '📁',
8
+ // Code
9
+ js: '📄', ts: '📄', jsx: '📄', tsx: '📄',
10
+ py: '🐍', rb: '💎', go: '📄', rs: '🦀',
11
+ // Web
12
+ html: '🌐', css: '🎨', svg: '🎨',
13
+ // Data
14
+ json: '📋', yaml: '📋', yml: '📋', toml: '📋',
15
+ xml: '📋', csv: '📋',
16
+ // Docs
17
+ md: '📝', txt: '📝', rst: '📝',
18
+ // Images
19
+ png: '🖼️', jpg: '🖼️', jpeg: '🖼️', gif: '🖼️',
20
+ webp: '🖼️', ico: '🖼️',
21
+ // Config
22
+ env: '🔒', gitignore: '🔒', lock: '🔒',
23
+ // Default
24
+ default: '📄',
25
+ };
26
+
27
+ type FileItem = {
28
+ name: string;
29
+ path: string;
30
+ isDirectory: boolean;
31
+ size?: number | null;
32
+ mtime?: number;
33
+ };
34
+
35
+ export function getFileIcon(name: string, isDirectory: boolean) {
36
+ if (isDirectory) return FILE_ICONS.directory;
37
+ const ext = name.split('.').pop()?.toLowerCase() || '';
38
+ return FILE_ICONS[ext] || FILE_ICONS.default;
39
+ }
40
+
41
+ function formatSize(bytes?: number | null) {
42
+ if (bytes == null) return '';
43
+ if (bytes < 1024) return `${bytes}B`;
44
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}K`;
45
+ return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
46
+ }
47
+
48
+ export class FileBrowser {
49
+ container: HTMLElement;
50
+ pathEl: HTMLElement;
51
+ messageInput: HTMLElement;
52
+ onFileInserted: ((filePath: string) => void) | null;
53
+ getSessionId: (() => string | null) | null;
54
+ currentPath: string | null;
55
+
56
+ constructor(
57
+ container: HTMLElement,
58
+ pathEl: HTMLElement,
59
+ messageInput: HTMLElement,
60
+ onFileInserted: ((filePath: string) => void) | null = null,
61
+ getSessionId: (() => string | null) | null = null
62
+ ) {
63
+ this.container = container;
64
+ this.pathEl = pathEl;
65
+ this.messageInput = messageInput;
66
+ this.onFileInserted = onFileInserted;
67
+ this.getSessionId = getSessionId;
68
+ this.currentPath = null;
69
+
70
+ this.setupDropTarget();
71
+ }
72
+
73
+ async load(dirPath?: string | null) {
74
+ this.container.innerHTML = '<div class="file-loading">Loading…</div>';
75
+
76
+ try {
77
+ const params = new URLSearchParams();
78
+ const sessionId = this.getSessionId?.();
79
+ if (!sessionId) {
80
+ this.currentPath = null;
81
+ this.pathEl.textContent = '';
82
+ this.pathEl.title = '';
83
+ this.container.innerHTML = '<div class="file-loading">Select a Tau tab to browse files</div>';
84
+ return;
85
+ }
86
+ params.set('sessionId', sessionId);
87
+ if (dirPath) params.set('path', dirPath);
88
+ const qs = params.toString();
89
+ const url = qs ? `/api/files?${qs}` : '/api/files';
90
+ const res = await fetch(url);
91
+ const data = await res.json();
92
+
93
+ if (data.error) {
94
+ this.container.innerHTML = `<div class="file-loading">${data.error}</div>`;
95
+ return;
96
+ }
97
+
98
+ this.currentPath = data.path;
99
+ this.pathEl.textContent = data.path;
100
+ this.pathEl.title = data.path;
101
+ this.render(data.items);
102
+ } catch (err) {
103
+ this.container.innerHTML = '<div class="file-loading">Failed to load</div>';
104
+ }
105
+ }
106
+
107
+ getParentPath() {
108
+ if (!this.currentPath) return null;
109
+ const sep = this.currentPath.includes('\\') ? '\\' : '/';
110
+ const normalized = this.currentPath.endsWith(sep) ? this.currentPath.slice(0, -1) : this.currentPath;
111
+ const lastSep = normalized.lastIndexOf(sep);
112
+ if (lastSep <= 0) return sep === '/' ? '/' : null;
113
+ const parent = normalized.slice(0, lastSep);
114
+ return /^[A-Za-z]:$/.test(parent) ? parent + sep : parent;
115
+ }
116
+
117
+ render(items: FileItem[]) {
118
+ this.container.innerHTML = '';
119
+
120
+ if (items.length === 0) {
121
+ this.container.innerHTML = '<div class="file-loading">Empty directory</div>';
122
+ return;
123
+ }
124
+
125
+ for (const item of items) {
126
+ const el = document.createElement('div');
127
+ el.className = `file-item${item.isDirectory ? ' directory' : ''}`;
128
+ el.draggable = true;
129
+ el.dataset.path = item.path;
130
+ el.dataset.name = item.name;
131
+ el.dataset.isDirectory = String(item.isDirectory);
132
+
133
+ const icon = getFileIcon(item.name, item.isDirectory);
134
+ const size = item.isDirectory ? '' : formatSize(item.size);
135
+
136
+ el.innerHTML = `
137
+ <span class="file-icon">${icon}</span>
138
+ <span class="file-name" title="${item.name}">${item.name}</span>
139
+ ${size ? `<span class="file-size">${size}</span>` : ''}
140
+ `;
141
+
142
+ // Click: navigate directory or insert file path into input
143
+ el.addEventListener('click', () => {
144
+ if (item.isDirectory) {
145
+ this.load(item.path);
146
+ } else {
147
+ this.insertPath(item.path);
148
+ }
149
+ });
150
+
151
+ // Double-click: open file natively
152
+ el.addEventListener('dblclick', (e) => {
153
+ e.preventDefault();
154
+ if (!item.isDirectory) {
155
+ this.openNatively(item.path);
156
+ }
157
+ });
158
+
159
+ // Drag start
160
+ el.addEventListener('dragstart', (e) => {
161
+ if (!e.dataTransfer) return;
162
+ e.dataTransfer.setData('text/plain', item.path);
163
+ e.dataTransfer.effectAllowed = 'copy';
164
+ el.classList.add('dragging');
165
+ });
166
+
167
+ el.addEventListener('dragend', () => {
168
+ el.classList.remove('dragging');
169
+ });
170
+
171
+ this.container.appendChild(el);
172
+ }
173
+ }
174
+
175
+ async openNatively(filePath: string) {
176
+ try {
177
+ const sessionId = this.getSessionId?.();
178
+ if (!sessionId) return;
179
+ await fetch('/api/open', {
180
+ method: 'POST',
181
+ headers: { 'Content-Type': 'application/json' },
182
+ body: JSON.stringify({ filePath, sessionId }),
183
+ });
184
+ } catch (err) {
185
+ console.error('[FileBrowser] Failed to open:', err);
186
+ }
187
+ }
188
+
189
+ insertPath(filePath: string) {
190
+ const input = this.messageInput;
191
+ const start = input.selectionStart ?? 0;
192
+ const end = input.selectionEnd ?? 0;
193
+ input.value = input.value.substring(0, start) + filePath + ' ' + input.value.substring(end);
194
+ input.selectionStart = input.selectionEnd = start + filePath.length + 1;
195
+ input.focus();
196
+ input.dispatchEvent(new Event('input'));
197
+ if (this.onFileInserted) this.onFileInserted(filePath);
198
+ }
199
+
200
+ setupDropTarget() {
201
+ const input = this.messageInput;
202
+
203
+ input.addEventListener('dragover', (e) => {
204
+ e.preventDefault();
205
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
206
+ input.classList.add('file-drop-hover');
207
+ });
208
+
209
+ input.addEventListener('dragleave', () => {
210
+ input.classList.remove('file-drop-hover');
211
+ });
212
+
213
+ input.addEventListener('drop', (e) => {
214
+ e.preventDefault();
215
+ input.classList.remove('file-drop-hover');
216
+
217
+ const filePath = e.dataTransfer?.getData('text/plain') ?? '';
218
+ // Accept Unix paths (/) and Windows paths (C:\ or C:/)
219
+ if (filePath && (filePath.startsWith('/') || /^[A-Za-z]:[\\\/]/.test(filePath))) {
220
+ this.insertPath(filePath);
221
+ }
222
+ });
223
+ }
224
+ }
@@ -0,0 +1,68 @@
1
+ import { Launcher } from './launcher.js';
2
+
3
+ type LauncherPanelOptions = {
4
+ launcherEl: HTMLElement;
5
+ messagesContainer: HTMLElement;
6
+ createSession(projectPath: string): Promise<void>;
7
+ };
8
+
9
+ export function setupLauncherPanel({ launcherEl, messagesContainer, createSession }: LauncherPanelOptions) {
10
+ const launcher = new Launcher(launcherEl, createSession);
11
+
12
+ async function init() {
13
+ try {
14
+ const res = await fetch('/api/projects');
15
+ const data = await res.json();
16
+ if (data.projects && data.projects.length > 0) {
17
+ launcher.projects = data.projects;
18
+ launcher.render();
19
+ addLauncherNav();
20
+ }
21
+ } catch {}
22
+ }
23
+
24
+ function addLauncherNav() {
25
+ const modeToggle = document.getElementById('mode-toggle');
26
+ if (!modeToggle || modeToggle.querySelector('.mode-link-launcher')) return;
27
+
28
+ const launcherLink = document.createElement('span');
29
+ launcherLink.className = 'mode-link mode-link-launcher';
30
+ launcherLink.title = 'Projects';
31
+ launcherLink.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>';
32
+ launcherLink.addEventListener('click', () => show());
33
+ modeToggle.appendChild(launcherLink);
34
+ }
35
+
36
+ function isVisible() {
37
+ return !!launcherEl && !launcherEl.classList.contains('hidden');
38
+ }
39
+
40
+ function show() {
41
+ launcherEl.classList.remove('hidden');
42
+ messagesContainer.style.display = 'none';
43
+ const inputArea = document.querySelector<HTMLElement>('.input-area');
44
+ if (inputArea) inputArea.style.display = 'none';
45
+ document.querySelector('.welcome')?.remove();
46
+
47
+ document.querySelectorAll('.mode-link').forEach(l => l.classList.remove('active'));
48
+ document.querySelector('.mode-link-launcher')?.classList.add('active');
49
+
50
+ launcher.load();
51
+ }
52
+
53
+ function hide() {
54
+ launcherEl.classList.add('hidden');
55
+ messagesContainer.style.display = '';
56
+ const inputArea = document.querySelector<HTMLElement>('.input-area');
57
+ if (inputArea) inputArea.style.display = '';
58
+
59
+ document.querySelectorAll('.mode-link').forEach(l => l.classList.remove('active'));
60
+ document.querySelector('.mode-link:first-child')?.classList.add('active');
61
+ }
62
+
63
+ document.querySelector('.mode-link:first-child')?.addEventListener('click', () => {
64
+ hide();
65
+ });
66
+
67
+ return { init, isVisible, show, hide };
68
+ }