pi-sdk-web 0.2.3 → 0.3.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/dist/cli.js +31 -14
- package/dist/server.js +151 -12
- package/dist/static/app.js +89 -11
- package/dist/static/index.html +1 -0
- package/dist/static/style.css +38 -0
- package/dist/ui-context.js +10 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* pi-web list List all sessions (name, id, cwd), newest first
|
|
8
8
|
* pi-web help Show this help
|
|
9
9
|
*/
|
|
10
|
-
import { SettingsManager, createAgentSessionFromServices, createAgentSessionServices, getAgentDir, resolveModelScopeWithDiagnostics, } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { SettingsManager, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, getAgentDir, resolveModelScopeWithDiagnostics, } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import { findSessionByName, listSessions, loadBuiltinExtensions } from "./session.js";
|
|
12
12
|
import { PiWebServer } from "./server.js";
|
|
13
13
|
const DEFAULT_PORT = 4080;
|
|
@@ -56,21 +56,38 @@ async function cmdResume(name, port) {
|
|
|
56
56
|
// the same modelRuntime used below, so scopedModels resolution sees them.
|
|
57
57
|
const agentDir = getAgentDir();
|
|
58
58
|
const settingsManager = SettingsManager.create(sessionManager.getCwd(), agentDir);
|
|
59
|
-
|
|
59
|
+
// Runtime factory: re-invoked by AgentSessionRuntime whenever the session is
|
|
60
|
+
// replaced (e.g. /resume switches to another session with a different cwd).
|
|
61
|
+
const createRuntime = async ({ cwd, agentDir: dir, sessionManager: sm, sessionStartEvent }) => {
|
|
62
|
+
const settings = SettingsManager.create(cwd, dir);
|
|
63
|
+
const services = await createAgentSessionServices({
|
|
64
|
+
cwd,
|
|
65
|
+
agentDir: dir,
|
|
66
|
+
settingsManager: settings,
|
|
67
|
+
resourceLoaderOptions: { extensionFactories: await loadBuiltinExtensions() },
|
|
68
|
+
});
|
|
69
|
+
// Resolve enabledModels (settings) into scopedModels, matching Pi's CLI
|
|
70
|
+
const enabledModels = settings.getEnabledModels();
|
|
71
|
+
const scopedModels = enabledModels && enabledModels.length > 0
|
|
72
|
+
? (await resolveModelScopeWithDiagnostics(enabledModels, services.modelRuntime, {
|
|
73
|
+
signal: AbortSignal.timeout(15_000),
|
|
74
|
+
})).scopedModels
|
|
75
|
+
: [];
|
|
76
|
+
const created = await createAgentSessionFromServices({
|
|
77
|
+
services,
|
|
78
|
+
sessionManager: sm,
|
|
79
|
+
sessionStartEvent,
|
|
80
|
+
scopedModels,
|
|
81
|
+
});
|
|
82
|
+
return { ...created, services, diagnostics: [] };
|
|
83
|
+
};
|
|
84
|
+
const runtime = await createAgentSessionRuntime(createRuntime, {
|
|
60
85
|
cwd: sessionManager.getCwd(),
|
|
61
86
|
agentDir,
|
|
62
|
-
|
|
63
|
-
resourceLoaderOptions: { extensionFactories: await loadBuiltinExtensions() },
|
|
87
|
+
sessionManager,
|
|
64
88
|
});
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
const scopedModels = enabledModels && enabledModels.length > 0
|
|
68
|
-
? (await resolveModelScopeWithDiagnostics(enabledModels, services.modelRuntime, {
|
|
69
|
-
signal: AbortSignal.timeout(15_000),
|
|
70
|
-
})).scopedModels
|
|
71
|
-
: [];
|
|
72
|
-
const { session } = await createAgentSessionFromServices({ services, sessionManager, scopedModels });
|
|
73
|
-
const server = new PiWebServer(session, { port });
|
|
89
|
+
const { session } = runtime;
|
|
90
|
+
const server = new PiWebServer(runtime, { port });
|
|
74
91
|
await server.start();
|
|
75
92
|
console.log(`server at http://127.0.0.1:${port}/ (session: ${info.name ?? info.id})`);
|
|
76
93
|
let shuttingDown = false;
|
|
@@ -86,7 +103,7 @@ async function cmdResume(name, port) {
|
|
|
86
103
|
// ignore teardown errors - we still need to exit
|
|
87
104
|
}
|
|
88
105
|
try {
|
|
89
|
-
|
|
106
|
+
runtime.dispose();
|
|
90
107
|
}
|
|
91
108
|
catch {
|
|
92
109
|
// ignore
|
package/dist/server.js
CHANGED
|
@@ -16,6 +16,7 @@ import { dirname, join, resolve, sep } from "node:path";
|
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
17
|
import { ModelRegistry, VERSION, } from "@earendil-works/pi-coding-agent";
|
|
18
18
|
import { WebSocket, WebSocketServer } from "ws";
|
|
19
|
+
import { listSessions } from "./session.js";
|
|
19
20
|
import { WebUIContext } from "./ui-context.js";
|
|
20
21
|
const DEFAULT_PORT = 4080;
|
|
21
22
|
// Static frontend: prefer the in-package copy (built by `npm run build` for
|
|
@@ -62,7 +63,7 @@ const MIME = {
|
|
|
62
63
|
".ico": "image/x-icon",
|
|
63
64
|
};
|
|
64
65
|
export class PiWebServer {
|
|
65
|
-
|
|
66
|
+
runtime;
|
|
66
67
|
port;
|
|
67
68
|
staticDir;
|
|
68
69
|
uiContext;
|
|
@@ -70,8 +71,12 @@ export class PiWebServer {
|
|
|
70
71
|
wsServer = null;
|
|
71
72
|
clients = new Set();
|
|
72
73
|
unsubscribe = null;
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
/** Current session (may change on /resume session switching). */
|
|
75
|
+
get session() {
|
|
76
|
+
return this.runtime.session;
|
|
77
|
+
}
|
|
78
|
+
constructor(runtime, options = {}) {
|
|
79
|
+
this.runtime = runtime;
|
|
75
80
|
this.port = options.port ?? DEFAULT_PORT;
|
|
76
81
|
this.staticDir = options.staticDir ?? STATIC_DIR;
|
|
77
82
|
this.uiContext = new WebUIContext((obj) => this.broadcast(obj));
|
|
@@ -80,13 +85,10 @@ export class PiWebServer {
|
|
|
80
85
|
// Lifecycle
|
|
81
86
|
// ------------------------------------------------------------------
|
|
82
87
|
async start() {
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
// but not terminal-only UI
|
|
88
|
-
mode: "rpc",
|
|
89
|
-
});
|
|
88
|
+
// Rebind hook: invoked by the runtime whenever the session is replaced
|
|
89
|
+
// (/resume switches to another session).
|
|
90
|
+
this.runtime.setRebindSession(() => this.bindCurrentSession(true));
|
|
91
|
+
await this.bindCurrentSession(false);
|
|
90
92
|
this.httpServer = createServer((req, res) => this.handleHttp(req, res));
|
|
91
93
|
// Some components (ws internals, MCP-style extensions) accumulate 'close'
|
|
92
94
|
// listeners on the server; raise the limit to avoid MaxListenersExceededWarning
|
|
@@ -98,13 +100,46 @@ export class PiWebServer {
|
|
|
98
100
|
this.httpServer.once("error", reject);
|
|
99
101
|
this.httpServer.listen(this.port, "127.0.0.1", () => resolvePromise());
|
|
100
102
|
});
|
|
101
|
-
|
|
102
|
-
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Bind extensions + subscribe events for the current session, and sync the
|
|
106
|
+
* process cwd. On session replacement (`afterSwitch`), also tell browsers to
|
|
107
|
+
* reload their view (new state + history) and switch the process cwd.
|
|
108
|
+
*/
|
|
109
|
+
async bindCurrentSession(afterSwitch) {
|
|
110
|
+
const session = this.runtime.session;
|
|
111
|
+
// Unsubscribe from the previous session first (also avoids stale events
|
|
112
|
+
// during teardown/dispose of the old session).
|
|
113
|
+
this.unsubscribe?.();
|
|
114
|
+
this.unsubscribe = null;
|
|
115
|
+
await session.bindExtensions({
|
|
116
|
+
uiContext: this.uiContext,
|
|
117
|
+
// "rpc" is the closest ExtensionMode: dialog-capable UI (hasUI=true),
|
|
118
|
+
// but not terminal-only UI
|
|
119
|
+
mode: "rpc",
|
|
120
|
+
});
|
|
121
|
+
this.unsubscribe = session.subscribe((event) => {
|
|
103
122
|
this.broadcast(event);
|
|
104
123
|
if (STATS_REFRESH_EVENTS.has(event.type)) {
|
|
105
124
|
this.broadcastStats();
|
|
106
125
|
}
|
|
107
126
|
});
|
|
127
|
+
if (afterSwitch) {
|
|
128
|
+
// Align process cwd with the resumed session (same as startup chdir)
|
|
129
|
+
const cwd = session.sessionManager.getCwd();
|
|
130
|
+
if (cwd) {
|
|
131
|
+
try {
|
|
132
|
+
process.chdir(cwd);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
console.error(`Session cwd not found (${cwd}), keeping current directory`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Stale extension dialogs/status from the previous session are dropped
|
|
139
|
+
this.uiContext.clearSessionState();
|
|
140
|
+
this.broadcastState();
|
|
141
|
+
this.broadcastHistory();
|
|
142
|
+
}
|
|
108
143
|
}
|
|
109
144
|
async stop() {
|
|
110
145
|
this.unsubscribe?.();
|
|
@@ -172,6 +207,10 @@ export class PiWebServer {
|
|
|
172
207
|
// state unavailable - skip
|
|
173
208
|
}
|
|
174
209
|
}
|
|
210
|
+
/** Broadcast the current session's history (used after session switching). */
|
|
211
|
+
broadcastHistory() {
|
|
212
|
+
this.broadcast({ type: "history", data: this.buildHistory() });
|
|
213
|
+
}
|
|
175
214
|
// ------------------------------------------------------------------
|
|
176
215
|
// HTTP: static files
|
|
177
216
|
// ------------------------------------------------------------------
|
|
@@ -487,10 +526,110 @@ export class PiWebServer {
|
|
|
487
526
|
await this.executeCommand(name, args);
|
|
488
527
|
break;
|
|
489
528
|
}
|
|
529
|
+
case "session":
|
|
530
|
+
// Read-only session info (TUI /session equivalent)
|
|
531
|
+
this.broadcastSessionInfo();
|
|
532
|
+
break;
|
|
533
|
+
case "get_sessions": {
|
|
534
|
+
// All sessions except the current one (for /resume)
|
|
535
|
+
const currentFile = this.session.sessionFile;
|
|
536
|
+
const all = await listSessions();
|
|
537
|
+
this.broadcast({
|
|
538
|
+
type: "sessions",
|
|
539
|
+
data: all
|
|
540
|
+
.filter((s) => s.path !== currentFile)
|
|
541
|
+
.map((s) => ({ name: s.name ?? "", id: s.id, cwd: s.cwd, path: s.path })),
|
|
542
|
+
});
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
case "resume": {
|
|
546
|
+
const path = typeof data.path === "string" && data.path ? data.path : "";
|
|
547
|
+
if (!path)
|
|
548
|
+
throw new Error("Missing 'path'");
|
|
549
|
+
await this.resumeSession(path);
|
|
550
|
+
break;
|
|
551
|
+
}
|
|
490
552
|
default:
|
|
491
553
|
throw new Error(`Unsupported command: ${cmdType}`);
|
|
492
554
|
}
|
|
493
555
|
}
|
|
556
|
+
/**
|
|
557
|
+
* Resume (switch to) another session file. The runtime tears down the current
|
|
558
|
+
* session, creates the new one and invokes our rebind callback, which rebinds
|
|
559
|
+
* extensions, resubscribes, switches cwd and broadcasts state/history.
|
|
560
|
+
*/
|
|
561
|
+
async resumeSession(path) {
|
|
562
|
+
// Unsubscribe from the current session before teardown so stale events from
|
|
563
|
+
// the old session are not broadcast during disposal.
|
|
564
|
+
this.unsubscribe?.();
|
|
565
|
+
this.unsubscribe = null;
|
|
566
|
+
try {
|
|
567
|
+
const result = await this.runtime.switchSession(path);
|
|
568
|
+
if (result.cancelled)
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
catch (err) {
|
|
572
|
+
// Rebind hook already ran on failure? No: on error the old session may be
|
|
573
|
+
// gone - resubscribe defensively to keep the server usable.
|
|
574
|
+
await this.bindCurrentSession(false);
|
|
575
|
+
throw err;
|
|
576
|
+
}
|
|
577
|
+
// On success the rebind hook (bindCurrentSession(true)) already ran inside
|
|
578
|
+
// switchSession; nothing more to do here.
|
|
579
|
+
}
|
|
580
|
+
/** TUI /session equivalent: show session info as a modal (read-only). */ broadcastSessionInfo() {
|
|
581
|
+
try {
|
|
582
|
+
const stats = this.session.getSessionStats();
|
|
583
|
+
const sm = this.session.sessionManager;
|
|
584
|
+
const entries = sm.getEntries();
|
|
585
|
+
const model = this.session.model;
|
|
586
|
+
const lines = [];
|
|
587
|
+
lines.push("## Session Info", "");
|
|
588
|
+
const name = sm.getSessionName();
|
|
589
|
+
if (name)
|
|
590
|
+
lines.push(`**Name:** ${name}`);
|
|
591
|
+
lines.push(`**ID:** ${this.session.sessionId}`);
|
|
592
|
+
if (stats.sessionFile)
|
|
593
|
+
lines.push(`**File:** ${stats.sessionFile}`);
|
|
594
|
+
if (model)
|
|
595
|
+
lines.push(`**Model:** ${model.provider}/${model.id}`);
|
|
596
|
+
lines.push(`**Thinking:** ${this.session.thinkingLevel}`, "");
|
|
597
|
+
lines.push("### Messages");
|
|
598
|
+
lines.push(`- User: ${stats.userMessages}`);
|
|
599
|
+
lines.push(`- Assistant: ${stats.assistantMessages}`);
|
|
600
|
+
lines.push(`- Tool calls: ${stats.toolCalls}`);
|
|
601
|
+
lines.push(`- Tool results: ${stats.toolResults}`);
|
|
602
|
+
lines.push(`- Total: ${stats.totalMessages}`);
|
|
603
|
+
lines.push(`- Entries: ${entries.length}`, "");
|
|
604
|
+
const t = stats.tokens;
|
|
605
|
+
if (t) {
|
|
606
|
+
lines.push("### Tokens");
|
|
607
|
+
lines.push(`- Input: ${t.input}`);
|
|
608
|
+
lines.push(`- Output: ${t.output}`);
|
|
609
|
+
lines.push(`- Cache read: ${t.cacheRead}`);
|
|
610
|
+
lines.push(`- Cache write: ${t.cacheWrite}`);
|
|
611
|
+
lines.push(`- Total: ${t.total}`, "");
|
|
612
|
+
}
|
|
613
|
+
lines.push("### Cost");
|
|
614
|
+
lines.push(`$${stats.cost.toFixed(4)}`);
|
|
615
|
+
const cu = stats.contextUsage;
|
|
616
|
+
if (cu?.contextWindow) {
|
|
617
|
+
lines.push("", "### Context");
|
|
618
|
+
lines.push(`- ${cu.percent}% / ${cu.contextWindow} tokens`);
|
|
619
|
+
}
|
|
620
|
+
this.broadcast({
|
|
621
|
+
type: "extension_ui_request",
|
|
622
|
+
id: crypto.randomUUID(),
|
|
623
|
+
method: "notify",
|
|
624
|
+
title: "/session",
|
|
625
|
+
message: lines.join("\n"),
|
|
626
|
+
notifyType: "info",
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
catch {
|
|
630
|
+
// session info unavailable - skip
|
|
631
|
+
}
|
|
632
|
+
}
|
|
494
633
|
/**
|
|
495
634
|
* Execute an extension slash command (e.g. /ctx-status) by invoking its
|
|
496
635
|
* registered handler with a command context.
|
package/dist/static/app.js
CHANGED
|
@@ -8,6 +8,8 @@ const BUILTIN_COMMANDS = [
|
|
|
8
8
|
{ name: 'compact', description: 'Manually compact the session context', builtin: true, action: 'compact' },
|
|
9
9
|
{ name: 'reload', description: 'Reload session resources and extensions', builtin: true, action: 'reload' },
|
|
10
10
|
{ name: 'export', description: 'Export session to HTML (or .jsonl)', builtin: true, action: 'export' },
|
|
11
|
+
{ name: 'session', description: 'Show session information', builtin: true, action: 'session' },
|
|
12
|
+
{ name: 'resume', description: 'Switch to another session', builtin: true, action: 'resume' },
|
|
11
13
|
{ name: 'name', description: 'Set session display name', builtin: true, action: 'name' },
|
|
12
14
|
{ name: 'login', description: 'Configure provider authentication (not supported in web)', builtin: true, unsupported: true },
|
|
13
15
|
{ name: 'logout', description: 'Remove provider authentication (not supported in web)', builtin: true, unsupported: true },
|
|
@@ -164,6 +166,9 @@ class PiWebClient {
|
|
|
164
166
|
case 'scoped_models':
|
|
165
167
|
this.handleScopedModels(data.data);
|
|
166
168
|
break;
|
|
169
|
+
case 'sessions':
|
|
170
|
+
this.handleSessions(data.data);
|
|
171
|
+
break;
|
|
167
172
|
case 'error':
|
|
168
173
|
this.appendError(data.error);
|
|
169
174
|
break;
|
|
@@ -589,6 +594,19 @@ class PiWebClient {
|
|
|
589
594
|
this.toolTimers.clear();
|
|
590
595
|
this.toolEls.clear();
|
|
591
596
|
this.streaming = { active: false, el: null, role: 'assistant' };
|
|
597
|
+
// Drop stale UI state from the previous session (on /resume reload)
|
|
598
|
+
const widgets = document.getElementById('widgets');
|
|
599
|
+
if (widgets) {
|
|
600
|
+
widgets.innerHTML = '';
|
|
601
|
+
widgets.style.display = 'none';
|
|
602
|
+
}
|
|
603
|
+
this.extStatus = {};
|
|
604
|
+
const extStatusEl = document.getElementById('ext-status');
|
|
605
|
+
if (extStatusEl) extStatusEl.style.display = 'none';
|
|
606
|
+
const pendingEl = document.getElementById('pending');
|
|
607
|
+
if (pendingEl) pendingEl.style.display = 'none';
|
|
608
|
+
const statusEl = document.getElementById('status');
|
|
609
|
+
if (statusEl) statusEl.style.display = 'none';
|
|
592
610
|
}
|
|
593
611
|
|
|
594
612
|
// ------------------------------------------------------------------
|
|
@@ -1202,6 +1220,10 @@ class PiWebClient {
|
|
|
1202
1220
|
} else if (cmd.action === 'export') {
|
|
1203
1221
|
const path = (args || '').trim();
|
|
1204
1222
|
this.send({ type: 'export', path: path });
|
|
1223
|
+
} else if (cmd.action === 'session') {
|
|
1224
|
+
this.send({ type: 'session' });
|
|
1225
|
+
} else if (cmd.action === 'resume') {
|
|
1226
|
+
this.openResumePicker();
|
|
1205
1227
|
} else if (cmd.action === 'name') {
|
|
1206
1228
|
const newName = window.prompt('Set session display name:', '');
|
|
1207
1229
|
if (newName && newName.trim()) {
|
|
@@ -1469,12 +1491,30 @@ class PiWebClient {
|
|
|
1469
1491
|
}
|
|
1470
1492
|
|
|
1471
1493
|
openExtensionNotify(req) {
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1494
|
+
// Command output notifications (title starts with "/", e.g. /ctx-status) keep
|
|
1495
|
+
// the modal. Other extension notifies are lightweight toasts (TUI shows
|
|
1496
|
+
// notify as a transient status message, not a dialog).
|
|
1497
|
+
if (req.title && String(req.title).startsWith('/')) {
|
|
1498
|
+
this.openModal(req.title || 'Notification', 'extension-notify');
|
|
1499
|
+
this.currentExtRequest = req;
|
|
1500
|
+
this.modalSearch.style.display = 'none';
|
|
1501
|
+
this.modalList.innerHTML = `<div class="modal-message body-text">${this.renderMarkdown(req.message || '')}</div>`;
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
this.showToast(req.message || '', req.notifyType);
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
showToast(message, type) {
|
|
1508
|
+
let container = document.getElementById('toast-container');
|
|
1509
|
+
if (!container) return;
|
|
1510
|
+
const toast = document.createElement('div');
|
|
1511
|
+
toast.className = 'toast' + (type ? ` toast-${type}` : '');
|
|
1512
|
+
toast.textContent = message;
|
|
1513
|
+
container.appendChild(toast);
|
|
1514
|
+
setTimeout(() => {
|
|
1515
|
+
toast.classList.add('toast-hide');
|
|
1516
|
+
setTimeout(() => toast.remove(), 300);
|
|
1517
|
+
}, type === 'error' ? 8000 : 5000);
|
|
1478
1518
|
}
|
|
1479
1519
|
|
|
1480
1520
|
handleModels(models) {
|
|
@@ -1510,6 +1550,31 @@ class PiWebClient {
|
|
|
1510
1550
|
});
|
|
1511
1551
|
}
|
|
1512
1552
|
|
|
1553
|
+
// ------------------------------------------------------------------
|
|
1554
|
+
// Resume picker (switch to another session)
|
|
1555
|
+
// ------------------------------------------------------------------
|
|
1556
|
+
|
|
1557
|
+
openResumePicker() {
|
|
1558
|
+
this.openModal('Resume Session', 'resume');
|
|
1559
|
+
this.modalList.innerHTML = '<div class="modal-message">Loading sessions...</div>';
|
|
1560
|
+
this.send({ type: 'get_sessions' });
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
handleSessions(data) {
|
|
1564
|
+
if (this.modalMode !== 'resume') return;
|
|
1565
|
+
const sessions = data || [];
|
|
1566
|
+
if (sessions.length === 0) {
|
|
1567
|
+
this.modalList.innerHTML = '<div class="modal-message">No other sessions available</div>';
|
|
1568
|
+
return;
|
|
1569
|
+
}
|
|
1570
|
+
this.renderModalItems(
|
|
1571
|
+
sessions.map((s) => ({ name: s.name || s.id, desc: s.cwd, value: s.path })),
|
|
1572
|
+
(item) => {
|
|
1573
|
+
this.send({ type: 'resume', path: item.value });
|
|
1574
|
+
},
|
|
1575
|
+
);
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1513
1578
|
// ------------------------------------------------------------------
|
|
1514
1579
|
// Scoped models picker (multi-select: models available for cycling)
|
|
1515
1580
|
// ------------------------------------------------------------------
|
|
@@ -1614,11 +1679,24 @@ class PiWebClient {
|
|
|
1614
1679
|
...((this.lastState && this.lastState.commands) || []),
|
|
1615
1680
|
...BUILTIN_COMMANDS,
|
|
1616
1681
|
];
|
|
1617
|
-
const filtered = commands
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1682
|
+
const filtered = commands
|
|
1683
|
+
.filter((c) => {
|
|
1684
|
+
const name = (c.name || '').replace(/^skill:/, '').toLowerCase();
|
|
1685
|
+
const desc = (c.description || c.source || '').toLowerCase();
|
|
1686
|
+
return name.includes(query) || desc.includes(query);
|
|
1687
|
+
})
|
|
1688
|
+
.sort((a, b) => {
|
|
1689
|
+
// Exact-name match first, then name-prefix, then name-contains,
|
|
1690
|
+
// then description-contains (avoids unrelated commands flooding the list)
|
|
1691
|
+
const score = (c) => {
|
|
1692
|
+
const name = (c.name || '').replace(/^skill:/, '').toLowerCase();
|
|
1693
|
+
if (name === query) return 0;
|
|
1694
|
+
if (name.startsWith(query)) return 1;
|
|
1695
|
+
if (name.includes(query)) return 2;
|
|
1696
|
+
return 3;
|
|
1697
|
+
};
|
|
1698
|
+
return score(a) - score(b);
|
|
1699
|
+
});
|
|
1622
1700
|
|
|
1623
1701
|
if (filtered.length === 0) {
|
|
1624
1702
|
this.hideCommandMenu();
|
package/dist/static/index.html
CHANGED
package/dist/static/style.css
CHANGED
|
@@ -734,6 +734,44 @@ body {
|
|
|
734
734
|
color: var(--text);
|
|
735
735
|
}
|
|
736
736
|
|
|
737
|
+
/* Lightweight extension notifications (TUI shows notify as transient status) */
|
|
738
|
+
#toast-container {
|
|
739
|
+
position: fixed;
|
|
740
|
+
top: 12px;
|
|
741
|
+
right: 12px;
|
|
742
|
+
z-index: 200;
|
|
743
|
+
display: flex;
|
|
744
|
+
flex-direction: column;
|
|
745
|
+
gap: 8px;
|
|
746
|
+
max-width: 420px;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
.toast {
|
|
750
|
+
background: var(--modal-bg);
|
|
751
|
+
border: 1px solid var(--border);
|
|
752
|
+
border-left: 3px solid var(--accent);
|
|
753
|
+
border-radius: 6px;
|
|
754
|
+
padding: 8px 12px;
|
|
755
|
+
color: var(--text);
|
|
756
|
+
font-size: 13px;
|
|
757
|
+
line-height: 1.5;
|
|
758
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
759
|
+
opacity: 1;
|
|
760
|
+
transition: opacity 0.3s;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
.toast-warning {
|
|
764
|
+
border-left-color: var(--warning);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
.toast-error {
|
|
768
|
+
border-left-color: var(--error);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
.toast-hide {
|
|
772
|
+
opacity: 0;
|
|
773
|
+
}
|
|
774
|
+
|
|
737
775
|
.modal-message {
|
|
738
776
|
color: var(--text);
|
|
739
777
|
font-size: 13px;
|
package/dist/ui-context.js
CHANGED
|
@@ -30,6 +30,16 @@ export class WebUIContext {
|
|
|
30
30
|
getStatusSnapshot() {
|
|
31
31
|
return Object.fromEntries(this.statusMap);
|
|
32
32
|
}
|
|
33
|
+
/** Drop state tied to the previous session (dialogs + status snapshots). */
|
|
34
|
+
clearSessionState() {
|
|
35
|
+
for (const pending of this.pending.values()) {
|
|
36
|
+
if (pending.timer)
|
|
37
|
+
clearTimeout(pending.timer);
|
|
38
|
+
pending.resolve(undefined);
|
|
39
|
+
}
|
|
40
|
+
this.pending.clear();
|
|
41
|
+
this.statusMap.clear();
|
|
42
|
+
}
|
|
33
43
|
/** Handle a browser `extension_ui_response` message. */
|
|
34
44
|
respond(id, response) {
|
|
35
45
|
const pending = this.pending.get(id);
|