Changeset 0.21.3 (#89)
This commit is contained in:
17
CHANGELOG.md
17
CHANGELOG.md
@@ -2,6 +2,23 @@
|
|||||||
|
|
||||||
All notable changes to Chat Switchboard.
|
All notable changes to Chat Switchboard.
|
||||||
|
|
||||||
|
## [0.21.3] — 2026-03-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Surface Registry (`surfaces.js`).** Mode-switching system that allows extensions to register "surfaces" (UI modes) that take over named regions of the interface. DOM nodes are detached and preserved in `DocumentFragment`s during mode switches — not destroyed — ensuring CM6 editor instances and other stateful components survive transitions.
|
||||||
|
- **Surface region attributes.** Four `data-surface-region` attributes on `index.html` containers: `surface-header` (model bar), `surface-main` (chat messages), `surface-footer` (input area), `sidebar-content` (search + chat history). Extensions swap these regions via `ctx.ui.replace()` / `ctx.ui.restore()`.
|
||||||
|
- **Mode selector.** Appears in the sidebar (`#modeSelectorWrap`) when ≥1 extension surface is registered beyond the default chat. Shows icon buttons for each mode with active state highlighting.
|
||||||
|
- **Extension context API.** `ctx.surfaces.register()`, `ctx.surfaces.unregister()`, `ctx.surfaces.activate()`, `ctx.surfaces.getCurrent()` on the scoped extension context. `ctx.ui.replace()` and `ctx.ui.restore()` for region DOM management.
|
||||||
|
- **Surface events.** `surface.activated`, `surface.deactivated`, `surface.registered`, `surface.unregistered` on the EventBus (all `localOnly`).
|
||||||
|
- **REPL console (`repl.js`).** Fourth tab in the debug modal (Ctrl+Shift+L). `AsyncFunction` wrapper for top-level `await`. Injected globals: `API`, `Events`, `Extensions`, `Surfaces`, `DebugLog`, `Panels`, `UI`, plus `$()`, `$$()`, `sleep()` helpers. Features: collapsible JSON pretty-printing, red errors with expandable stack traces, command history via ↑/↓ (persisted to sessionStorage), tab-completion on live object graphs and EventBus labels, multi-line input (Shift+Enter), admin-gated (admin role OR `?debug=1` URL param).
|
||||||
|
- **CSS.** Mode selector styles (`.mode-selector`, `.mode-btn`, active/hover states). REPL styles (output formatting, value-type color coding, collapsible JSON, input prompt).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `extensions.js` context builder: added `ctx.surfaces` namespace and `ctx.ui.replace()` / `ctx.ui.restore()` methods.
|
||||||
|
- `app.js` startup: initializes `Surfaces.init()` before `Extensions.loadAll()`, and `REPL.init()` after auth confirmation.
|
||||||
|
- `index.html`: added `data-surface-region` attributes, mode selector container, REPL tab in debug modal, script tags for `surfaces.js` and `repl.js`.
|
||||||
|
- `EXTENSIONS.md` §6: updated with implementation details, actual API signatures, and event catalog.
|
||||||
|
|
||||||
## [0.21.2] — 2026-03-01
|
## [0.21.2] — 2026-03-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -300,28 +300,36 @@ tools. The editor extension defines `read_file`, `write_file`,
|
|||||||
|
|
||||||
## 6. Surfaces (Modes)
|
## 6. Surfaces (Modes)
|
||||||
|
|
||||||
|
*Implemented in v0.21.3.*
|
||||||
|
|
||||||
A "mode" is an extension that registers a **surface** — a UI region that
|
A "mode" is an extension that registers a **surface** — a UI region that
|
||||||
replaces or augments the default chat area.
|
replaces or augments the default chat area. The surface system preserves
|
||||||
|
DOM state across mode switches (critical for CM6 editor instances).
|
||||||
|
|
||||||
### 6.1 Surface Regions
|
### 6.1 Surface Regions
|
||||||
|
|
||||||
|
The following `data-surface-region` attributes are present in `index.html`:
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────┐
|
||||||
│ sidebar-top │ surface-header │
|
│ sidebar-top │ surface-header │
|
||||||
|
│ │ (model-bar) │
|
||||||
|
│ mode-selector │ │
|
||||||
|
│ (auto-shown │ surface-main │
|
||||||
|
│ when ≥2 │ (chatMessages) │
|
||||||
|
│ surfaces) │ │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ sidebar-nav │ │
|
│ sidebar-content │ surface-footer │
|
||||||
│ (mode selector) │ surface-main │
|
│ (search+chats) │ (input-area) │
|
||||||
│ │ (chat, editor, article, │
|
|
||||||
│ sidebar-content │ cluster, ...) │
|
|
||||||
│ (context panel) │ │
|
|
||||||
│ │ │
|
│ │ │
|
||||||
│ sidebar-bottom │ surface-footer │
|
│ sidebar-bottom │ │
|
||||||
│ │ (input area) │
|
|
||||||
└─────────────────────────────────────────────┘
|
└─────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6.2 Surface Registration
|
### 6.2 Surface Registration
|
||||||
|
|
||||||
|
**Manifest-based** (planned — extension loader integration):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"surfaces": [
|
"surfaces": [
|
||||||
@@ -336,11 +344,16 @@ replaces or augments the default chat area.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Imperative** (implemented):
|
||||||
|
|
||||||
```js
|
```js
|
||||||
Extensions.register({
|
Extensions.register({
|
||||||
id: 'editor-mode',
|
id: 'editor-mode',
|
||||||
init(ctx) {
|
init(ctx) {
|
||||||
ctx.surfaces.register('editor', {
|
ctx.surfaces.register('editor', {
|
||||||
|
label: 'Editor',
|
||||||
|
icon: 'code',
|
||||||
|
regions: ['surface-main', 'surface-footer', 'sidebar-content'],
|
||||||
activate() {
|
activate() {
|
||||||
ctx.ui.replace('surface-main', this.renderEditor());
|
ctx.ui.replace('surface-main', this.renderEditor());
|
||||||
ctx.ui.replace('surface-footer', this.renderEditorInput());
|
ctx.ui.replace('surface-footer', this.renderEditorInput());
|
||||||
@@ -356,12 +369,35 @@ Extensions.register({
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Region management API** (on `ctx.ui`):
|
||||||
|
|
||||||
|
- `ctx.ui.replace(regionId, element)` — saves current children to a
|
||||||
|
`DocumentFragment` (detached, not destroyed), inserts new element.
|
||||||
|
- `ctx.ui.restore(regionId)` — re-attaches saved children.
|
||||||
|
|
||||||
|
This is critical for CM6 — editor instances survive mode switches because
|
||||||
|
their DOM nodes are detached (preserving internal state) rather than removed.
|
||||||
|
|
||||||
|
**Surface management API** (on `ctx.surfaces`):
|
||||||
|
|
||||||
|
- `ctx.surfaces.register(id, opts)` — register a new surface
|
||||||
|
- `ctx.surfaces.unregister(id)` — unregister (switches to chat if active)
|
||||||
|
- `ctx.surfaces.activate(id)` — switch to a surface
|
||||||
|
- `ctx.surfaces.getCurrent()` — returns active surface id
|
||||||
|
|
||||||
### 6.3 Mode Selector
|
### 6.3 Mode Selector
|
||||||
|
|
||||||
When extensions register surfaces, a mode selector appears in the sidebar.
|
When extensions register surfaces, a mode selector appears in the sidebar
|
||||||
Clicking a mode calls `activate()` on that surface and `deactivate()` on
|
(`#modeSelectorWrap`) below the brand/new-chat area. Clicking a mode calls
|
||||||
the current one. The bus event `surface.activated` fires so other
|
`activate()` on that surface and `deactivate()` on the current one.
|
||||||
extensions can react.
|
|
||||||
|
Events fired:
|
||||||
|
```js
|
||||||
|
Events.on('surface.activated', ({ surface, previous }) => { ... });
|
||||||
|
Events.on('surface.deactivated', ({ surface }) => { ... });
|
||||||
|
Events.on('surface.registered', ({ surface }) => { ... });
|
||||||
|
Events.on('surface.unregistered', ({ surface }) => { ... });
|
||||||
|
```
|
||||||
|
|
||||||
### 6.4 Core Surface
|
### 6.4 Core Surface
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ v0.21.0 Workspace Storage Primitive ✅
|
|||||||
│
|
│
|
||||||
┌───────┴──────────────┐
|
┌───────┴──────────────┐
|
||||||
│ │
|
│ │
|
||||||
v0.21.1 Workspace ✅ v0.21.3 Surface Infra
|
v0.21.1 Workspace ✅ v0.21.3 Surface Infra ✅
|
||||||
Tools + Bindings + REPL (parallel)
|
Tools + Bindings + REPL (parallel)
|
||||||
│ │
|
│ │
|
||||||
v0.21.2 Workspace ✅ v0.21.5 Editor Surface
|
v0.21.2 Workspace ✅ v0.21.5 Editor Surface
|
||||||
@@ -724,15 +724,28 @@ Embed text files for semantic search. Reuses `knowledge.SplitText` + `knowledge.
|
|||||||
- [x] Graceful degradation when no embedding role configured
|
- [x] Graceful degradation when no embedding role configured
|
||||||
- [x] Mock store updated for FS unit tests
|
- [x] Mock store updated for FS unit tests
|
||||||
|
|
||||||
### v0.21.3 — Surface Infrastructure + REPL
|
### v0.21.3 — Surface Infrastructure + REPL ✅
|
||||||
|
|
||||||
Pure UI architecture. No workspace dependency (parallel development).
|
Pure UI architecture. No workspace dependency (parallel development).
|
||||||
|
|
||||||
- [ ] Surface registration: `ctx.surfaces.register()` with label, icon, regions, activate/deactivate
|
- [x] `data-surface-region` attributes on index.html containers (surface-header, surface-main, surface-footer, sidebar-content)
|
||||||
- [ ] Region management: `ctx.ui.replace()` / `ctx.ui.restore()` for CM6 state preservation
|
- [x] `surfaces.js` — SurfaceRegistry: register, activate, deactivate, getCurrent, list
|
||||||
- [ ] Mode selector in sidebar when ≥1 extension surface registered
|
- [x] `ctx.ui.replace()` / `ctx.ui.restore()` with DOM preservation (DocumentFragment-based)
|
||||||
- [ ] `surface.activated` / `surface.deactivated` events
|
- [x] `ctx.surfaces.register()` API for extensions
|
||||||
- [ ] REPL console ([#70](https://git.gobha.me/xcaliber/chat-switchboard/issues/70)): fourth debug modal tab, AsyncFunction wrapper, injected globals, command history, tab-completion, admin-gated
|
- [x] Mode selector component in sidebar (`#modeSelectorWrap`, auto-shown when ≥2 surfaces)
|
||||||
|
- [x] Chat as default surface (implicit, always registered)
|
||||||
|
- [x] `surface.activated` / `surface.deactivated` / `surface.registered` / `surface.unregistered` EventBus events
|
||||||
|
- [x] REPL tab: AsyncFunction wrapper, global injection (API, Events, Extensions, Surfaces, DebugLog, Panels, UI, $, $$, sleep)
|
||||||
|
- [x] REPL tab: pretty-print results (collapsible JSON, type-colored primitives, DOM element summaries)
|
||||||
|
- [x] REPL tab: command history (sessionStorage, ↑/↓ navigation)
|
||||||
|
- [x] REPL tab: tab-completion on object graphs, event labels, globals
|
||||||
|
- [x] REPL tab: event label hints (Events.on/emit completion)
|
||||||
|
- [x] REPL tab: admin gate (admin role OR ?debug=1 URL param)
|
||||||
|
- [x] REPL tab: toolbar (clear, copy, help)
|
||||||
|
- [x] CSS: mode selector + REPL styles
|
||||||
|
- [x] Documentation in EXTENSIONS.md §6 updated with implementation details
|
||||||
|
- [ ] Mobile: mode selector collapses to hamburger/bottom nav (deferred — functions via sidebar on mobile)
|
||||||
|
- [ ] Integration with extension loader (surfaces from manifest — deferred to extension loader update)
|
||||||
|
|
||||||
### v0.21.4 — Git Integration
|
### v0.21.4 — Git Integration
|
||||||
|
|
||||||
|
|||||||
@@ -1748,6 +1748,86 @@ select option { background: var(--bg-surface); color: var(--text); }
|
|||||||
.debug-state-pre { max-height: none; margin: 0; }
|
.debug-state-pre { max-height: none; margin: 0; }
|
||||||
.debug-footer { display: flex; align-items: center; }
|
.debug-footer { display: flex; align-items: center; }
|
||||||
|
|
||||||
|
/* ── Mode Selector (v0.21.3) ───────────── */
|
||||||
|
|
||||||
|
.mode-selector {
|
||||||
|
display: flex; gap: 2px; padding: 6px 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.mode-btn {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 32px; height: 32px; border: none;
|
||||||
|
border-radius: var(--radius); background: none;
|
||||||
|
color: var(--text-3); cursor: pointer;
|
||||||
|
transition: background var(--transition), color var(--transition);
|
||||||
|
}
|
||||||
|
.mode-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
|
.mode-btn.active { background: var(--bg-active); color: var(--accent); }
|
||||||
|
.sidebar.collapsed .mode-selector { justify-content: center; }
|
||||||
|
|
||||||
|
/* ── REPL Console (v0.21.3) ────────────── */
|
||||||
|
|
||||||
|
.repl-toolbar {
|
||||||
|
display: flex; gap: 0.5rem; padding: 6px 12px;
|
||||||
|
border-bottom: 1px solid var(--border); font-size: 11px;
|
||||||
|
}
|
||||||
|
.repl-output {
|
||||||
|
flex: 1; overflow-y: auto; padding: 6px;
|
||||||
|
font-family: var(--mono); font-size: 12px; line-height: 1.6;
|
||||||
|
}
|
||||||
|
.repl-input-wrap {
|
||||||
|
display: flex; align-items: flex-start; gap: 6px;
|
||||||
|
padding: 8px 12px; border-top: 1px solid var(--border);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
}
|
||||||
|
.repl-prompt {
|
||||||
|
font-family: var(--mono); font-size: 12px; line-height: 1.6;
|
||||||
|
color: var(--accent); font-weight: bold;
|
||||||
|
flex-shrink: 0; padding-top: 2px;
|
||||||
|
}
|
||||||
|
.repl-input {
|
||||||
|
flex: 1; background: none; border: none; outline: none;
|
||||||
|
font-family: var(--mono); font-size: 12px; line-height: 1.6;
|
||||||
|
color: var(--text); resize: none; overflow: hidden;
|
||||||
|
min-height: 20px;
|
||||||
|
}
|
||||||
|
.repl-entry { padding: 2px 6px; }
|
||||||
|
.repl-entry-input pre { color: var(--text-2); margin: 0; white-space: pre-wrap; word-break: break-all; }
|
||||||
|
.repl-entry-result { color: var(--text); }
|
||||||
|
.repl-entry-error { color: var(--danger, #e55); }
|
||||||
|
.repl-entry-info { color: var(--text-3); font-size: 11px; }
|
||||||
|
|
||||||
|
.repl-error-msg { margin: 0; white-space: pre-wrap; font-weight: 500; }
|
||||||
|
.repl-error-stack { margin: 2px 0 0 0; color: var(--text-3); font-size: 11px; white-space: pre-wrap; }
|
||||||
|
.repl-stack-toggle {
|
||||||
|
background: none; border: none; color: var(--text-3);
|
||||||
|
cursor: pointer; font-size: 10px; font-family: var(--mono);
|
||||||
|
padding: 0; margin-top: 2px;
|
||||||
|
}
|
||||||
|
.repl-stack-toggle:hover { color: var(--text-2); }
|
||||||
|
|
||||||
|
.repl-elapsed { color: var(--text-3); font-size: 10px; margin-left: 8px; }
|
||||||
|
|
||||||
|
/* REPL value types */
|
||||||
|
.repl-null, .repl-undefined { color: var(--text-3); margin: 0; }
|
||||||
|
.repl-string { color: #98c379; margin: 0; white-space: pre-wrap; word-break: break-all; }
|
||||||
|
.repl-number { color: #d19a66; margin: 0; }
|
||||||
|
.repl-boolean { color: #56b6c2; margin: 0; }
|
||||||
|
.repl-function { color: #c678dd; margin: 0; font-style: italic; }
|
||||||
|
.repl-symbol { color: #e5c07b; margin: 0; }
|
||||||
|
.repl-dom { color: #61afef; margin: 0; }
|
||||||
|
.repl-object { color: var(--text); margin: 0; }
|
||||||
|
.repl-json { margin: 0; white-space: pre-wrap; word-break: break-all; color: var(--text); font-size: 11px; }
|
||||||
|
|
||||||
|
.repl-collapsible { display: inline; }
|
||||||
|
.repl-json-toggle {
|
||||||
|
background: none; border: none; color: var(--text-2);
|
||||||
|
cursor: pointer; font-family: var(--mono); font-size: 12px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.repl-json-toggle:hover { color: var(--accent); }
|
||||||
|
|
||||||
/* ── Sidebar Notes Button ───────────────── */
|
/* ── Sidebar Notes Button ───────────────── */
|
||||||
|
|
||||||
.sidebar-notes-btn {
|
.sidebar-notes-btn {
|
||||||
|
|||||||
@@ -72,12 +72,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mode Selector (shown when ≥1 extension surface registered) -->
|
||||||
|
<div class="mode-selector" id="modeSelectorWrap" style="display:none"></div>
|
||||||
|
|
||||||
|
<div data-surface-region="sidebar-content">
|
||||||
<div class="sidebar-search" id="sidebarSearch">
|
<div class="sidebar-search" id="sidebarSearch">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
<input type="text" id="chatSearchInput" placeholder="Search chats…" autocomplete="off">
|
<input type="text" id="chatSearchInput" placeholder="Search chats…" autocomplete="off">
|
||||||
<button class="sidebar-search-clear" id="chatSearchClear" title="Clear">✕</button>
|
<button class="sidebar-search-clear" id="chatSearchClear" title="Clear">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-chats" id="chatHistory"></div>
|
<div class="sidebar-chats" id="chatHistory"></div>
|
||||||
|
</div><!-- /sidebar-content region -->
|
||||||
|
|
||||||
<!-- Notes shortcut -->
|
<!-- Notes shortcut -->
|
||||||
<div class="sidebar-notes-btn">
|
<div class="sidebar-notes-btn">
|
||||||
@@ -124,7 +129,7 @@
|
|||||||
|
|
||||||
<!-- Main Chat Area -->
|
<!-- Main Chat Area -->
|
||||||
<main class="chat-area">
|
<main class="chat-area">
|
||||||
<div class="model-bar">
|
<div class="model-bar" data-surface-region="surface-header">
|
||||||
<button class="mobile-menu-btn" id="mobileMenuBtn" title="Menu">
|
<button class="mobile-menu-btn" id="mobileMenuBtn" title="Menu">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -150,7 +155,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="messages" id="chatMessages">
|
<div class="messages" id="chatMessages" data-surface-region="surface-main">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-logo"><img src="favicon-256.png" class="empty-logo-img" alt=""></div>
|
<div class="empty-logo"><img src="favicon-256.png" class="empty-logo-img" alt=""></div>
|
||||||
<h2>Chat Switchboard</h2>
|
<h2>Chat Switchboard</h2>
|
||||||
@@ -158,7 +163,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input-area">
|
<div class="input-area" data-surface-region="surface-footer">
|
||||||
<div class="context-warning" id="contextWarning" style="display:none">
|
<div class="context-warning" id="contextWarning" style="display:none">
|
||||||
<span class="context-warning-icon">⚠️</span>
|
<span class="context-warning-icon">⚠️</span>
|
||||||
<span class="context-warning-text" id="contextWarningText"></span>
|
<span class="context-warning-text" id="contextWarningText"></span>
|
||||||
@@ -1065,6 +1070,7 @@
|
|||||||
<button class="debug-tab active" data-tab="console" onclick="switchDebugTab('console')">Console (<span id="debugConsoleCount">0</span>)</button>
|
<button class="debug-tab active" data-tab="console" onclick="switchDebugTab('console')">Console (<span id="debugConsoleCount">0</span>)</button>
|
||||||
<button class="debug-tab" data-tab="network" onclick="switchDebugTab('network')">Network (<span id="debugNetworkCount">0</span>)</button>
|
<button class="debug-tab" data-tab="network" onclick="switchDebugTab('network')">Network (<span id="debugNetworkCount">0</span>)</button>
|
||||||
<button class="debug-tab" data-tab="state" onclick="switchDebugTab('state')">State</button>
|
<button class="debug-tab" data-tab="state" onclick="switchDebugTab('state')">State</button>
|
||||||
|
<button class="debug-tab" data-tab="repl" onclick="switchDebugTab('repl')">REPL</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body debug-modal-body">
|
<div class="modal-body debug-modal-body">
|
||||||
<div class="debug-tab-content" id="debugConsoleTab">
|
<div class="debug-tab-content" id="debugConsoleTab">
|
||||||
@@ -1076,6 +1082,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="debug-tab-content" id="debugNetworkTab" style="display:none"><div id="debugNetworkContent" class="debug-content"></div></div>
|
<div class="debug-tab-content" id="debugNetworkTab" style="display:none"><div id="debugNetworkContent" class="debug-content"></div></div>
|
||||||
<div class="debug-tab-content" id="debugStateTab" style="display:none"><div id="debugStateContent" class="debug-content"></div></div>
|
<div class="debug-tab-content" id="debugStateTab" style="display:none"><div id="debugStateContent" class="debug-content"></div></div>
|
||||||
|
<div class="debug-tab-content" id="debugReplTab" style="display:none">
|
||||||
|
<div class="repl-toolbar">
|
||||||
|
<button class="btn-small" onclick="REPL.clear()" title="Clear output">Clear</button>
|
||||||
|
<button class="btn-small" onclick="REPL.copyOutput()" title="Copy output">Copy</button>
|
||||||
|
<button class="btn-small" onclick="REPL.showHelp()" title="Show help">Help</button>
|
||||||
|
</div>
|
||||||
|
<div class="repl-output" id="replOutput"></div>
|
||||||
|
<div class="repl-input-wrap">
|
||||||
|
<span class="repl-prompt">></span>
|
||||||
|
<textarea class="repl-input" id="replInput" placeholder="Enter expression… (Shift+Enter for newline)" rows="1" spellcheck="false"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer debug-footer">
|
<div class="modal-footer debug-footer">
|
||||||
<button class="btn-primary btn-small" onclick="runDebugDiagnostics()">Diagnostics</button>
|
<button class="btn-primary btn-small" onclick="runDebugDiagnostics()">Diagnostics</button>
|
||||||
@@ -1157,7 +1175,9 @@
|
|||||||
<script src="vendor/codemirror/codemirror.bundle.js?v=%%APP_VERSION%%" onerror="console.warn('[CM6] Bundle not available — falling back to textarea')"></script>
|
<script src="vendor/codemirror/codemirror.bundle.js?v=%%APP_VERSION%%" onerror="console.warn('[CM6] Bundle not available — falling back to textarea')"></script>
|
||||||
|
|
||||||
<script src="js/debug.js?v=%%APP_VERSION%%"></script>
|
<script src="js/debug.js?v=%%APP_VERSION%%"></script>
|
||||||
|
<script src="js/repl.js?v=%%APP_VERSION%%"></script>
|
||||||
<script src="js/events.js?v=%%APP_VERSION%%"></script>
|
<script src="js/events.js?v=%%APP_VERSION%%"></script>
|
||||||
|
<script src="js/surfaces.js?v=%%APP_VERSION%%"></script>
|
||||||
<script src="js/extensions.js?v=%%APP_VERSION%%"></script>
|
<script src="js/extensions.js?v=%%APP_VERSION%%"></script>
|
||||||
<script src="js/api.js?v=%%APP_VERSION%%"></script>
|
<script src="js/api.js?v=%%APP_VERSION%%"></script>
|
||||||
<script src="js/panels.js?v=%%APP_VERSION%%"></script>
|
<script src="js/panels.js?v=%%APP_VERSION%%"></script>
|
||||||
|
|||||||
@@ -173,6 +173,10 @@ async function startApp() {
|
|||||||
UI.restoreSidebar();
|
UI.restoreSidebar();
|
||||||
await loadSettings();
|
await loadSettings();
|
||||||
|
|
||||||
|
// Initialize surface system (v0.21.3) — must happen before extensions
|
||||||
|
// so that extensions can register surfaces during init().
|
||||||
|
if (typeof Surfaces !== 'undefined') Surfaces.init();
|
||||||
|
|
||||||
// Load extensions BEFORE chats so block renderers (mermaid, katex, csv, diff)
|
// Load extensions BEFORE chats so block renderers (mermaid, katex, csv, diff)
|
||||||
// are registered when messages are first rendered.
|
// are registered when messages are first rendered.
|
||||||
try {
|
try {
|
||||||
@@ -198,6 +202,7 @@ async function startApp() {
|
|||||||
await initBanners();
|
await initBanners();
|
||||||
initAttachments();
|
initAttachments();
|
||||||
if (typeof ToolsToggle !== 'undefined') ToolsToggle.init();
|
if (typeof ToolsToggle !== 'undefined') ToolsToggle.init();
|
||||||
|
if (typeof REPL !== 'undefined') REPL.init(); // v0.21.3: admin-gated REPL tab
|
||||||
if (typeof KnowledgeUI !== 'undefined') {
|
if (typeof KnowledgeUI !== 'undefined') {
|
||||||
KnowledgeUI.init();
|
KnowledgeUI.init();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -242,6 +242,22 @@ const Extensions = {
|
|||||||
return Promise.resolve(window.confirm(msg));
|
return Promise.resolve(window.confirm(msg));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace a surface region's content with a new element.
|
||||||
|
* Current children are preserved in memory (not destroyed)
|
||||||
|
* and can be restored later. Critical for CM6 state preservation.
|
||||||
|
*/
|
||||||
|
replace(regionId, element) {
|
||||||
|
if (typeof Surfaces !== 'undefined') Surfaces.replace(regionId, element);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore a surface region's previously saved content.
|
||||||
|
*/
|
||||||
|
restore(regionId) {
|
||||||
|
if (typeof Surfaces !== 'undefined') Surfaces.restore(regionId);
|
||||||
|
},
|
||||||
|
|
||||||
/** Inject an element into a named UI region (stub for future use). */
|
/** Inject an element into a named UI region (stub for future use). */
|
||||||
inject(region, el) {
|
inject(region, el) {
|
||||||
console.warn(`[Extensions] ui.inject() not yet implemented (${extId}:${region})`);
|
console.warn(`[Extensions] ui.inject() not yet implemented (${extId}:${region})`);
|
||||||
@@ -254,6 +270,23 @@ const Extensions = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Surface registration (v0.21.3)
|
||||||
|
surfaces: {
|
||||||
|
register: (id, opts) => {
|
||||||
|
if (typeof Surfaces !== 'undefined') Surfaces.register(id, opts);
|
||||||
|
},
|
||||||
|
unregister: (id) => {
|
||||||
|
if (typeof Surfaces !== 'undefined') Surfaces.unregister(id);
|
||||||
|
},
|
||||||
|
activate: (id) => {
|
||||||
|
if (typeof Surfaces !== 'undefined') Surfaces.activate(id);
|
||||||
|
},
|
||||||
|
getCurrent: () => {
|
||||||
|
if (typeof Surfaces !== 'undefined') return Surfaces.getCurrent();
|
||||||
|
return 'chat';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// Model info (resolved at call time)
|
// Model info (resolved at call time)
|
||||||
get model() {
|
get model() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
552
src/js/repl.js
Normal file
552
src/js/repl.js
Normal file
@@ -0,0 +1,552 @@
|
|||||||
|
// ==========================================
|
||||||
|
// Chat Switchboard – REPL Console
|
||||||
|
// ==========================================
|
||||||
|
// Fourth tab in the debug modal (Ctrl+Shift+L).
|
||||||
|
// Evaluates JavaScript with top-level await support
|
||||||
|
// via AsyncFunction. Injects app globals for introspection.
|
||||||
|
//
|
||||||
|
// Features:
|
||||||
|
// - AsyncFunction wrapper (top-level await)
|
||||||
|
// - Injected globals: API, Events, Extensions, DebugLog, Surfaces, Stores
|
||||||
|
// - Pretty-print results (collapsible JSON, red errors with stack)
|
||||||
|
// - Command history: ↑/↓ with sessionStorage
|
||||||
|
// - Tab-completion on live object graphs
|
||||||
|
// - Event label hints on Events.on(' / Events.emit('
|
||||||
|
// - Multi-line: Shift+Enter for newline, Enter to run
|
||||||
|
// - Admin-gated OR ?debug=1 URL param
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const REPL = {
|
||||||
|
|
||||||
|
// ── State ────────────────────────────────
|
||||||
|
_history: [],
|
||||||
|
_historyIndex: -1,
|
||||||
|
_maxHistory: 100,
|
||||||
|
_storageKey: 'cs::repl::history',
|
||||||
|
_initialized: false,
|
||||||
|
|
||||||
|
// ── Initialization ───────────────────────
|
||||||
|
|
||||||
|
init() {
|
||||||
|
if (this._initialized) return;
|
||||||
|
this._initialized = true;
|
||||||
|
|
||||||
|
// Restore history from sessionStorage
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem(this._storageKey);
|
||||||
|
if (saved) this._history = JSON.parse(saved);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
const input = document.getElementById('replInput');
|
||||||
|
if (!input) return;
|
||||||
|
|
||||||
|
input.addEventListener('keydown', (e) => this._onKeyDown(e));
|
||||||
|
input.addEventListener('input', () => this._autoResize(input));
|
||||||
|
|
||||||
|
// Gate: hide REPL tab if not admin and no ?debug=1
|
||||||
|
this._applyGate();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide the REPL tab unless user is admin or ?debug=1 is set.
|
||||||
|
*/
|
||||||
|
_applyGate() {
|
||||||
|
const tab = document.querySelector('.debug-tab[data-tab="repl"]');
|
||||||
|
if (!tab) return;
|
||||||
|
|
||||||
|
const isAdmin = typeof API !== 'undefined' && API.user?.role === 'admin';
|
||||||
|
const debugParam = new URLSearchParams(window.location.search).has('debug');
|
||||||
|
|
||||||
|
if (!isAdmin && !debugParam) {
|
||||||
|
tab.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
tab.style.display = '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Execution ────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate an expression with top-level await support.
|
||||||
|
* Injects app globals as local variables.
|
||||||
|
*/
|
||||||
|
async run(code) {
|
||||||
|
if (!code.trim()) return;
|
||||||
|
|
||||||
|
// Add to history
|
||||||
|
this._history.push(code);
|
||||||
|
if (this._history.length > this._maxHistory) this._history.shift();
|
||||||
|
this._historyIndex = -1;
|
||||||
|
this._saveHistory();
|
||||||
|
|
||||||
|
// Render input
|
||||||
|
this._appendInput(code);
|
||||||
|
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build an AsyncFunction with injected globals.
|
||||||
|
// The last expression is auto-returned if no explicit return.
|
||||||
|
const wrappedCode = this._wrapCode(code);
|
||||||
|
|
||||||
|
// Injected globals accessible inside the REPL
|
||||||
|
const globals = this._getGlobals();
|
||||||
|
const argNames = Object.keys(globals);
|
||||||
|
const argValues = Object.values(globals);
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-new-func
|
||||||
|
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
|
||||||
|
const fn = new AsyncFunction(...argNames, wrappedCode);
|
||||||
|
|
||||||
|
const result = await fn(...argValues);
|
||||||
|
const elapsed = (performance.now() - startTime).toFixed(1);
|
||||||
|
|
||||||
|
if (result !== undefined) {
|
||||||
|
this._appendResult(result, elapsed);
|
||||||
|
} else {
|
||||||
|
this._appendInfo(`(undefined) [${elapsed}ms]`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this._appendError(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._scrollToBottom();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap code for AsyncFunction evaluation.
|
||||||
|
* If the code is a single expression (no semicolons, no let/const/var/return),
|
||||||
|
* auto-return it. Otherwise, execute as-is.
|
||||||
|
*/
|
||||||
|
_wrapCode(code) {
|
||||||
|
const trimmed = code.trim();
|
||||||
|
|
||||||
|
// Multi-statement: check for declarations or multiple statements
|
||||||
|
if (/^(let |const |var |return |if |for |while |switch |try |class |function )/.test(trimmed)) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single expression — auto-return
|
||||||
|
// Handle edge case: object literals starting with { need wrapping
|
||||||
|
if (trimmed.startsWith('{') && !trimmed.startsWith('{(')) {
|
||||||
|
return `return (${trimmed})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `return (${trimmed})`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the set of globals injected into REPL scope.
|
||||||
|
*/
|
||||||
|
_getGlobals() {
|
||||||
|
const globals = {};
|
||||||
|
|
||||||
|
// Core app objects
|
||||||
|
if (typeof API !== 'undefined') globals.API = API;
|
||||||
|
if (typeof Events !== 'undefined') globals.Events = Events;
|
||||||
|
if (typeof Extensions !== 'undefined') globals.Extensions = Extensions;
|
||||||
|
if (typeof Surfaces !== 'undefined') globals.Surfaces = Surfaces;
|
||||||
|
if (typeof DebugLog !== 'undefined') globals.DebugLog = DebugLog;
|
||||||
|
if (typeof PanelRegistry !== 'undefined') globals.Panels = PanelRegistry;
|
||||||
|
if (typeof UI !== 'undefined') globals.UI = UI;
|
||||||
|
|
||||||
|
// Convenience aliases
|
||||||
|
globals.$ = (sel) => document.querySelector(sel);
|
||||||
|
globals.$$ = (sel) => [...document.querySelectorAll(sel)];
|
||||||
|
globals.sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||||
|
|
||||||
|
return globals;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Key Handling ─────────────────────────
|
||||||
|
|
||||||
|
_onKeyDown(e) {
|
||||||
|
const input = e.target;
|
||||||
|
|
||||||
|
// Enter (without Shift) → execute
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.run(input.value);
|
||||||
|
input.value = '';
|
||||||
|
this._autoResize(input);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ↑ / ↓ → history navigation (only when cursor is on first/last line)
|
||||||
|
if (e.key === 'ArrowUp' && this._isCursorOnFirstLine(input)) {
|
||||||
|
e.preventDefault();
|
||||||
|
this._navigateHistory(-1, input);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowDown' && this._isCursorOnLastLine(input)) {
|
||||||
|
e.preventDefault();
|
||||||
|
this._navigateHistory(1, input);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab → completion
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
this._tabComplete(input);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_isCursorOnFirstLine(input) {
|
||||||
|
return input.value.substring(0, input.selectionStart).indexOf('\n') === -1;
|
||||||
|
},
|
||||||
|
|
||||||
|
_isCursorOnLastLine(input) {
|
||||||
|
return input.value.substring(input.selectionStart).indexOf('\n') === -1;
|
||||||
|
},
|
||||||
|
|
||||||
|
_autoResize(input) {
|
||||||
|
input.style.height = 'auto';
|
||||||
|
input.style.height = Math.min(input.scrollHeight, 120) + 'px';
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── History ──────────────────────────────
|
||||||
|
|
||||||
|
_navigateHistory(direction, input) {
|
||||||
|
if (this._history.length === 0) return;
|
||||||
|
|
||||||
|
if (this._historyIndex === -1) {
|
||||||
|
// Starting navigation — save current input
|
||||||
|
this._historyScratch = input.value;
|
||||||
|
this._historyIndex = this._history.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._historyIndex += direction;
|
||||||
|
|
||||||
|
if (this._historyIndex < 0) this._historyIndex = 0;
|
||||||
|
if (this._historyIndex >= this._history.length) {
|
||||||
|
// Past end → restore scratch
|
||||||
|
this._historyIndex = -1;
|
||||||
|
input.value = this._historyScratch || '';
|
||||||
|
} else {
|
||||||
|
input.value = this._history[this._historyIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
this._autoResize(input);
|
||||||
|
// Move cursor to end
|
||||||
|
input.selectionStart = input.selectionEnd = input.value.length;
|
||||||
|
},
|
||||||
|
|
||||||
|
_saveHistory() {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(this._storageKey, JSON.stringify(this._history));
|
||||||
|
} catch { /* quota exceeded — ignore */ }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Tab Completion ──────────────────────
|
||||||
|
|
||||||
|
_tabComplete(input) {
|
||||||
|
const cursor = input.selectionStart;
|
||||||
|
const text = input.value.substring(0, cursor);
|
||||||
|
|
||||||
|
// Event label completion: Events.on(' or Events.emit('
|
||||||
|
const eventMatch = text.match(/Events\.(on|once|emit|off)\s*\(\s*['"]([^'"]*)?$/);
|
||||||
|
if (eventMatch) {
|
||||||
|
this._completeEventLabels(input, cursor, eventMatch[2] || '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Object property completion: obj.prop or obj.prop.sub
|
||||||
|
const propMatch = text.match(/([\w$.]+)\.([\w]*)$/);
|
||||||
|
if (propMatch) {
|
||||||
|
this._completeProperty(input, cursor, propMatch[1], propMatch[2]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top-level global completion
|
||||||
|
const wordMatch = text.match(/([\w$]+)$/);
|
||||||
|
if (wordMatch) {
|
||||||
|
this._completeGlobal(input, cursor, wordMatch[1]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_completeEventLabels(input, cursor, partial) {
|
||||||
|
// Gather known event labels from the Events handler map
|
||||||
|
if (typeof Events === 'undefined') return;
|
||||||
|
const labels = Array.from(Events._handlers?.keys() || []);
|
||||||
|
const matches = labels.filter(l => l.startsWith(partial)).sort();
|
||||||
|
|
||||||
|
if (matches.length === 0) return;
|
||||||
|
if (matches.length === 1) {
|
||||||
|
this._insertCompletion(input, cursor, partial, matches[0]);
|
||||||
|
} else {
|
||||||
|
this._appendInfo('Completions: ' + matches.join(', '));
|
||||||
|
// Complete common prefix
|
||||||
|
const common = this._commonPrefix(matches);
|
||||||
|
if (common.length > partial.length) {
|
||||||
|
this._insertCompletion(input, cursor, partial, common);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_completeProperty(input, cursor, objExpr, partial) {
|
||||||
|
try {
|
||||||
|
// Resolve the object in global scope
|
||||||
|
const globals = this._getGlobals();
|
||||||
|
// eslint-disable-next-line no-new-func
|
||||||
|
const obj = new Function(...Object.keys(globals), `return ${objExpr}`)(...Object.values(globals));
|
||||||
|
if (obj == null) return;
|
||||||
|
|
||||||
|
const keys = [];
|
||||||
|
// Own + prototype properties
|
||||||
|
for (let o = obj; o && o !== Object.prototype; o = Object.getPrototypeOf(o)) {
|
||||||
|
for (const k of Object.getOwnPropertyNames(o)) {
|
||||||
|
if (k.startsWith(partial) && !k.startsWith('_')) keys.push(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const unique = [...new Set(keys)].sort();
|
||||||
|
|
||||||
|
if (unique.length === 0) return;
|
||||||
|
if (unique.length === 1) {
|
||||||
|
this._insertCompletion(input, cursor, partial, unique[0]);
|
||||||
|
} else {
|
||||||
|
this._appendInfo('Completions: ' + unique.slice(0, 30).join(', ') + (unique.length > 30 ? ' …' : ''));
|
||||||
|
const common = this._commonPrefix(unique);
|
||||||
|
if (common.length > partial.length) {
|
||||||
|
this._insertCompletion(input, cursor, partial, common);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* can't resolve — ignore */ }
|
||||||
|
},
|
||||||
|
|
||||||
|
_completeGlobal(input, cursor, partial) {
|
||||||
|
const globals = Object.keys(this._getGlobals());
|
||||||
|
// Add common window globals
|
||||||
|
const candidates = [...globals, 'document', 'window', 'console', 'fetch', 'JSON', 'Math',
|
||||||
|
'Array', 'Object', 'String', 'Number', 'Boolean', 'Promise', 'Map', 'Set'];
|
||||||
|
const matches = [...new Set(candidates)].filter(c => c.startsWith(partial)).sort();
|
||||||
|
|
||||||
|
if (matches.length === 0) return;
|
||||||
|
if (matches.length === 1) {
|
||||||
|
this._insertCompletion(input, cursor, partial, matches[0]);
|
||||||
|
} else {
|
||||||
|
this._appendInfo('Completions: ' + matches.join(', '));
|
||||||
|
const common = this._commonPrefix(matches);
|
||||||
|
if (common.length > partial.length) {
|
||||||
|
this._insertCompletion(input, cursor, partial, common);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_insertCompletion(input, cursor, partial, completion) {
|
||||||
|
const before = input.value.substring(0, cursor - partial.length);
|
||||||
|
const after = input.value.substring(cursor);
|
||||||
|
input.value = before + completion + after;
|
||||||
|
const newPos = before.length + completion.length;
|
||||||
|
input.selectionStart = input.selectionEnd = newPos;
|
||||||
|
},
|
||||||
|
|
||||||
|
_commonPrefix(strs) {
|
||||||
|
if (strs.length === 0) return '';
|
||||||
|
let prefix = strs[0];
|
||||||
|
for (let i = 1; i < strs.length; i++) {
|
||||||
|
while (!strs[i].startsWith(prefix)) {
|
||||||
|
prefix = prefix.slice(0, -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return prefix;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Output Rendering ────────────────────
|
||||||
|
|
||||||
|
_appendInput(code) {
|
||||||
|
const output = document.getElementById('replOutput');
|
||||||
|
if (!output) return;
|
||||||
|
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'repl-entry repl-entry-input';
|
||||||
|
|
||||||
|
const pre = document.createElement('pre');
|
||||||
|
pre.textContent = '> ' + code;
|
||||||
|
div.appendChild(pre);
|
||||||
|
output.appendChild(div);
|
||||||
|
},
|
||||||
|
|
||||||
|
_appendResult(value, elapsed) {
|
||||||
|
const output = document.getElementById('replOutput');
|
||||||
|
if (!output) return;
|
||||||
|
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'repl-entry repl-entry-result';
|
||||||
|
|
||||||
|
const formatted = this._formatValue(value);
|
||||||
|
div.appendChild(formatted);
|
||||||
|
|
||||||
|
if (elapsed) {
|
||||||
|
const time = document.createElement('span');
|
||||||
|
time.className = 'repl-elapsed';
|
||||||
|
time.textContent = `[${elapsed}ms]`;
|
||||||
|
div.appendChild(time);
|
||||||
|
}
|
||||||
|
|
||||||
|
output.appendChild(div);
|
||||||
|
},
|
||||||
|
|
||||||
|
_appendError(err) {
|
||||||
|
const output = document.getElementById('replOutput');
|
||||||
|
if (!output) return;
|
||||||
|
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'repl-entry repl-entry-error';
|
||||||
|
|
||||||
|
const msg = document.createElement('pre');
|
||||||
|
msg.className = 'repl-error-msg';
|
||||||
|
msg.textContent = err.message || String(err);
|
||||||
|
div.appendChild(msg);
|
||||||
|
|
||||||
|
if (err.stack) {
|
||||||
|
const stack = document.createElement('pre');
|
||||||
|
stack.className = 'repl-error-stack';
|
||||||
|
stack.textContent = err.stack.split('\n').slice(1, 5).join('\n');
|
||||||
|
stack.style.display = 'none';
|
||||||
|
|
||||||
|
const toggle = document.createElement('button');
|
||||||
|
toggle.className = 'repl-stack-toggle';
|
||||||
|
toggle.textContent = '▸ stack';
|
||||||
|
toggle.onclick = () => {
|
||||||
|
const shown = stack.style.display !== 'none';
|
||||||
|
stack.style.display = shown ? 'none' : '';
|
||||||
|
toggle.textContent = shown ? '▸ stack' : '▾ stack';
|
||||||
|
};
|
||||||
|
|
||||||
|
div.appendChild(toggle);
|
||||||
|
div.appendChild(stack);
|
||||||
|
}
|
||||||
|
|
||||||
|
output.appendChild(div);
|
||||||
|
},
|
||||||
|
|
||||||
|
_appendInfo(text) {
|
||||||
|
const output = document.getElementById('replOutput');
|
||||||
|
if (!output) return;
|
||||||
|
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'repl-entry repl-entry-info';
|
||||||
|
div.textContent = text;
|
||||||
|
output.appendChild(div);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a JS value for display.
|
||||||
|
* Objects/arrays get collapsible JSON; primitives get plain text.
|
||||||
|
*/
|
||||||
|
_formatValue(value) {
|
||||||
|
if (value === null) return this._textEl('null', 'repl-null');
|
||||||
|
if (value === undefined) return this._textEl('undefined', 'repl-undefined');
|
||||||
|
|
||||||
|
const type = typeof value;
|
||||||
|
|
||||||
|
if (type === 'string') return this._textEl(JSON.stringify(value), 'repl-string');
|
||||||
|
if (type === 'number' || type === 'bigint') return this._textEl(String(value), 'repl-number');
|
||||||
|
if (type === 'boolean') return this._textEl(String(value), 'repl-boolean');
|
||||||
|
if (type === 'function') return this._textEl(`[Function: ${value.name || 'anonymous'}]`, 'repl-function');
|
||||||
|
if (type === 'symbol') return this._textEl(String(value), 'repl-symbol');
|
||||||
|
|
||||||
|
if (value instanceof HTMLElement) {
|
||||||
|
return this._textEl(`<${value.tagName.toLowerCase()}${value.id ? '#' + value.id : ''}${value.className ? '.' + value.className.split(' ').join('.') : ''}>`, 'repl-dom');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Error) {
|
||||||
|
return this._textEl(`${value.constructor.name}: ${value.message}`, 'repl-error-msg');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Objects and arrays → collapsible JSON
|
||||||
|
try {
|
||||||
|
const json = JSON.stringify(value, null, 2);
|
||||||
|
if (json.length < 200) {
|
||||||
|
return this._textEl(json, 'repl-json');
|
||||||
|
}
|
||||||
|
return this._collapsibleJson(json, value);
|
||||||
|
} catch {
|
||||||
|
return this._textEl(String(value), 'repl-object');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_textEl(text, className) {
|
||||||
|
const pre = document.createElement('pre');
|
||||||
|
pre.className = className || '';
|
||||||
|
pre.textContent = text;
|
||||||
|
return pre;
|
||||||
|
},
|
||||||
|
|
||||||
|
_collapsibleJson(json, value) {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'repl-collapsible';
|
||||||
|
|
||||||
|
const summary = Array.isArray(value)
|
||||||
|
? `Array(${value.length})`
|
||||||
|
: `Object {${Object.keys(value).slice(0, 3).join(', ')}${Object.keys(value).length > 3 ? ', …' : ''}}`;
|
||||||
|
|
||||||
|
const toggle = document.createElement('button');
|
||||||
|
toggle.className = 'repl-json-toggle';
|
||||||
|
toggle.textContent = '▸ ' + summary;
|
||||||
|
|
||||||
|
const pre = document.createElement('pre');
|
||||||
|
pre.className = 'repl-json';
|
||||||
|
pre.textContent = json;
|
||||||
|
pre.style.display = 'none';
|
||||||
|
|
||||||
|
toggle.onclick = () => {
|
||||||
|
const shown = pre.style.display !== 'none';
|
||||||
|
pre.style.display = shown ? 'none' : '';
|
||||||
|
toggle.textContent = (shown ? '▸ ' : '▾ ') + summary;
|
||||||
|
};
|
||||||
|
|
||||||
|
container.appendChild(toggle);
|
||||||
|
container.appendChild(pre);
|
||||||
|
return container;
|
||||||
|
},
|
||||||
|
|
||||||
|
_scrollToBottom() {
|
||||||
|
const output = document.getElementById('replOutput');
|
||||||
|
if (output) output.scrollTop = output.scrollHeight;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Toolbar Actions ──────────────────────
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
const output = document.getElementById('replOutput');
|
||||||
|
if (output) output.innerHTML = '';
|
||||||
|
},
|
||||||
|
|
||||||
|
copyOutput() {
|
||||||
|
const output = document.getElementById('replOutput');
|
||||||
|
if (!output) return;
|
||||||
|
const text = output.innerText;
|
||||||
|
navigator.clipboard.writeText(text)
|
||||||
|
.then(() => { if (typeof showToast === 'function') showToast('📋 REPL output copied', 'success'); })
|
||||||
|
.catch(() => {});
|
||||||
|
},
|
||||||
|
|
||||||
|
showHelp() {
|
||||||
|
const globals = Object.keys(this._getGlobals());
|
||||||
|
const help = [
|
||||||
|
'╭─ REPL Help ─────────────────────────────╮',
|
||||||
|
'│ Enter Run expression │',
|
||||||
|
'│ Shift+Enter New line │',
|
||||||
|
'│ ↑ / ↓ Command history │',
|
||||||
|
'│ Tab Auto-complete │',
|
||||||
|
'╰──────────────────────────────────────────╯',
|
||||||
|
'',
|
||||||
|
'Injected globals: ' + globals.join(', '),
|
||||||
|
'',
|
||||||
|
'Top-level await is supported:',
|
||||||
|
' const r = await API._get("/api/v1/me")',
|
||||||
|
'',
|
||||||
|
'Shortcuts:',
|
||||||
|
' $(sel) → document.querySelector(sel)',
|
||||||
|
' $$(sel) → document.querySelectorAll(sel)',
|
||||||
|
' sleep(ms) → await sleep(1000)',
|
||||||
|
];
|
||||||
|
help.forEach(line => this._appendInfo(line));
|
||||||
|
this._scrollToBottom();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialized from app.js startApp() after auth is confirmed,
|
||||||
|
// ensuring API.user.role is available for admin gate check.
|
||||||
319
src/js/surfaces.js
Normal file
319
src/js/surfaces.js
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
// ==========================================
|
||||||
|
// Chat Switchboard – Surface Registry
|
||||||
|
// ==========================================
|
||||||
|
// Manages "modes" (surfaces) — chat, editor, article, etc.
|
||||||
|
// Each surface can take over named regions of the UI without
|
||||||
|
// destroying the DOM nodes of the previous surface.
|
||||||
|
//
|
||||||
|
// Load order: events.js → surfaces.js → extensions.js
|
||||||
|
//
|
||||||
|
// Key design: replace() detaches children (kept in memory),
|
||||||
|
// restore() re-attaches them. CM6 editor instances survive
|
||||||
|
// mode switches because their DOM isn't destroyed.
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const Surfaces = {
|
||||||
|
|
||||||
|
// ── State ────────────────────────────────
|
||||||
|
_registry: new Map(), // surfaceId → { label, icon, regions, activate, deactivate }
|
||||||
|
_current: 'chat', // active surface id
|
||||||
|
_saved: new Map(), // regionId → DocumentFragment (saved DOM children)
|
||||||
|
_regionEls: new Map(), // regionId → DOM element (cached lookups)
|
||||||
|
|
||||||
|
// ── Initialization ───────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache region elements and register chat as the implicit default surface.
|
||||||
|
* Called from app.js init after DOM is ready.
|
||||||
|
*/
|
||||||
|
init() {
|
||||||
|
// Cache all region containers
|
||||||
|
document.querySelectorAll('[data-surface-region]').forEach(el => {
|
||||||
|
const id = el.dataset.surfaceRegion;
|
||||||
|
this._regionEls.set(id, el);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Chat is always registered as the default surface
|
||||||
|
this._registry.set('chat', {
|
||||||
|
label: 'Chat',
|
||||||
|
icon: 'message-square',
|
||||||
|
regions: ['surface-header', 'surface-main', 'surface-footer'],
|
||||||
|
activate: null, // chat activation is implicit (restore regions)
|
||||||
|
deactivate: null,
|
||||||
|
_isDefault: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[Surfaces] Initialized with ${this._regionEls.size} region(s)`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Registration ─────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new surface (mode).
|
||||||
|
* @param {string} id — unique surface identifier
|
||||||
|
* @param {object} opts — { label, icon, regions[], activate(), deactivate() }
|
||||||
|
*/
|
||||||
|
register(id, opts = {}) {
|
||||||
|
if (this._registry.has(id)) {
|
||||||
|
console.warn(`[Surfaces] ${id} already registered`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._registry.set(id, {
|
||||||
|
label: opts.label || id,
|
||||||
|
icon: opts.icon || 'layout',
|
||||||
|
regions: opts.regions || [],
|
||||||
|
activate: opts.activate || null,
|
||||||
|
deactivate: opts.deactivate || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[Surfaces] Registered: ${id} (${opts.label || id})`);
|
||||||
|
|
||||||
|
// Show mode selector if we now have >1 surface
|
||||||
|
this._updateModeSelector();
|
||||||
|
|
||||||
|
Events.emit('surface.registered', { surface: id }, { localOnly: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister a surface. If it's currently active, switch back to chat.
|
||||||
|
*/
|
||||||
|
unregister(id) {
|
||||||
|
if (id === 'chat') return; // can't unregister chat
|
||||||
|
if (!this._registry.has(id)) return;
|
||||||
|
|
||||||
|
if (this._current === id) {
|
||||||
|
this.activate('chat');
|
||||||
|
}
|
||||||
|
this._registry.delete(id);
|
||||||
|
this._updateModeSelector();
|
||||||
|
|
||||||
|
Events.emit('surface.unregistered', { surface: id }, { localOnly: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Activation ───────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch to a different surface.
|
||||||
|
* Deactivates the current surface, saves its region DOM, and activates the new one.
|
||||||
|
*/
|
||||||
|
activate(id) {
|
||||||
|
if (!this._registry.has(id)) {
|
||||||
|
console.error(`[Surfaces] Unknown surface: ${id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this._current === id) return;
|
||||||
|
|
||||||
|
const previous = this._current;
|
||||||
|
const prevDef = this._registry.get(previous);
|
||||||
|
const nextDef = this._registry.get(id);
|
||||||
|
|
||||||
|
// Deactivate current surface
|
||||||
|
if (prevDef) {
|
||||||
|
// Save current region contents
|
||||||
|
for (const regionId of (prevDef.regions || [])) {
|
||||||
|
this._saveRegion(regionId);
|
||||||
|
}
|
||||||
|
// Call surface-specific deactivation
|
||||||
|
if (typeof prevDef.deactivate === 'function') {
|
||||||
|
try { prevDef.deactivate(); } catch (e) {
|
||||||
|
console.error(`[Surfaces] deactivate ${previous}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Events.emit('surface.deactivated', { surface: previous }, { localOnly: true });
|
||||||
|
|
||||||
|
this._current = id;
|
||||||
|
|
||||||
|
// Activate new surface
|
||||||
|
if (typeof nextDef.activate === 'function') {
|
||||||
|
try { nextDef.activate(); } catch (e) {
|
||||||
|
console.error(`[Surfaces] activate ${id}:`, e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Default behavior: restore saved DOM for this surface's regions
|
||||||
|
for (const regionId of (nextDef.regions || [])) {
|
||||||
|
this._restoreRegion(regionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update mode selector active state
|
||||||
|
this._updateModeSelectorActive();
|
||||||
|
|
||||||
|
Events.emit('surface.activated', { surface: id, previous }, { localOnly: true });
|
||||||
|
console.log(`[Surfaces] Activated: ${id} (was: ${previous})`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Query ────────────────────────────────
|
||||||
|
|
||||||
|
/** Get the currently active surface id. */
|
||||||
|
getCurrent() {
|
||||||
|
return this._current;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Get surface definition by id. */
|
||||||
|
get(id) {
|
||||||
|
return this._registry.get(id) || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Get all registered surface ids. */
|
||||||
|
list() {
|
||||||
|
return Array.from(this._registry.keys());
|
||||||
|
},
|
||||||
|
|
||||||
|
/** True when more than just chat is registered. */
|
||||||
|
hasMultiple() {
|
||||||
|
return this._registry.size > 1;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Region Management ────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace a region's content with a new element.
|
||||||
|
* The current children are saved (detached, not destroyed) and can be
|
||||||
|
* restored later with restore(). This is critical for CM6 state preservation.
|
||||||
|
*
|
||||||
|
* @param {string} regionId — data-surface-region value
|
||||||
|
* @param {Element} element — new content to insert
|
||||||
|
*/
|
||||||
|
replace(regionId, element) {
|
||||||
|
const container = this._regionEls.get(regionId);
|
||||||
|
if (!container) {
|
||||||
|
console.warn(`[Surfaces] Unknown region: ${regionId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save current children to a DocumentFragment (preserves DOM state)
|
||||||
|
const key = `${this._current}::${regionId}`;
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
while (container.firstChild) {
|
||||||
|
frag.appendChild(container.firstChild);
|
||||||
|
}
|
||||||
|
this._saved.set(key, frag);
|
||||||
|
|
||||||
|
// Insert new content
|
||||||
|
if (element) {
|
||||||
|
container.appendChild(element);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore a region's previously saved content.
|
||||||
|
* @param {string} regionId — data-surface-region value
|
||||||
|
*/
|
||||||
|
restore(regionId) {
|
||||||
|
const container = this._regionEls.get(regionId);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const key = `${this._current}::${regionId}`;
|
||||||
|
const frag = this._saved.get(key);
|
||||||
|
|
||||||
|
// Clear current contents
|
||||||
|
while (container.firstChild) {
|
||||||
|
container.removeChild(container.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-attach saved DOM
|
||||||
|
if (frag) {
|
||||||
|
container.appendChild(frag);
|
||||||
|
this._saved.delete(key);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Internal: Save/Restore Regions ───────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the current DOM children of a region for the active surface.
|
||||||
|
* Called during deactivation.
|
||||||
|
*/
|
||||||
|
_saveRegion(regionId) {
|
||||||
|
const container = this._regionEls.get(regionId);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const key = `${this._current}::${regionId}`;
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
while (container.firstChild) {
|
||||||
|
frag.appendChild(container.firstChild);
|
||||||
|
}
|
||||||
|
this._saved.set(key, frag);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore the saved DOM children of a region for the active surface.
|
||||||
|
* Called during activation.
|
||||||
|
*/
|
||||||
|
_restoreRegion(regionId) {
|
||||||
|
const container = this._regionEls.get(regionId);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const key = `${this._current}::${regionId}`;
|
||||||
|
const frag = this._saved.get(key);
|
||||||
|
|
||||||
|
// Clear container
|
||||||
|
while (container.firstChild) {
|
||||||
|
container.removeChild(container.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore saved content
|
||||||
|
if (frag) {
|
||||||
|
container.appendChild(frag);
|
||||||
|
this._saved.delete(key);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Mode Selector UI ────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuild the mode selector in the sidebar.
|
||||||
|
* Shown only when ≥1 extension surface is registered (i.e. more than just chat).
|
||||||
|
*/
|
||||||
|
_updateModeSelector() {
|
||||||
|
const wrap = document.getElementById('modeSelectorWrap');
|
||||||
|
if (!wrap) return;
|
||||||
|
|
||||||
|
if (this._registry.size <= 1) {
|
||||||
|
wrap.style.display = 'none';
|
||||||
|
wrap.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wrap.style.display = '';
|
||||||
|
wrap.innerHTML = '';
|
||||||
|
|
||||||
|
for (const [id, def] of this._registry) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'mode-btn' + (id === this._current ? ' active' : '');
|
||||||
|
btn.dataset.surface = id;
|
||||||
|
btn.title = def.label;
|
||||||
|
btn.innerHTML = this._iconSvg(def.icon);
|
||||||
|
btn.addEventListener('click', () => this.activate(id));
|
||||||
|
wrap.appendChild(btn);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Update the active class on mode selector buttons. */
|
||||||
|
_updateModeSelectorActive() {
|
||||||
|
const wrap = document.getElementById('modeSelectorWrap');
|
||||||
|
if (!wrap) return;
|
||||||
|
wrap.querySelectorAll('.mode-btn').forEach(btn => {
|
||||||
|
btn.classList.toggle('active', btn.dataset.surface === this._current);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return an SVG string for a lucide-style icon name.
|
||||||
|
* Only the icons we actually need are included here.
|
||||||
|
*/
|
||||||
|
_iconSvg(name) {
|
||||||
|
const icons = {
|
||||||
|
'message-square': '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
|
||||||
|
'code': '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>',
|
||||||
|
'file-text': '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>',
|
||||||
|
'layout': '<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="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>',
|
||||||
|
'terminal': '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>',
|
||||||
|
'globe': '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>',
|
||||||
|
};
|
||||||
|
return icons[name] || icons['layout'];
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user