Changeset 0.17.2 (#77)

This commit is contained in:
2026-02-28 11:58:27 +00:00
parent 856dc9b0ac
commit a008dac488
26 changed files with 3018 additions and 116 deletions

View File

@@ -1,15 +1,24 @@
# .gitea/workflows/ci.yaml # .gitea/workflows/ci.yaml
# ============================================ # ============================================
# Chat Switchboard - CI/CD Pipeline (v0.17.1) # Chat Switchboard - CI/CD Pipeline (v0.17.2)
# ============================================ # ============================================
# Cluster deployments use SEPARATE FE + BE images. # Cluster deployments use SEPARATE FE + BE images.
# Unified image is for Docker Hub only (docker-compose use). # Unified image is for Docker Hub only (docker-compose use).
# #
# Pipeline: # Pipeline:
# 1a. Frontend tests (Node.js — contracts, model logic, policy wiring) # 0. Detect changes (path-based gating for all downstream jobs)
# 1b. Go test (Postgres — all PRs and pushes) # 1a. Frontend tests — skipped if only BE/docs changed
# 1c. Go test (SQLite — compilation + store verification) # 1b. Go test (PG) — skipped if only FE/docs changed
# 2. Build + Deploy (depends on all test jobs passing) # 1c. Go test (SQLite) — skipped if only FE/docs changed
# 2. Build + Deploy — skipped if docs-only change
#
# Path gating rules:
# src/, src/editor/ → frontend tests
# server/, scripts/db-* → backend tests (PG + SQLite)
# Dockerfile*, k8s/, .gitea/ → all tests (infra change)
# docs/, *.md → skip all tests + deploy
# VERSION, scripts/* → frontend + backend tests
# Tags (v*) → always full pipeline
# #
# Deployment mapping (single domain, path-based): # Deployment mapping (single domain, path-based):
# PR → FE + BE :dev → switchboard.DOMAIN/dev/ (DB wipe + fresh schema) # PR → FE + BE :dev → switchboard.DOMAIN/dev/ (DB wipe + fresh schema)
@@ -76,11 +85,99 @@ env:
DOCKERHUB_IMAGE: ${{ vars.DOCKERHUB_IMAGE || 'gobha/chat-switchboard' }} DOCKERHUB_IMAGE: ${{ vars.DOCKERHUB_IMAGE || 'gobha/chat-switchboard' }}
jobs: jobs:
# ── Stage 0: Detect Changed Paths ──────────────
# Sets output flags so downstream jobs can skip when irrelevant.
# Tags always set all flags true (full pipeline for releases).
detect-changes:
runs-on: ubuntu-latest
outputs:
frontend: ${{ steps.filter.outputs.frontend }}
backend: ${{ steps.filter.outputs.backend }}
infra: ${{ steps.filter.outputs.infra }}
docs_only: ${{ steps.filter.outputs.docs_only }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # need history for diff
- name: Detect changed paths
id: filter
run: |
# Tags = release → run everything
if [[ "${{ gitea.ref }}" == refs/tags/v* ]]; then
echo "frontend=true" >> "$GITHUB_OUTPUT"
echo "backend=true" >> "$GITHUB_OUTPUT"
echo "infra=true" >> "$GITHUB_OUTPUT"
echo "docs_only=false" >> "$GITHUB_OUTPUT"
echo "🏷️ Tag build — running full pipeline"
exit 0
fi
# Determine diff base
if [[ "${{ gitea.event_name }}" == "pull_request" ]]; then
BASE="${{ gitea.event.pull_request.base.sha }}"
HEAD="${{ gitea.event.pull_request.head.sha }}"
else
# Push to main — compare with previous commit
BASE="${{ gitea.event.before }}"
HEAD="${{ gitea.sha }}"
fi
echo "Comparing ${BASE:0:8}..${HEAD:0:8}"
CHANGED=$(git diff --name-only "${BASE}" "${HEAD}" 2>/dev/null || git diff --name-only HEAD~1 HEAD)
echo "Changed files:"
echo "${CHANGED}" | sed 's/^/ /'
# Classify
FE=false; BE=false; INFRA=false; DOCS=false; OTHER=false
while IFS= read -r file; do
[[ -z "$file" ]] && continue
case "$file" in
src/js/*|src/css/*|src/editor/*|src/index.html|src/sw.js|src/manifest.json|src/vendor/*)
FE=true ;;
server/*|scripts/db-*)
BE=true ;;
.gitea/*|k8s/*|Dockerfile*|docker-compose*|docker-entrypoint*|nginx.conf)
INFRA=true ;;
docs/*|*.md|CHANGELOG.md|LICENSE)
DOCS=true ;;
VERSION|scripts/*)
FE=true; BE=true ;;
*)
OTHER=true ;;
esac
done <<< "${CHANGED}"
# Docs-only: only docs changed, nothing else
if [[ "$DOCS" == "true" && "$FE" == "false" && "$BE" == "false" && "$INFRA" == "false" && "$OTHER" == "false" ]]; then
DOCS_ONLY=true
else
DOCS_ONLY=false
fi
echo "frontend=${FE}" >> "$GITHUB_OUTPUT"
echo "backend=${BE}" >> "$GITHUB_OUTPUT"
echo "infra=${INFRA}" >> "$GITHUB_OUTPUT"
echo "docs_only=${DOCS_ONLY}" >> "$GITHUB_OUTPUT"
echo ""
echo "━━━ Change Detection ━━━"
echo " frontend: ${FE}"
echo " backend: ${BE}"
echo " infra: ${INFRA}"
echo " docs_only: ${DOCS_ONLY}"
# ── Stage 1a: Frontend Tests ───────────────── # ── Stage 1a: Frontend Tests ─────────────────
# API contract tests, model processing, policy wiring audits. # API contract tests, model processing, policy wiring audits.
# Uses Node.js built-in test runner (node --test), zero npm deps. # Uses Node.js built-in test runner (node --test), zero npm deps.
#
# Runs when: frontend files changed, infra changed, or not docs-only.
# Skipped when: only backend or docs changed.
test-frontend: test-frontend:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [detect-changes]
if: needs.detect-changes.outputs.frontend == 'true' || needs.detect-changes.outputs.infra == 'true'
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -106,8 +203,13 @@ jobs:
run: node --test src/js/__tests__/*.test.js run: node --test src/js/__tests__/*.test.js
# ── Stage 1b: Go Build & Test (Postgres) ───── # ── Stage 1b: Go Build & Test (Postgres) ─────
#
# Runs when: backend files changed or infra changed.
# Skipped when: only frontend or docs changed.
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [detect-changes]
if: needs.detect-changes.outputs.backend == 'true' || needs.detect-changes.outputs.infra == 'true'
env: env:
GOPRIVATE: git.gobha.me/* GOPRIVATE: git.gobha.me/*
GONOSUMCHECK: git.gobha.me/* GONOSUMCHECK: git.gobha.me/*
@@ -190,8 +292,13 @@ jobs:
# Verifies SQLite backend compiles, stores work, and handler # Verifies SQLite backend compiles, stores work, and handler
# integration tests pass against an in-memory SQLite database. # integration tests pass against an in-memory SQLite database.
# No external database or provider keys required. # No external database or provider keys required.
#
# Runs when: backend files changed or infra changed.
# Skipped when: only frontend or docs changed.
test-sqlite: test-sqlite:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [detect-changes]
if: needs.detect-changes.outputs.backend == 'true' || needs.detect-changes.outputs.infra == 'true'
env: env:
GOPRIVATE: git.gobha.me/* GOPRIVATE: git.gobha.me/*
GONOSUMCHECK: git.gobha.me/* GONOSUMCHECK: git.gobha.me/*
@@ -244,9 +351,19 @@ jobs:
echo "✓ SQLite integration tests complete" echo "✓ SQLite integration tests complete"
# ── Stage 2: Build, Database, Deploy ───────── # ── Stage 2: Build, Database, Deploy ─────────
#
# Depends on all test jobs. Skipped jobs (due to path gating)
# are treated as successful — no blocking.
#
# Skipped entirely for docs-only changes (nothing to build).
build-and-deploy: build-and-deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [test, test-frontend, test-sqlite] needs: [detect-changes, test, test-frontend, test-sqlite]
# Run unless: a needed job failed, the workflow was cancelled, or it's docs-only.
# Skipped test jobs (path-gated) are fine — they don't block.
if: |
!cancelled() && !failure() &&
needs.detect-changes.outputs.docs_only != 'true'
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4

View File

@@ -2,6 +2,71 @@
All notable changes to Chat Switchboard. All notable changes to Chat Switchboard.
## [0.17.2] — 2026-02-28
### Added
- **CodeMirror 6 integration.** Rich editor infrastructure compiled at Docker
build time via esbuild (IIFE bundle, ~295KB min / ~90KB gzip). Two factory
functions exposed on `window.CM`:
- `CM.chatInput()` — Markdown-mode editor for the chat input with
auto-growing height, Enter=send / Shift+Enter=newline, spell check, and
WYSIWYG fenced code block decorations (visual container with monospace
font and accent border, matching claude.ai UX).
- `CM.codeEditor()` — Full-featured code editor for admin extension panel
with line numbers, bracket matching, search/replace, fold gutter, and 10
bundled language modes (Markdown, JavaScript, JSON, SQL, HTML, CSS, YAML,
Go, Python, Rust).
- **Graceful degradation.** All CM6 integration points check `window.CM`
availability. If the bundle fails to load, the app falls back to native
`<textarea>` with zero breakage.
- **Dark/Light/System theme toggle.** New appearance setting with three
modes. Light theme overrides all CSS variables via `[data-theme="light"]`
selector. System mode tracks `prefers-color-scheme` media query in real
time. Theme changes emit `theme.changed` on the EventBus; CM6 editors
toggle `oneDark` syntax theme via compartment reconfiguration.
- **Vim/Emacs keybinding preference.** Editor keybinding mode (Standard /
Vim / Emacs) configurable in appearance settings. Applies to code editors
and extension editors only — chat input always uses standard keybindings.
Live-switchable on already-open editors via `keymap.changed` event.
Bundled statically (~40KB for both modes).
- **Inline code shortcut.** Ctrl/Cmd+E wraps selection in backticks or
inserts an empty inline code pair with cursor between them.
- **Code block shortcut.** Typing ` ``` ` at the start of a line expands
to a fenced code block with cursor positioned inside. The decoration plugin
renders code blocks with a styled visual container in the chat input.
- **CI path-based change detection.** New `detect-changes` job classifies
changed files into frontend/backend/infra/docs buckets. Test jobs skip
when irrelevant (FE-only changes skip Go tests, docs-only changes skip
all tests and deploy). Tags always run the full pipeline.
### Changed
- Docker build pipeline: both `Dockerfile.frontend` and unified `Dockerfile`
now include a `cm6-build` stage (Node 20 Alpine → esbuild → IIFE bundle).
- `ChatInput` abstraction in `chat.js` replaces direct textarea access
across 7 callsites (`chat.js`, `tokens.js`, `attachments.js`).
- Extension editor in `admin-handlers.js` uses `CM.codeEditor()` with JSON
and JavaScript modes, replacing bare `<textarea>` with manual Tab handler.
- Service worker excludes `/vendor/codemirror/` from cache (version-busted).
- Debug state snapshot includes CM6 version and language list.
- `ARCHITECTURE.md` updated to v0.17 reflecting CM6 integration, SQLite
dual-driver, modular frontend file structure, and theme system.
- CI pipeline header updated to v0.17.2 with path gating documentation.
- `build-editor.sh` falls back to `npm install` if `package-lock.json`
is missing (belt-and-suspenders for local dev).
### Fixed
- **App initialization crash.** `Events.publish` (nonexistent) → `Events.emit`
(correct API). The unhandled exception during `initAppearance()` killed
`initListeners()`, leaving the entire app half-initialized — settings modal
unclosable, keyboard shortcuts unwired, paste handlers missing.
- **Cursor invisible in dark and light mode.** CM6 cursor used `--accent`
color (low contrast on both themes). Switched to `--text` for consistent
visibility. Added explicit `borderLeftWidth: 2px`.
- **Placeholder text offset.** Double padding between `.cm-editor` wrapper
(12px) and `.cm-content` (8px) pushed placeholder 20px below expected
position. Zeroed `.cm-content` padding — wrapper is the single source of
truth.
## [0.17.1] — 2026-02-27 ## [0.17.1] — 2026-02-27
### Added ### Added

View File

@@ -3,7 +3,8 @@
# ============================================ # ============================================
# Stage 1: Build Go backend # Stage 1: Build Go backend
# Stage 2: Download JS vendor libs (marked, DOMPurify) # Stage 2: Download JS vendor libs (marked, DOMPurify)
# Stage 3: Production image (nginx + backend) # Stage 3: Build CM6 editor bundle (esbuild)
# Stage 4: Production image (nginx + backend)
# #
# Vendor libs are baked in during build so the # Vendor libs are baked in during build so the
# app works in disconnected environments # app works in disconnected environments
@@ -48,7 +49,15 @@ RUN npm pack katex@0.16.11 && \
cp -r package/dist/fonts katex/fonts && \ cp -r package/dist/fonts katex/fonts && \
rm -rf package katex-0.16.11.tgz rm -rf package katex-0.16.11.tgz
# ── Stage 3: Production ───────────────────── # ── Stage 3: CM6 editor bundle ─────────────
FROM node:20-alpine AS cm6-build
WORKDIR /build
COPY src/editor/ /build/src/editor/
COPY scripts/build-editor.sh /build/scripts/build-editor.sh
RUN cd /build/src/editor && npm ci --loglevel=warn
RUN sh /build/scripts/build-editor.sh /build/dist
# ── Stage 4: Production ─────────────────────
FROM nginx:1-alpine FROM nginx:1-alpine
RUN apk add --no-cache bash RUN apk add --no-cache bash
@@ -74,6 +83,7 @@ COPY --from=vendor /vendor/mermaid/mermaid.min.js /usr/share/nginx/html/vendor/m
COPY --from=vendor /vendor/katex/katex.min.js /usr/share/nginx/html/vendor/katex/katex.min.js COPY --from=vendor /vendor/katex/katex.min.js /usr/share/nginx/html/vendor/katex/katex.min.js
COPY --from=vendor /vendor/katex/katex.min.css /usr/share/nginx/html/vendor/katex/katex.min.css COPY --from=vendor /vendor/katex/katex.min.css /usr/share/nginx/html/vendor/katex/katex.min.css
COPY --from=vendor /vendor/katex/fonts/ /usr/share/nginx/html/vendor/katex/fonts/ COPY --from=vendor /vendor/katex/fonts/ /usr/share/nginx/html/vendor/katex/fonts/
COPY --from=cm6-build /build/dist/ /usr/share/nginx/html/vendor/codemirror/
# Builtin extensions (seeded into DB on startup by backend) # Builtin extensions (seeded into DB on startup by backend)
COPY extensions/builtin/ /app/extensions/builtin/ COPY extensions/builtin/ /app/extensions/builtin/

View File

@@ -30,7 +30,15 @@ RUN npm pack marked@16.3.0 dompurify@3.2.4 mermaid@11.4.1 katex@0.16.11 2>/dev/n
cp -r /tmp/package/dist/fonts/* /vendor/katex/fonts/ && \ cp -r /tmp/package/dist/fonts/* /vendor/katex/fonts/ && \
rm -rf /tmp/package /tmp/*.tgz rm -rf /tmp/package /tmp/*.tgz
# Stage 2: nginx + entrypoint # Stage 2: CM6 editor bundle (esbuild → single IIFE)
FROM node:20-alpine AS cm6-build
WORKDIR /build
COPY src/editor/ /build/src/editor/
COPY scripts/build-editor.sh /build/scripts/build-editor.sh
RUN cd /build/src/editor && npm ci --loglevel=warn
RUN sh /build/scripts/build-editor.sh /build/dist
# Stage 3: nginx + entrypoint
FROM nginx:1-alpine FROM nginx:1-alpine
# Remove default config — entrypoint generates it # Remove default config — entrypoint generates it
@@ -38,6 +46,7 @@ RUN rm -f /etc/nginx/conf.d/default.conf
COPY src/ /usr/share/nginx/html/ COPY src/ /usr/share/nginx/html/
COPY --from=vendor /vendor/ /usr/share/nginx/html/vendor/ COPY --from=vendor /vendor/ /usr/share/nginx/html/vendor/
COPY --from=cm6-build /build/dist/ /usr/share/nginx/html/vendor/codemirror/
COPY VERSION /VERSION COPY VERSION /VERSION
COPY docker-entrypoint-fe.sh /docker-entrypoint-fe.sh COPY docker-entrypoint-fe.sh /docker-entrypoint-fe.sh
RUN chmod +x /docker-entrypoint-fe.sh RUN chmod +x /docker-entrypoint-fe.sh

View File

@@ -1 +1 @@
0.17.1 0.17.2

View File

@@ -1,4 +1,4 @@
# Architecture — Chat Switchboard v0.11 # Architecture — Chat Switchboard v0.17
## Deployment Modes ## Deployment Modes
@@ -8,9 +8,9 @@ Three Docker images support different deployment scenarios:
|-------|-----------|----------|----------| |-------|-----------|----------|----------|
| **Unified** | `Dockerfile` | nginx + Go backend | Dev, docker-compose, single-node | | **Unified** | `Dockerfile` | nginx + Go backend | Dev, docker-compose, single-node |
| **Backend** | `server/Dockerfile` | Go binary only | K8s — scale API independently | | **Backend** | `server/Dockerfile` | Go binary only | K8s — scale API independently |
| **Frontend** | `Dockerfile.frontend` | nginx + static files | K8s — scale FE independently | | **Frontend** | `Dockerfile.frontend` | nginx + static files + CM6 bundle | K8s — scale FE independently |
**Unified** bundles everything in one container. Nginx serves static files and proxies `/api/*` and `/ws` to the Go backend running on `:8080`. Good for development and small deployments. **Unified** bundles everything in one container (4-stage Docker build: vendor libs → CM6 bundle → Go backend → nginx runtime). Nginx serves static files and proxies `/api/*` and `/ws` to the Go backend running on `:8080`. Good for development and small deployments.
**Split (Backend + Frontend)** for production K8s: Ingress routes `/api/*` and `/ws` to the backend Service, everything else to the frontend Service. The frontend entrypoint (`docker-entrypoint-fe.sh`) handles `BASE_PATH` injection into `index.html` and dynamic nginx config generation at startup. Supports branding volume mounts at `/branding/`. **Split (Backend + Frontend)** for production K8s: Ingress routes `/api/*` and `/ws` to the backend Service, everything else to the frontend Service. The frontend entrypoint (`docker-entrypoint-fe.sh`) handles `BASE_PATH` injection into `index.html` and dynamic nginx config generation at startup. Supports branding volume mounts at `/branding/`.
@@ -29,8 +29,8 @@ Three Docker images support different deployment scenarios:
└──────┬──────┘ └────────────────┘ └──────┬──────┘ └────────────────┘
┌──────▼──────┐ ┌──────▼──────┐
│ PostgreSQL │ │ PostgreSQL │ (or SQLite for
└─────────────┘ └─────────────┘ single-node)
``` ```
## Design Principles ## Design Principles
@@ -39,7 +39,7 @@ Three Docker images support different deployment scenarios:
2. **Roles vs Teams**: Clean separation between vertical permissions (Roles: admin, user) and horizontal visibility (Teams: organizational units). A user's role determines what they can *do*; their team membership determines what they can *see*. 2. **Roles vs Teams**: Clean separation between vertical permissions (Roles: admin, user) and horizontal visibility (Teams: organizational units). A user's role determines what they can *do*; their team membership determines what they can *see*.
3. **Store Layer**: All database access goes through typed Go interfaces (`store.Stores`). Handlers never write raw SQL. This enables future portability (SQLite for dev, Postgres for prod) and testability (mock stores). 3. **Store Layer**: All database access goes through typed Go interfaces (`store.Stores`). Handlers never write raw SQL. The store layer has two implementations — `store/postgres/` and `store/sqlite/` — selected at startup via `DB_DRIVER`. This enables Postgres for production and SQLite for single-node or air-gapped deployments.
4. **Scope Model**: Provider configs, personas, and model settings all use a three-value `scope` column: `global` (admin-managed, visible to all), `team` (team-admin-managed, visible to team), `personal` (user-managed, visible to owner). The `owner_id` column points to the owning user or team depending on scope. 4. **Scope Model**: Provider configs, personas, and model settings all use a three-value `scope` column: `global` (admin-managed, visible to all), `team` (team-admin-managed, visible to team), `personal` (user-managed, visible to owner). The `owner_id` column points to the owning user or team depending on scope.
@@ -112,37 +112,51 @@ server/
├── main.go # Wiring: stores → handlers → routes ├── main.go # Wiring: stores → handlers → routes
├── config/config.go # Env-based configuration ├── config/config.go # Env-based configuration
├── database/ ├── database/
│ ├── database.go # Connection management │ ├── database.go # Connection management (PG + SQLite)
│ ├── migrate.go # Auto-migration on startup │ ├── migrate.go # Auto-migration on startup
│ └── migrations/ │ └── migrations/
── 001_v09_schema.sql # Consolidated schema ── 001_v016_schema.sql # Consolidated schema (PG)
│ └── 002_v017_persona_kb.sql
├── store/ ├── store/
│ ├── interfaces.go # Store interfaces + shared types │ ├── interfaces.go # Store interfaces + shared types
── postgres/ # Postgres implementations ── postgres/ # Postgres implementations (19 stores)
│ │ ├── stores.go # NewStores() constructor
│ │ ├── provider.go # ProviderStore
│ │ ├── catalog.go # CatalogStore
│ │ ├── persona.go # PersonaStore
│ │ ├── channel.go # ChannelStore
│ │ ├── message.go # MessageStore
│ │ ├── user.go # UserStore
│ │ ├── team.go # TeamStore
│ │ ├── note.go # NoteStore
│ │ ├── knowledge.go # KnowledgeStore
│ │ └── ...
│ └── sqlite/ # SQLite implementations (19 stores)
│ ├── stores.go # NewStores() constructor │ ├── stores.go # NewStores() constructor
── provider.go # ProviderStore ── ... # Mirror of postgres/ with dialect adaptations
│ ├── catalog.go # CatalogStore
│ ├── persona.go # PersonaStore
│ ├── user.go # UserStore
│ ├── team.go # TeamStore
│ ├── policy.go # PolicyStore
│ ├── audit.go # AuditStore
│ └── ...
├── models/models.go # Shared domain types ├── models/models.go # Shared domain types
├── capabilities/ ├── capabilities/
│ ├── intrinsic.go # Heuristic detection + resolution │ ├── intrinsic.go # Heuristic detection + resolution
│ └── resolver.go # ModelsForUser() unified resolver │ └── resolver.go # ModelsForUser() unified resolver
├── compaction/ # Conversation summarization engine
├── crypto/ # AES-256-GCM API key encryption
├── events/ # EventBus + WebSocket hub
├── extraction/ # Document text extraction pipeline
├── handlers/ # HTTP handlers (Gin) ├── handlers/ # HTTP handlers (Gin)
│ ├── auth.go # Login, register, refresh, logout │ ├── auth.go # Login, register, refresh, logout
│ ├── admin.go # User/config/model management │ ├── admin.go # User/config/model management
│ ├── channels.go # Channel CRUD │ ├── channels.go # Channel CRUD
│ ├── messages.go # Message CRUD + forking │ ├── messages.go # Message CRUD + forking
│ ├── completion.go # Chat completions (SSE streaming) │ ├── completion.go # Chat completions (SSE streaming)
│ ├── stream_loop.go # Shared streaming + tool execution loop
│ ├── capabilities.go # Model list + ResolveModelCaps │ ├── capabilities.go # Model list + ResolveModelCaps
│ ├── presets.go # Persona CRUD (all scopes) │ ├── presets.go # Persona CRUD (all scopes)
│ ├── apiconfigs.go # User provider config CRUD (BYOK) │ ├── apiconfigs.go # User provider config CRUD (BYOK)
│ ├── teams.go # Team management │ ├── teams.go # Team management
│ ├── notes.go # Notes CRUD + search
│ ├── knowledge.go # Knowledge base management
│ └── ... │ └── ...
├── knowledge/ # KB chunking, embedding, search
├── providers/ # LLM provider adapters ├── providers/ # LLM provider adapters
│ ├── provider.go # Provider interface │ ├── provider.go # Provider interface
│ ├── anthropic.go │ ├── anthropic.go
@@ -150,8 +164,8 @@ server/
│ ├── openrouter.go │ ├── openrouter.go
│ └── venice.go │ └── venice.go
├── middleware/ # Auth, admin, CORS, rate limiting ├── middleware/ # Auth, admin, CORS, rate limiting
├── events/ # EventBus + WebSocket hub ├── storage/ # Blob storage (PVC + S3)
└── tools/ # Built-in tool definitions (notes) └── tools/ # Built-in tool definitions
``` ```
## Store Layer Pattern ## Store Layer Pattern
@@ -210,25 +224,80 @@ Used by: `provider_configs`, `personas`, `model_catalog` (visibility column adds
## Schema Migration ## Schema Migration
Single consolidated migration (`001_v09_schema.sql`) replaces the previous 21 incremental migrations. The Go backend auto-migrates on startup: Migrations are handled by the backend on startup (no separate migration job):
1. Creates `schema_migrations` table if absent 1. Creates `schema_migrations` table if absent
2. Checks which migration files have been applied 2. Checks which migration files have been applied
3. Applies any new `.sql` files in order 3. Applies any new `.sql` files in order
Postgres uses consolidated schemas (`001_v016_schema.sql` + incremental). SQLite uses application-generated UUIDs and `datetime()` instead of `gen_random_uuid()` and `now()`. Both drivers share the same migration tracking table.
## Frontend Architecture ## Frontend Architecture
Vanilla JavaScript, no build step. Five files with clear responsibilities: Vanilla JavaScript, no framework. The frontend ships as static files served by nginx. The only build step is the CM6 editor bundle, compiled at Docker build time via esbuild (IIFE output, no runtime bundler).
| File | Role | ### File Structure
|------|------|
| `api.js` | HTTP client with token refresh. All backend calls. |
| `app.js` | Application state machine. Business logic. |
| `ui.js` | DOM rendering. All `document.createElement` calls. |
| `events.js` | Labeled event bus with WebSocket bridge. |
| `debug.js` | Admin debug panel (model list, stats, config). |
Communication: `app.js` calls `API.*` methods, updates state, then calls `UI.*` methods to render. `events.js` handles real-time updates via WebSocket. No framework, no virtual DOM, no reactive bindings. ```
src/
├── index.html # Single-page app shell
├── sw.js # Service worker (offline cache)
├── manifest.json # PWA manifest
├── css/styles.css # All styles (CSS variables, light/dark themes)
├── js/
│ ├── api.js # HTTP client with token refresh
│ ├── app.js # Application state machine, startup, routing
│ ├── chat.js # Chat input abstraction, message send/receive
│ ├── events.js # Labeled event bus + WebSocket bridge
│ ├── debug.js # Debug panel, diagnostics, state export
│ ├── ui-core.js # DOM rendering, sidebar, modals, panels
│ ├── ui-settings.js # Settings tabs, theme, appearance, teams
│ ├── ui-admin.js # Admin panel rendering
│ ├── admin-handlers.js # Admin CRUD operations + extension editors
│ ├── settings-handlers.js # User settings CRUD, command palette
│ ├── tokens.js # Input token counting + context budget
│ ├── attachments.js # File upload, paste-to-file, lightbox
│ ├── notes.js # Notes panel CRUD
│ ├── knowledge.js # Knowledge base UI
│ └── __tests__/ # Node.js test suite (node --test)
├── editor/ # CM6 source (compiled at build time)
│ ├── package.json # CM6 + language mode dependencies
│ ├── package-lock.json # Lockfile for npm ci
│ ├── build.mjs # esbuild script → IIFE bundle
│ ├── index.mjs # Bundle entrypoint (window.CM)
│ ├── chat-input.mjs # Chat input factory (markdown, code blocks)
│ ├── code-editor.mjs # Code editor factory (syntax, keybindings)
│ └── theme.mjs # Switchboard + chat input themes
└── vendor/ # Vendored libraries (local + CDN fallback)
├── marked/ # Markdown renderer
├── purify/ # DOMPurify (XSS protection)
├── mermaid/ # Diagram renderer
├── katex/ # Math renderer
└── codemirror/ # CM6 bundle (built by Docker)
└── codemirror.bundle.js # ~295KB min, ~90KB gzip
```
### CM6 Integration
The CodeMirror 6 bundle provides two factory functions exposed on `window.CM`:
**`CM.chatInput(target, opts)`** — Markdown-mode editor for the chat input area. Features: auto-growing height, Enter=send / Shift+Enter=newline, WYSIWYG fenced code block decorations (visual container with monospace font and accent border), inline code shortcut (Ctrl/Cmd+E), spell check. Always uses standard keybindings.
**`CM.codeEditor(target, opts)`** — Full-featured code editor for the admin extension panel (JSON manifests, JavaScript scripts). Features: line numbers, bracket matching, search/replace, fold gutter, 10 bundled language modes. Supports Vim and Emacs keybindings via user preference (reconfigurable at runtime via compartments).
Both factories return a clean API: `getValue()`, `setValue()`, `focus()`, `destroy()`. The `ChatInput` abstraction in `chat.js` wraps the CM6 instance with a textarea fallback — all callsites use `ChatInput.getValue()` etc., never raw DOM access.
**Graceful degradation**: Every integration point checks `window.CM` before using CM6. If the bundle fails to load (air-gapped without the build step, CDN issues), the app falls back to native `<textarea>` with zero breakage.
### Theme System
CSS variables define the color palette in `:root` (dark, default) and `[data-theme="light"]` (light override). All UI components — including CM6 editors — reference these variables, so theme switches propagate instantly without editor reconfiguration.
The appearance settings offer three modes: Light, Dark, System. System mode listens to `prefers-color-scheme` and re-evaluates on OS theme change. Theme changes emit `theme.changed` on the EventBus; CM6 code editors toggle the `oneDark` syntax theme via compartment reconfiguration.
### Communication Pattern
`app.js` calls `API.*` methods, updates state, then calls `UI.*` methods to render. `events.js` handles real-time updates via WebSocket. No framework, no virtual DOM, no reactive bindings.
## Security Model ## Security Model

451
docs/DESIGN-0.17.3.md Normal file
View File

@@ -0,0 +1,451 @@
# DESIGN: Notes Rich Text Editor + Obsidian-Style Linking
**Version:** v0.17.3
**Status:** Draft
**Scope:** Upgrade the notes editor from a plain `<textarea>` to a CM6-powered
markdown editor with live preview, `[[wikilink]]` resolution, and a backlinks
system.
**Depends on:** v0.17.2 (CodeMirror 6 bundle provides `CM.codeEditor()` and
the markdown language mode).
---
## Motivation
The notes system (v0.9.3 side panel, CRUD, folders, search) stores markdown
but edits it as raw text in a `<textarea>`. Now that CM6 is bundled, we can
give notes the same live-preview markdown experience that the chat input gets
— plus something the chat input doesn't need: **inter-note linking**.
The Obsidian model is the right reference: markdown files with `[[wikilinks]]`
resolved at render time, bidirectional backlinks, and create-on-reference.
Our advantage over Obsidian: we already have **semantic search via embeddings**
(v0.14.0 KB infrastructure), so link autocomplete can be smarter than
filename matching.
---
## Architecture
### New CM6 Factory: `CM.noteEditor()`
A third factory alongside `chatInput()` and `codeEditor()`, tailored for
long-form markdown editing in the notes panel.
```javascript
CM.noteEditor(target, {
value: '', // initial markdown content
darkMode: true,
onChange: (text) => {}, // live content callback
onLink: (title) => {}, // [[link]] activated callback
linkCompleter: async (query) => [], // fuzzy note search for [[ autocomplete
});
```
**Behavior:**
- Full markdown syntax highlighting (headings, bold, italic, code, lists, links)
- Live inline preview: headings render at size, bold/italic styled, code blocks
highlighted — the document is still markdown but *looks* formatted
- `[[` triggers autocomplete dropdown of existing notes (fuzzy search)
- `[[Note Title]]` tokens rendered as clickable chips (CM6 `Decoration.widget`)
- Line numbers off (it's a note, not code)
- Spell check enabled (`EditorView.contentAttributes: { spellcheck: 'true' }`)
- Vim/Emacs keybindings respected (user preference from v0.17.2)
### Wikilink Syntax
Standard double-bracket syntax with optional display text:
```
[[Note Title]] → links to note titled "Note Title"
[[Note Title|display text]] → shows "display text", links to "Note Title"
```
Resolution order:
1. Exact title match (case-insensitive)
2. Fuzzy title match (for autocomplete, not for rendering)
3. Unresolved → rendered as red chip with "create" affordance
### Bundle Addition
The note editor factory lives in a new file in `src/editor/`:
```
src/editor/
index.mjs # existing — adds noteEditor to window.CM
chat-input.mjs # existing
code-editor.mjs # existing
note-editor.mjs # NEW — noteEditor() factory
wikilink.mjs # NEW — CM6 extension: parse, decorate, autocomplete [[links]]
theme.mjs # existing
```
The wikilink extension is CM6-native: a `ViewPlugin` that scans for `[[...]]`
patterns and replaces them with `Decoration.widget` nodes (clickable chips).
The autocomplete uses CM6's `autocompletion` with a custom `completeFromList`
source triggered by `[[`.
No bundle size concern — the wikilink plugin is tiny custom code, not an
external dependency.
---
## Data Model
### `note_links` Table
Tracks directed links between notes, extracted on save.
```sql
-- Postgres
CREATE TABLE IF NOT EXISTS note_links (
source_note_id UUID NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
target_note_id UUID REFERENCES notes(id) ON DELETE CASCADE,
target_title TEXT NOT NULL, -- raw [[title]] text, for unresolved links
display_text TEXT, -- optional |alias
created_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (source_note_id, target_title)
);
CREATE INDEX idx_note_links_target ON note_links(target_note_id)
WHERE target_note_id IS NOT NULL;
-- SQLite
CREATE TABLE IF NOT EXISTS note_links (
source_note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
target_note_id TEXT REFERENCES notes(id) ON DELETE CASCADE,
target_title TEXT NOT NULL,
display_text TEXT,
created_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (source_note_id, target_title)
);
CREATE INDEX IF NOT EXISTS idx_note_links_target ON note_links(target_note_id)
WHERE target_note_id IS NOT NULL;
```
**Key design decisions:**
- `target_note_id` is **nullable** — an unresolved link (target note doesn't
exist yet) stores the title but has `NULL` for the FK. When a note with that
title is created, a background pass resolves dangling links.
- Primary key is `(source_note_id, target_title)` — a note can only link to
the same title once (last one wins if duplicated).
- `target_title` is always stored for human readability and re-resolution
after renames.
### Link Extraction (on save)
When a note is saved, the backend:
1. Parses content for `[[...]]` patterns (simple regex: `\[\[([^\]|]+)(?:\|([^\]]+))?\]\]`)
2. Resolves each title to a note ID: `SELECT id FROM notes WHERE LOWER(title) = LOWER($1) AND user_id = $2`
3. Deletes existing links for this source note: `DELETE FROM note_links WHERE source_note_id = $1`
4. Inserts fresh links with resolved (or NULL) target IDs
This full-replace approach is simple and correct — notes don't have thousands
of links, so the cost is negligible.
### Backlinks Query
```sql
SELECT n.id, n.title, n.folder_path, n.updated_at, nl.display_text
FROM note_links nl
JOIN notes n ON n.id = nl.source_note_id
WHERE nl.target_note_id = $1
ORDER BY n.updated_at DESC;
```
### Dangling Link Resolution
When a new note is created, resolve any dangling links pointing at its title:
```sql
UPDATE note_links
SET target_note_id = $1
WHERE target_note_id IS NULL
AND LOWER(target_title) = LOWER($2)
AND source_note_id IN (SELECT id FROM notes WHERE user_id = $3);
```
This runs once on note creation — cheap and ensures links "light up"
retroactively.
---
## API Changes
### Existing Endpoints (modified)
**`PUT /api/v1/notes/:id`** — after updating note content, re-extract links.
No API signature change; link extraction is a server-side side effect.
**`POST /api/v1/notes`** — after creating a note, extract links AND resolve
dangling links from other notes that reference this title.
### New Endpoints
**`GET /api/v1/notes/:id/backlinks`**
Returns notes that link to this note.
```json
{
"data": [
{
"id": "uuid",
"title": "Meeting Notes 2026-02-28",
"folder_path": "/work",
"updated_at": "2026-02-28T10:00:00Z",
"display_text": null
}
]
}
```
**`GET /api/v1/notes/search-titles?q=<query>&limit=10`**
Lightweight fuzzy title search for the `[[` autocomplete. Returns titles
and IDs only — no content, no embeddings. Fast path.
```json
{
"data": [
{ "id": "uuid", "title": "Project Kickoff Notes" },
{ "id": "uuid", "title": "Project Architecture" }
]
}
```
Uses `ILIKE` on Postgres, `LIKE` on SQLite (case-insensitive via COLLATE).
The semantic search endpoint (`GET /api/v1/notes/search`) remains available
for deeper searches but is overkill for autocomplete.
---
## Frontend Integration
### Note Editor Swap
In `notes.js`, `openNoteEditor()` currently sets `.value` on the
`#noteEditorContent` textarea. After v0.17.3:
```javascript
// Replace textarea with CM6 note editor
const container = document.getElementById('noteEditorContentContainer');
const editor = CM.noteEditor(container, {
value: note.content || '',
darkMode: document.body.classList.contains('dark-theme'),
onChange: (text) => {
// Could auto-save or mark dirty
},
onLink: (title) => {
// Navigate to linked note
const linked = _findNoteByTitle(title);
if (linked) {
openNoteEditor(linked.id);
} else {
// Offer to create
_createNoteFromLink(title);
}
},
linkCompleter: async (query) => {
const resp = await API.searchNoteTitles(query, 10);
return (resp.data || []).map(n => ({ label: n.title, id: n.id }));
},
});
```
The HTML changes from:
```html
<textarea id="noteEditorContent" rows="12" ...></textarea>
```
To:
```html
<div id="noteEditorContentContainer" class="notes-content-input"></div>
```
With graceful fallback: if `window.CM?.noteEditor` is undefined, create a
`<textarea>` inside the container (same behavior as today).
### Backlinks Panel
Below the note editor, a collapsible "Backlinks" section:
```html
<div id="noteBacklinks" class="note-backlinks" style="display:none">
<div class="note-backlinks-header" onclick="toggleBacklinks()">
<span>Linked mentions</span>
<span id="noteBacklinksCount" class="badge">0</span>
</div>
<div id="noteBacklinksList" class="note-backlinks-list"></div>
</div>
```
Loaded on `openNoteEditor()` for existing notes:
```javascript
async function loadBacklinks(noteId) {
const resp = await API.getNoteBacklinks(noteId);
const links = resp.data || [];
const el = document.getElementById('noteBacklinksList');
const count = document.getElementById('noteBacklinksCount');
count.textContent = links.length;
document.getElementById('noteBacklinks').style.display =
links.length > 0 ? '' : 'none';
el.innerHTML = links.map(n => `
<div class="note-backlink-item" onclick="openNoteEditor('${n.id}')">
<span class="note-backlink-title">${esc(n.title)}</span>
<span class="note-backlink-folder text-muted">${esc(n.folder_path)}</span>
</div>
`).join('');
}
```
### Note Preview (read mode)
The existing note list view shows raw markdown. After this release,
`[[Note Title]]` patterns in rendered markdown (via marked.js) become
clickable links. A small marked.js extension or post-render pass
converts `[[...]]` to `<a>` tags with `onclick` handlers.
---
## Access Control
**Links respect existing note ownership.** A user can only:
- Create links to notes they own (their own notes)
- See backlinks from notes they own
- Autocomplete searches their own notes
Team-scoped notes (via `team_id` on the notes table) follow the same
grant model as other team resources. When team notes ship (potentially
v0.23.0 channel-scoped notes), the link resolution query adds
`AND (user_id = $1 OR team_id IN (...))`.
For v0.17.3, the scope is **personal notes only** — which is the
current notes model.
---
## Store Layer
### `NoteLinkStore` Interface
```go
type NoteLinkStore interface {
// ReplaceLinks deletes existing links for sourceNoteID and inserts new ones.
// Each link has TargetTitle (always set) and TargetNoteID (nullable, resolved).
ReplaceLinks(ctx context.Context, sourceNoteID string, links []NoteLink) error
// ResolveByTitle sets target_note_id on dangling links matching the title.
ResolveByTitle(ctx context.Context, userID, targetNoteID, title string) error
// Backlinks returns notes that link to the given note.
Backlinks(ctx context.Context, noteID string) ([]NoteLinkResult, error)
}
type NoteLink struct {
TargetNoteID *string // nil for unresolved
TargetTitle string
DisplayText string
}
type NoteLinkResult struct {
SourceNoteID string
Title string
FolderPath string
UpdatedAt time.Time
DisplayText string
}
```
Implementations in both `store/postgres/note_link.go` and
`store/sqlite/note_link.go`.
### Link Extraction Helper
```go
// ExtractWikilinks parses [[Title]] and [[Title|Display]] from markdown content.
func ExtractWikilinks(content string) []NoteLink {
re := regexp.MustCompile(`\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]`)
matches := re.FindAllStringSubmatch(content, -1)
seen := make(map[string]bool)
var links []NoteLink
for _, m := range matches {
title := strings.TrimSpace(m[1])
if seen[strings.ToLower(title)] { continue }
seen[strings.ToLower(title)] = true
link := NoteLink{TargetTitle: title}
if len(m) > 2 && m[2] != "" {
link.DisplayText = strings.TrimSpace(m[2])
}
links = append(links, link)
}
return links
}
```
This lives in a shared `notes/` package (or in the handler) — it's pure
string parsing with no DB dependency.
---
## Migration
**Migration 002** (both Postgres and SQLite):
```sql
-- 002_note_links.sql
CREATE TABLE IF NOT EXISTS note_links ( ... );
CREATE INDEX ...;
```
No backfill needed — existing notes have no `[[links]]` in their content,
so the table starts empty. Links populate organically as users edit notes
with the new editor.
---
## Implementation Checklist
### Phase 1: Backend (links infrastructure)
- [ ] Migration 002: `note_links` table (Postgres + SQLite)
- [ ] `NoteLinkStore` interface + Postgres/SQLite implementations
- [ ] `ExtractWikilinks()` helper with tests
- [ ] Link extraction on note create/update in handlers
- [ ] Dangling link resolution on note create
- [ ] `GET /api/v1/notes/:id/backlinks` endpoint
- [ ] `GET /api/v1/notes/search-titles?q=` endpoint
- [ ] API client additions in `api.js`
### Phase 2: Frontend (CM6 note editor)
- [ ] `src/editor/note-editor.mjs``CM.noteEditor()` factory
- [ ] `src/editor/wikilink.mjs` — CM6 extension (parse, decorate, autocomplete)
- [ ] Rebuild CM6 bundle (add new modules to `index.mjs`)
- [ ] Replace `<textarea>` with CM6 note editor in `notes.js`
- [ ] `saveNote()` reads from CM6 `.getValue()`
- [ ] Graceful fallback if CM6 unavailable
- [ ] Backlinks panel UI below editor
- [ ] `[[link]]` rendering in note list preview (marked.js extension)
### Phase 3: Polish
- [ ] Wikilink chips styled to match app theme (accent color, hover state)
- [ ] Unresolved links styled distinctly (red/dashed, "click to create")
- [ ] Autocomplete dropdown styled (note title + folder path hint)
- [ ] Keyboard navigation in autocomplete (↑/↓, Enter to select, Esc to dismiss)
- [ ] Mobile/touch testing for link taps
- [ ] Update ARCHITECTURE.md
---
## Future (not in v0.17.3)
- **Graph view**: visual node graph of note connections (d3 force layout)
- **Orphan detection**: notes with no inbound or outbound links
- **Link suggestions**: "notes that might be related" via embedding similarity
(reuse KB infrastructure)
- **Transclusion**: `![[Note Title]]` embeds the target note's content inline
- **Tag linking**: `#tag` syntax as an alternative navigation path
- **Team note links**: cross-user linking when team/channel-scoped notes ship

View File

@@ -1,7 +1,7 @@
# DESIGN: CodeMirror 6 Integration # DESIGN: CodeMirror 6 Integration
**Version:** v0.18.0 (or fits into current cycle) **Version:** v0.17.2
**Status:** Draft **Status:** Complete
**Scope:** Add CM6 as a vendored dependency, bundled at Docker build time via esbuild. Three integration surfaces. **Scope:** Add CM6 as a vendored dependency, bundled at Docker build time via esbuild. Three integration surfaces.
--- ---

View File

@@ -60,7 +60,7 @@ v0.16.0 User Groups v0.17.0 Persona-KB Binding
┌───────┴──────────────┐ ┌───────┴──────────────┐
│ │ │ │
v0.17.1 SQLite Backend ✅ v0.17.2 CodeMirror 6 v0.17.1 SQLite Backend ✅ v0.17.2 CodeMirror 6
(dual DB) (editor bundle, chat (dual DB) (editor bundle, chat
│ input, ext editor) │ input, ext editor)
└───────┬──────────────┘ └───────┬──────────────┘
@@ -286,7 +286,7 @@ Depends on: v0.17.0 DB debt cleanup (store layer is sole DB interface).
--- ---
## v0.17.2 — CodeMirror 6 Integration ## v0.17.2 — CodeMirror 6 Integration
Rich editor infrastructure for the frontend. CM6 is ESM-native; the build Rich editor infrastructure for the frontend. CM6 is ESM-native; the build
step runs in Docker (esbuild → single IIFE bundle). No change to dev step runs in Docker (esbuild → single IIFE bundle). No change to dev
@@ -296,40 +296,44 @@ Depends on: nothing (frontend-only). Prerequisite for: extension surfaces
(v0.21.0 editor mode). See [DESIGN-CM6.md](DESIGN-CM6.md) for full spec. (v0.21.0 editor mode). See [DESIGN-CM6.md](DESIGN-CM6.md) for full spec.
**Build Pipeline** **Build Pipeline**
- [ ] `src/editor/` directory: `package.json`, `build.mjs`, `index.mjs`, language/theme modules - [x] `src/editor/` directory: `package.json`, `build.mjs`, `index.mjs`, language/theme modules
- [ ] `scripts/build-editor.sh`: shared build script for both Dockerfiles and local dev - [x] `scripts/build-editor.sh`: shared build script for both Dockerfiles and local dev
- [ ] New Docker stage (`cm6-build`): `npm ci` + `node build.mjs``vendor/codemirror/` - [x] New Docker stage (`cm6-build`): `npm ci` + `node build.mjs``vendor/codemirror/`
- [ ] Both `Dockerfile.frontend` and `Dockerfile` (unified) use the shared build script - [x] Both `Dockerfile.frontend` and `Dockerfile` (unified) use the shared build script
- [ ] Bundle output: `codemirror.bundle.js` (~295KB min, ~90KB gzip) + `codemirror.bundle.css` - [x] Bundle output: `codemirror.bundle.js` (~295KB min, ~90KB gzip)
- [ ] Graceful degradation: all integration points check `window.CM` — falls back to `<textarea>` if bundle unavailable - [x] Graceful degradation: all integration points check `window.CM` — falls back to `<textarea>` if bundle unavailable
**Languages (bundled)** **Languages (bundled)**
- [ ] Markdown, JavaScript, JSON, SQL, HTML, CSS, YAML, Go, Python, Rust - [x] Markdown, JavaScript, JSON, SQL, HTML, CSS, YAML, Go, Python, Rust
- [ ] Additional modes: one-line import + rebuild - [x] Additional modes: one-line import + rebuild
**Phase 1: Extension Editor** (admin panel) **Phase 1: Extension Editor** (admin panel)
- [ ] `CM.codeEditor()` factory: line numbers, bracket matching, auto-indent, search/replace - [x] `CM.codeEditor()` factory: line numbers, bracket matching, auto-indent, search/replace
- [ ] Replace manifest `<textarea>` → CM6 JSON mode - [x] Replace manifest `<textarea>` → CM6 JSON mode
- [ ] Replace script `<textarea>` → CM6 JavaScript mode - [x] Replace script `<textarea>` → CM6 JavaScript mode
- [ ] Remove manual Tab-key handler in `admin-handlers.js` (CM6 handles it) - [x] Remove manual Tab-key handler in `admin-handlers.js` (CM6 handles it)
- [ ] Update `saveAdminExtension()` to use `.getValue()` - [x] Update `saveAdminExtension()` to use `.getValue()`
**Phase 2: Chat Input** (markdown mode) **Phase 2: Chat Input** (markdown mode)
- [ ] `CM.chatInput()` factory: markdown highlighting, minimal chrome (no line numbers, no gutter) - [x] `CM.chatInput()` factory: markdown highlighting, minimal chrome (no line numbers, no gutter)
- [ ] Enter=send, Shift+Enter=newline keybindings - [x] Enter=send, Shift+Enter=newline keybindings
- [ ] Auto-growing height, placeholder text - [x] Auto-growing height, placeholder text
- [ ] Wire `onChange``updateInputTokens()` - [x] Wire `onChange``updateInputTokens()`
- [ ] Update `chat.js`, `attachments.js`, `tokens.js` to use CM API - [x] Update `chat.js`, `attachments.js`, `tokens.js` to use CM API
- [ ] Test paste handling (plain text, code, attachments) and mobile/touch input - [x] WYSIWYG fenced code block decorations (visual container matching claude.ai UX)
- [x] Inline code shortcut (Ctrl/Cmd+E)
**Phase 3: Polish** **Phase 3: Polish**
- [ ] Theme integration: CSS variable overrides (`--bg`, `--border`, `--accent`, etc.) - [x] Theme integration: CSS variable overrides (`--bg`, `--border`, `--accent`, etc.)
- [ ] Dark/light mode switching (listen for theme toggle event) - [x] Dark/light/system mode toggle in appearance settings
- [ ] Vim/Emacs keybinding preference in appearance settings (`@replit/codemirror-vim`, `@replit/codemirror-emacs`) - [x] Vim/Emacs keybinding preference in appearance settings (`@replit/codemirror-vim`, `@replit/codemirror-emacs`)
- [ ] Vim/Emacs applies to code editor + extension editor only (never chat input) - [x] Vim/Emacs applies to code editor + extension editor only (never chat input)
- [ ] SW cache: exclude `vendor/codemirror/` or add to `SHELL_FILES` with version bust - [x] SW cache: exclude `vendor/codemirror/` with version bust
- [ ] Update `debug.js` snapshot to include CM6 version - [x] Update `debug.js` snapshot to include CM6 version
- [ ] Documentation in ARCHITECTURE.md - [x] Documentation in ARCHITECTURE.md
**CI/CD**
- [x] Path-based change detection gating (FE-only → skip BE tests, docs-only → skip all)
--- ---

44
scripts/build-editor.sh Normal file
View File

@@ -0,0 +1,44 @@
#!/bin/sh
# ==========================================
# scripts/build-editor.sh — Build CM6 Bundle
# ==========================================
# Shared by Dockerfile.frontend, Dockerfile (unified),
# and local development.
#
# Usage:
# ./scripts/build-editor.sh # → src/vendor/codemirror/
# ./scripts/build-editor.sh /build/dist # → /build/dist/ (Docker)
#
# Prerequisites: Node.js 20+, npm
# ==========================================
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
EDITOR_DIR="${SCRIPT_DIR}/../src/editor"
OUTPUT_DIR="${1:-${SCRIPT_DIR}/../src/vendor/codemirror}"
if [ ! -d "${EDITOR_DIR}" ]; then
echo "❌ Editor source not found: ${EDITOR_DIR}"
exit 1
fi
cd "${EDITOR_DIR}"
# Install dependencies if not already present (Docker will have run npm ci)
if [ ! -d node_modules ]; then
echo "📦 Installing CM6 dependencies..."
if [ -f package-lock.json ]; then
npm ci --loglevel=warn
else
echo " ⚠️ No package-lock.json — using npm install"
npm install --loglevel=warn
fi
fi
mkdir -p "${OUTPUT_DIR}"
echo "🔨 Building CM6 bundle..."
node build.mjs --outdir="${OUTPUT_DIR}"
echo "✅ CM6 bundle → ${OUTPUT_DIR}"
ls -lh "${OUTPUT_DIR}"/codemirror.bundle.* 2>/dev/null || true

View File

@@ -6,7 +6,7 @@
========================================== */ ========================================== */
:root { :root {
/* ── Core palette ────────────────────────── */ /* ── Core palette (Dark — default) ────────── */
--bg: #0e0e10; --bg: #0e0e10;
--bg-surface: #18181b; --bg-surface: #18181b;
--bg-raised: #222227; --bg-raised: #222227;
@@ -60,6 +60,44 @@
--banner-fg: transparent; --banner-fg: transparent;
} }
/* ── Light Theme ─────────────────────────────── */
[data-theme="light"] {
--bg: #ffffff;
--bg-surface: #f8f9fa;
--bg-raised: #f0f1f3;
--bg-hover: #e8e9ec;
--border: #d5d7db;
--border-light: #c0c3c9;
--text: #1a1a2e;
--text-2: #555770;
--text-3: #8b8da3;
--accent: #4a7cdb;
--accent-hover: #3968c4;
--accent-dim: rgba(74,124,219,0.10);
--danger: #dc2626;
--success: #16a34a;
--warning: #ca8a04;
--purple: #7c3aed;
--purple-dim: rgba(124,58,237,0.08);
--text-on-color: #fff;
--text-on-warning: #000;
--accent-light: #2563eb;
--danger-light: #dc2626;
--success-light: #16a34a;
--warning-light: #ca8a04;
--danger-dim: rgba(220,38,38,0.10);
--success-dim: rgba(22,163,74,0.10);
--warning-dim: rgba(202,138,4,0.10);
--overlay: rgba(0,0,0,0.35);
--glass: rgba(0,0,0,0.02);
color-scheme: light;
}
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; } html, body { height: 100%; }
body { font-family: var(--font); background: var(--bg); color: var(--text); overflow: hidden; font-size: 14px; } body { font-family: var(--font); background: var(--bg); color: var(--text); overflow: hidden; font-size: 14px; }
@@ -912,6 +950,27 @@ a:hover { text-decoration: underline; }
.input-wrap textarea:focus { outline: none; } .input-wrap textarea:focus { outline: none; }
.input-wrap textarea::placeholder { color: var(--text-3); } .input-wrap textarea::placeholder { color: var(--text-3); }
/* CM6 chat input — match textarea layout within .input-wrap */
#messageInputWrap {
flex: 1; min-width: 0;
}
#messageInputWrap .cm-editor {
background: transparent;
padding: 12px 0 12px 8px;
max-height: 200px;
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
}
#messageInputWrap .cm-editor.cm-focused { outline: none; }
#messageInputWrap .cm-scroller { overflow-y: auto; }
/* CM6 extension editor containers */
.ext-edit-form .cm-editor {
height: 100%;
font-size: 12px;
}
.input-actions { display: flex; align-items: center; gap: 2px; padding: 6px 8px; } .input-actions { display: flex; align-items: center; gap: 2px; padding: 6px 8px; }
/* Left-side toolbar: attach, tools, KB — single container handles alignment */ /* Left-side toolbar: attach, tools, KB — single container handles alignment */
@@ -1276,6 +1335,25 @@ select option { background: var(--bg-surface); color: var(--text); }
.range-labels { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-3); } .range-labels { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-3); }
.checkbox-label { display: flex; align-items: center; gap: 8px; font-size: 14px; cursor: pointer; margin: 0.5rem 0; color: var(--text-2); } .checkbox-label { display: flex; align-items: center; gap: 8px; font-size: 14px; cursor: pointer; margin: 0.5rem 0; color: var(--text-2); }
/* ── Theme Toggle ────────────────────────── */
.theme-toggle {
display: flex; gap: 4px; padding: 3px;
background: var(--bg); border: 1px solid var(--border);
border-radius: var(--radius); width: fit-content;
}
.theme-btn {
display: flex; align-items: center; gap: 6px;
padding: 6px 14px; border-radius: calc(var(--radius) - 2px);
border: none; background: none; color: var(--text-3);
font-family: var(--font); font-size: 13px; font-weight: 500;
cursor: pointer; transition: all var(--transition);
}
.theme-btn:hover { color: var(--text); background: var(--bg-hover); }
.theme-btn.active {
color: var(--text); background: var(--bg-raised);
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
}
/* ── Modal ────────────────────────────────── */ /* ── Modal ────────────────────────────────── */
.modal-overlay { .modal-overlay {

54
src/editor/build.mjs Normal file
View File

@@ -0,0 +1,54 @@
// ==========================================
// Chat Switchboard — CM6 Build Script
// ==========================================
// Bundles CodeMirror 6 into a single IIFE that
// exposes window.CM. Run via scripts/build-editor.sh
// or directly: node build.mjs [--outdir=path]
//
// Output:
// codemirror.bundle.js (~295KB min, ~90KB gzip)
// codemirror.bundle.css (~5-10KB)
// ==========================================
import { build } from 'esbuild';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Parse --outdir=<path> from argv
let outdir = resolve(__dirname, 'dist');
for (const arg of process.argv.slice(2)) {
if (arg.startsWith('--outdir=')) {
outdir = resolve(arg.slice('--outdir='.length));
}
}
try {
const result = await build({
entryPoints: [resolve(__dirname, 'index.mjs')],
bundle: true,
minify: true,
format: 'iife',
target: ['es2020'],
outfile: resolve(outdir, 'codemirror.bundle.js'),
// Metafile for optional size analysis
metafile: true,
// Log level
logLevel: 'info',
});
// Print bundle size summary
if (result.metafile) {
const outputs = result.metafile.outputs;
for (const [file, info] of Object.entries(outputs)) {
const kb = (info.bytes / 1024).toFixed(1);
console.log(` ${file}: ${kb} KB`);
}
}
console.log(`✅ CM6 bundle → ${outdir}`);
} catch (err) {
console.error('❌ CM6 build failed:', err.message);
process.exit(1);
}

310
src/editor/chat-input.mjs Normal file
View File

@@ -0,0 +1,310 @@
// ==========================================
// Chat Switchboard — CM6 Chat Input Factory
// ==========================================
// Minimal markdown editor for the chat message
// input. No line numbers, no gutter. Enter sends,
// Shift+Enter inserts newline. Auto-growing height.
//
// Usage:
// const editor = CM.chatInput(container, {
// placeholder: 'Send a message...',
// onSubmit: (text) => sendMessage(text),
// onChange: (text) => updateInputTokens(text),
// maxHeight: 200,
// darkMode: true,
// });
//
// editor.getValue()
// editor.setValue(str)
// editor.focus()
// editor.destroy()
// ==========================================
import { EditorView, keymap, drawSelection, dropCursor,
highlightSpecialChars, placeholder as placeholderExt,
ViewPlugin, Decoration } from '@codemirror/view';
import { EditorState, Compartment, RangeSetBuilder } from '@codemirror/state';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { syntaxHighlighting, defaultHighlightStyle, syntaxTree } from '@codemirror/language';
import { chatInputTheme } from './theme.mjs';
// ── Auto-Height Extension ───────────────────
/**
* Extension that auto-grows the editor height to fit content,
* up to maxHeight pixels. Mirrors the existing textarea behavior:
* this.style.height = 'auto';
* this.style.height = Math.min(this.scrollHeight, 200) + 'px';
*/
function autoHeight(maxHeight = 200) {
return EditorView.updateListener.of((update) => {
if (update.docChanged || update.geometryChanged) {
const dom = update.view.dom;
dom.style.height = 'auto';
const scrollH = update.view.contentDOM.scrollHeight;
// Account for .cm-editor padding (12px top + 12px bottom from wrapper CSS)
const padV = 24;
const targetH = Math.min(scrollH + padV, maxHeight);
dom.style.height = targetH + 'px';
dom.style.overflowY = scrollH + padV > maxHeight ? 'auto' : 'hidden';
}
});
}
// ── Fenced Code Block Decorations ───────────
//
// Scans the markdown syntax tree for FencedCode nodes
// and applies line decorations so they render as visual
// code blocks (dark background, monospace, rounded corners)
// inside the chat input — matching the claude.ai UX.
const codeBlockDeco = ViewPlugin.fromClass(class {
decorations;
constructor(view) {
this.decorations = this.buildDecorations(view);
}
update(update) {
// Rebuild when content changes, viewport shifts, or the parser
// finishes an incremental parse (tree reference changes)
if (update.docChanged || update.viewportChanged ||
syntaxTree(update.startState) !== syntaxTree(update.state)) {
this.decorations = this.buildDecorations(update.view);
}
}
buildDecorations(view) {
const builder = new RangeSetBuilder();
const tree = syntaxTree(view.state);
const doc = view.state.doc;
tree.iterate({
enter(node) {
if (node.name !== 'FencedCode') return;
const startLine = doc.lineAt(node.from);
const endLine = doc.lineAt(node.to);
for (let ln = startLine.number; ln <= endLine.number; ln++) {
const line = doc.line(ln);
let cls;
if (ln === startLine.number) {
cls = 'cm-codeblock cm-codeblock-open';
} else if (ln === endLine.number) {
cls = 'cm-codeblock cm-codeblock-close';
} else {
cls = 'cm-codeblock cm-codeblock-mid';
}
// Single-line code block (just ```)
if (startLine.number === endLine.number) {
cls = 'cm-codeblock cm-codeblock-open cm-codeblock-close';
}
builder.add(line.from, line.from, Decoration.line({ class: cls }));
}
},
});
return builder.finish();
}
}, {
decorations: (v) => v.decorations,
});
// ── Code Block Shortcuts ────────────────────
/**
* Input handler: triple backtick → fenced code block.
* Triggers when user types the third ` completing ```
* at the start of a line (possibly preceded only by whitespace/text).
*/
function tripleBacktickHandler() {
return EditorView.inputHandler.of((view, from, to, text) => {
if (text !== '`') return false;
const line = view.state.doc.lineAt(from);
const textBeforeCursor = view.state.doc.sliceString(line.from, from);
// Match `` at end of line (completing ```) — only at line start
if (/^``$/.test(textBeforeCursor.trimStart()) && textBeforeCursor.trimStart() === '``') {
const leadingWhitespace = textBeforeCursor.match(/^(\s*)/)[1];
const block = leadingWhitespace + '```\n' + leadingWhitespace + '\n' + leadingWhitespace + '```';
view.dispatch({
changes: { from: line.from, to, insert: block },
selection: { anchor: line.from + leadingWhitespace.length + 4 },
});
return true;
}
return false;
});
}
/**
* Keybinding: Ctrl/Cmd+E wraps selection in inline code backticks,
* or inserts a pair and places cursor between them.
*/
function inlineCodeKeymap() {
return keymap.of([{
key: 'Mod-e',
run: (view) => {
const { from, to } = view.state.selection.main;
if (from !== to) {
const text = view.state.doc.sliceString(from, to);
view.dispatch({
changes: { from, to, insert: '`' + text + '`' },
selection: { anchor: from + 1, head: to + 1 },
});
} else {
view.dispatch({
changes: { from, to: from, insert: '``' },
selection: { anchor: from + 1 },
});
}
return true;
},
}]);
}
// ── Chat Input Factory ──────────────────────
/**
* Create a chat message input with markdown highlighting.
*
* @param {HTMLElement} target - Container element
* @param {Object} opts
* @param {string} [opts.placeholder='Send a message...']
* @param {Function} [opts.onSubmit] - Called with text on Enter (without Shift)
* @param {Function} [opts.onChange] - Called with text on every edit
* @param {number} [opts.maxHeight=200] - Max height in pixels before scrolling
* @param {boolean} [opts.darkMode] - Auto-detected from body if omitted
* @returns {{ getValue, setValue, focus, getView, destroy }}
*/
export function chatInput(target, opts = {}) {
const {
placeholder: placeholderText = 'Send a message...',
onSubmit = null,
onChange = null,
maxHeight = 200,
darkMode = document.documentElement.getAttribute('data-theme') !== 'light',
} = opts;
// Build extensions
const extensions = [
// Markdown highlighting
markdown({ base: markdownLanguage }),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
// Code block shortcuts
tripleBacktickHandler(), // ``` → fenced code block
inlineCodeKeymap(), // Ctrl/Cmd+E → inline code
// Code block visual decorations (WYSIWYG style)
codeBlockDeco,
// Base editing
highlightSpecialChars(),
history(),
drawSelection(),
dropCursor(),
closeBrackets(),
// Theme — minimal chrome, no gutters
chatInputTheme,
// Placeholder
placeholderExt(placeholderText),
// Auto-growing height
autoHeight(maxHeight),
// Keymaps — chat-specific: Enter sends, Shift+Enter newline
keymap.of([
// Enter to submit (must be before defaultKeymap)
{
key: 'Enter',
run: (view) => {
if (onSubmit) {
const text = view.state.doc.toString();
onSubmit(text);
return true;
}
return false;
},
},
// Shift+Enter to insert newline
{
key: 'Shift-Enter',
run: (view) => {
view.dispatch(
view.state.replaceSelection('\n')
);
return true;
},
},
...closeBracketsKeymap,
...defaultKeymap,
...historyKeymap,
]),
];
// Change listener
if (onChange) {
extensions.push(EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChange(update.state.doc.toString());
}
}));
}
// Enable spell check and standard text input attributes
extensions.push(
EditorView.contentAttributes.of({
spellcheck: 'true',
autocapitalize: 'sentences',
autocorrect: 'on',
})
);
// Single-line display mode: wrap long lines
extensions.push(EditorView.lineWrapping);
const view = new EditorView({
state: EditorState.create({ doc: '', extensions }),
parent: target,
});
// ── Public API ──────────────────────────
return {
getValue() {
return view.state.doc.toString();
},
setValue(text) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: text || '' },
});
},
focus() {
view.focus();
},
/** Access the underlying EditorView */
getView() {
return view;
},
/** Clean up */
destroy() {
view.destroy();
},
};
}

241
src/editor/code-editor.mjs Normal file
View File

@@ -0,0 +1,241 @@
// ==========================================
// Chat Switchboard — CM6 Code Editor Factory
// ==========================================
// Full-featured code editor: line numbers, bracket
// matching, search/replace, auto-indent, syntax
// highlighting. Used for extension editing and
// future code surfaces.
//
// Usage:
// const editor = CM.codeEditor(container, {
// language: 'javascript',
// value: 'const x = 1;',
// lineNumbers: true,
// darkMode: true,
// readOnly: false,
// keymap: 'standard', // 'standard' | 'vim' | 'emacs'
// onChange: (value) => {},
// });
//
// editor.getValue()
// editor.setValue(str)
// editor.focus()
// editor.destroy()
// ==========================================
import { EditorView, keymap, lineNumbers, highlightActiveLine,
highlightActiveLineGutter, drawSelection, dropCursor,
rectangularSelection, crosshairCursor,
highlightSpecialChars, placeholder as placeholderExt } from '@codemirror/view';
import { EditorState, Compartment } from '@codemirror/state';
import { defaultKeymap, indentWithTab, history, historyKeymap,
undo, redo } from '@codemirror/commands';
import { bracketMatching, indentOnInput, foldGutter, foldKeymap,
syntaxHighlighting, defaultHighlightStyle,
HighlightStyle } from '@codemirror/language';
import { highlightSelectionMatches, searchKeymap } from '@codemirror/search';
import { autocompletion, completionKeymap, closeBrackets,
closeBracketsKeymap } from '@codemirror/autocomplete';
import { oneDark } from '@codemirror/theme-one-dark';
import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { sql } from '@codemirror/lang-sql';
import { html } from '@codemirror/lang-html';
import { css } from '@codemirror/lang-css';
import { yaml } from '@codemirror/lang-yaml';
import { go } from '@codemirror/lang-go';
import { python } from '@codemirror/lang-python';
import { rust } from '@codemirror/lang-rust';
import { vim } from '@replit/codemirror-vim';
import { emacs } from '@replit/codemirror-emacs';
import { switchboardTheme } from './theme.mjs';
// ── Language Registry ────────────────────────
const LANGUAGES = {
javascript, js: javascript,
json,
markdown, md: markdown,
sql,
html,
css,
yaml, yml: yaml,
go,
python, py: python,
rust, rs: rust,
};
/**
* Resolve a language name to a CM6 LanguageSupport instance.
* Returns null if the language isn't bundled.
*/
function resolveLanguage(name) {
if (!name) return null;
const factory = LANGUAGES[name.toLowerCase()];
return factory ? factory() : null;
}
// ── Code Editor Factory ─────────────────────
/**
* Create a full-featured code editor.
*
* @param {HTMLElement} target - Container element (editor replaces its content)
* @param {Object} opts
* @param {string} [opts.language] - Language mode name
* @param {string} [opts.value=''] - Initial content
* @param {boolean} [opts.lineNumbers=true]
* @param {boolean} [opts.darkMode] - Auto-detected from body if omitted
* @param {boolean} [opts.readOnly=false]
* @param {string} [opts.keymap='standard'] - 'standard' | 'vim' | 'emacs'
* @param {string} [opts.placeholder] - Placeholder text
* @param {Function} [opts.onChange] - Called with new value on every edit
* @returns {{ getValue, setValue, focus, getView, destroy }}
*/
export function codeEditor(target, opts = {}) {
const {
language = null,
value = '',
lineNumbers: showLineNumbers = true,
darkMode = document.documentElement.getAttribute('data-theme') !== 'light',
readOnly = false,
keymap: keymapMode = 'standard',
placeholder: placeholderText = '',
onChange = null,
} = opts;
// Compartments for reconfigurable extensions
const langCompartment = new Compartment();
const keymapCompartment = new Compartment();
const readOnlyCompartment = new Compartment();
const themeCompartment = new Compartment();
// Build extensions list
const extensions = [
// Base editing
highlightSpecialChars(),
history(),
drawSelection(),
dropCursor(),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
bracketMatching(),
closeBrackets(),
autocompletion(),
rectangularSelection(),
crosshairCursor(),
highlightActiveLine(),
highlightSelectionMatches(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
// Keymaps
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...foldKeymap,
...completionKeymap,
indentWithTab,
]),
// Theme
switchboardTheme,
themeCompartment.of(darkMode ? [oneDark] : []),
// Configurable compartments
langCompartment.of(resolveLanguage(language) || []),
keymapCompartment.of(_resolveKeymapExt(keymapMode)),
readOnlyCompartment.of(EditorState.readOnly.of(readOnly)),
// Optional features
...(showLineNumbers ? [lineNumbers(), highlightActiveLineGutter(), foldGutter()] : []),
...(placeholderText ? [placeholderExt(placeholderText)] : []),
];
// Change listener
if (onChange) {
extensions.push(EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChange(update.state.doc.toString());
}
}));
}
// Create the editor
const view = new EditorView({
state: EditorState.create({ doc: value, extensions }),
parent: target,
});
// ── Public API ──────────────────────────
return {
getValue() {
return view.state.doc.toString();
},
setValue(text) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: text },
});
},
focus() {
view.focus();
},
/** Access the underlying EditorView for advanced use */
getView() {
return view;
},
/** Switch language mode at runtime */
setLanguage(name) {
const lang = resolveLanguage(name);
view.dispatch({
effects: langCompartment.reconfigure(lang || []),
});
},
/** Switch keymap mode at runtime */
setKeymap(mode) {
view.dispatch({
effects: keymapCompartment.reconfigure(_resolveKeymapExt(mode)),
});
},
/** Toggle read-only state */
setReadOnly(ro) {
view.dispatch({
effects: readOnlyCompartment.reconfigure(
EditorState.readOnly.of(ro)
),
});
},
/** Toggle dark/light mode (controls oneDark syntax theme) */
setDarkMode(dark) {
view.dispatch({
effects: themeCompartment.reconfigure(dark ? [oneDark] : []),
});
},
/** Clean up — removes the editor from the DOM */
destroy() {
view.destroy();
},
};
}
// ── Keybinding Resolution ───────────────────
function _resolveKeymapExt(mode) {
if (mode === 'vim') return vim();
if (mode === 'emacs') return emacs();
return [];
}

61
src/editor/index.mjs Normal file
View File

@@ -0,0 +1,61 @@
// ==========================================
// Chat Switchboard — CM6 Bundle Entrypoint
// ==========================================
// esbuild compiles this into an IIFE that sets
// window.CM with factory functions and utilities.
//
// Consumers:
// if (window.CM) {
// const editor = CM.chatInput(el, opts);
// const code = CM.codeEditor(el, opts);
// }
// ==========================================
import { EditorView } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown';
import { sql } from '@codemirror/lang-sql';
import { html } from '@codemirror/lang-html';
import { css } from '@codemirror/lang-css';
import { yaml } from '@codemirror/lang-yaml';
import { go } from '@codemirror/lang-go';
import { python } from '@codemirror/lang-python';
import { rust } from '@codemirror/lang-rust';
import { vim } from '@replit/codemirror-vim';
import { emacs } from '@replit/codemirror-emacs';
import { codeEditor } from './code-editor.mjs';
import { chatInput } from './chat-input.mjs';
// ── Expose on window ────────────────────────
window.CM = {
// Version (injected by the build script or matched to app version)
version: '0.17.2',
// Low-level access (for advanced use / debug console)
EditorView,
EditorState,
// Factory: full-featured code editor
// CM.codeEditor(container, { language, value, lineNumbers, darkMode, keymap, onChange })
codeEditor,
// Factory: chat message input (markdown mode, minimal chrome)
// CM.chatInput(container, { placeholder, onSubmit, onChange, maxHeight, darkMode })
chatInput,
// Bundled language modes (for reference / dynamic use)
languages: {
javascript, json, markdown, sql, html, css, yaml, go, python, rust,
},
// Keybinding modes (for reference / settings UI)
keybindings: { vim, emacs },
};
console.log('[CM6] CodeMirror bundle loaded (v' + window.CM.version + ')');

917
src/editor/package-lock.json generated Normal file
View File

@@ -0,0 +1,917 @@
{
"name": "editor",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@codemirror/autocomplete": "^6",
"@codemirror/commands": "^6",
"@codemirror/lang-css": "^6",
"@codemirror/lang-go": "^6",
"@codemirror/lang-html": "^6",
"@codemirror/lang-javascript": "^6",
"@codemirror/lang-json": "^6",
"@codemirror/lang-markdown": "^6",
"@codemirror/lang-python": "^6",
"@codemirror/lang-rust": "^6",
"@codemirror/lang-sql": "^6",
"@codemirror/lang-yaml": "^6",
"@codemirror/language": "^6",
"@codemirror/search": "^6",
"@codemirror/state": "^6",
"@codemirror/theme-one-dark": "^6",
"@codemirror/view": "^6",
"@replit/codemirror-emacs": "^6",
"@replit/codemirror-vim": "^6"
},
"devDependencies": {
"esbuild": "^0.25.0"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz",
"integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.2",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.2.tgz",
"integrity": "sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.4.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-go": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-go/-/lang-go-6.0.1.tgz",
"integrity": "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.0",
"@lezer/go": "^1.0.0"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.11",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
"integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-json": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/json": "^1.0.0"
}
},
"node_modules/@codemirror/lang-markdown": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz",
"integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.3.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/lang-python": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz",
"integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.3.2",
"@codemirror/language": "^6.8.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/python": "^1.1.4"
}
},
"node_modules/@codemirror/lang-rust": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz",
"integrity": "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/rust": "^1.0.0"
}
},
"node_modules/@codemirror/lang-sql": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz",
"integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@codemirror/lang-yaml": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.2.tgz",
"integrity": "sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.2.0",
"@lezer/lr": "^1.0.0",
"@lezer/yaml": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.2",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.2.tgz",
"integrity": "sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.4",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.4.tgz",
"integrity": "sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.35.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz",
"integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.5.4",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz",
"integrity": "sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/theme-one-dark": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
"integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.39.15",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.15.tgz",
"integrity": "sha512-aCWjgweIIXLBHh7bY6cACvXuyrZ0xGafjQ2VInjp4RM4gMfscK5uESiNdrH0pE+e1lZr2B4ONGsjchl2KsKZzg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@lezer/common": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz",
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.1.tgz",
"integrity": "sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/go": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.1.tgz",
"integrity": "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.13",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/json": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.8",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz",
"integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/markdown": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.3.tgz",
"integrity": "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@lezer/python": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz",
"integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/rust": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.2.tgz",
"integrity": "sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/yaml": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz",
"integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.4.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@replit/codemirror-emacs": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@replit/codemirror-emacs/-/codemirror-emacs-6.1.0.tgz",
"integrity": "sha512-74DITnht6Cs6sHg02PQ169IKb1XgtyhI9sLD0JeOFco6Ds18PT+dkD8+DgXBDokne9UIFKsBbKPnpFRAz60/Lw==",
"license": "MIT",
"peerDependencies": {
"@codemirror/autocomplete": "^6.0.2",
"@codemirror/commands": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.1",
"@codemirror/view": "^6.3.0"
}
},
"node_modules/@replit/codemirror-vim": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@replit/codemirror-vim/-/codemirror-vim-6.3.0.tgz",
"integrity": "sha512-aTx931ULAMuJx6xLf7KQDOL7CxD+Sa05FktTDrtLaSy53uj01ll3Zf17JdKsriER248oS55GBzg0CfCTjEneAQ==",
"license": "MIT",
"peerDependencies": {
"@codemirror/commands": "6.x.x",
"@codemirror/language": "6.x.x",
"@codemirror/search": "6.x.x",
"@codemirror/state": "6.x.x",
"@codemirror/view": "6.x.x"
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
}
}
}

28
src/editor/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"private": true,
"type": "module",
"devDependencies": {
"esbuild": "^0.25.0"
},
"dependencies": {
"@codemirror/autocomplete": "^6",
"@codemirror/commands": "^6",
"@codemirror/lang-css": "^6",
"@codemirror/lang-go": "^6",
"@codemirror/lang-html": "^6",
"@codemirror/lang-javascript": "^6",
"@codemirror/lang-json": "^6",
"@codemirror/lang-markdown": "^6",
"@codemirror/lang-python": "^6",
"@codemirror/lang-rust": "^6",
"@codemirror/lang-sql": "^6",
"@codemirror/lang-yaml": "^6",
"@codemirror/language": "^6",
"@codemirror/search": "^6",
"@codemirror/state": "^6",
"@codemirror/theme-one-dark": "^6",
"@codemirror/view": "^6",
"@replit/codemirror-vim": "^6",
"@replit/codemirror-emacs": "^6"
}
}

186
src/editor/theme.mjs Normal file
View File

@@ -0,0 +1,186 @@
// ==========================================
// Chat Switchboard — CM6 Theme
// ==========================================
// Maps the app's CSS custom properties to CM6
// theme slots. Works in both light and dark mode.
//
// The theme is applied via EditorView.theme()
// which generates scoped CSS classes. Dark mode
// detection uses body.dark-theme (existing pattern).
// ==========================================
import { EditorView } from '@codemirror/view';
/**
* Switchboard base theme — uses CSS variables so it
* adapts automatically when the theme toggle fires.
*/
export const switchboardTheme = EditorView.theme({
'&': {
backgroundColor: 'var(--bg-surface, var(--bg))',
color: 'var(--text)',
fontSize: '14px',
},
'.cm-content': {
fontFamily: 'var(--mono, monospace)',
caretColor: 'var(--accent)',
padding: '4px 0',
},
'&.cm-focused': {
outline: 'none',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--accent)',
borderLeftWidth: '2px',
},
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground': {
backgroundColor: 'var(--accent-muted, rgba(99, 102, 241, 0.2))',
},
'.cm-activeLine': {
backgroundColor: 'var(--bg-hover, rgba(255, 255, 255, 0.03))',
},
'.cm-gutters': {
backgroundColor: 'var(--bg-2, var(--bg))',
color: 'var(--text-3, #666)',
borderRight: '1px solid var(--border)',
minWidth: '40px',
},
'.cm-activeLineGutter': {
backgroundColor: 'var(--bg-hover, rgba(255, 255, 255, 0.05))',
color: 'var(--text-2)',
},
'.cm-foldPlaceholder': {
backgroundColor: 'var(--bg-2)',
color: 'var(--text-3)',
border: '1px solid var(--border)',
borderRadius: '3px',
padding: '0 4px',
},
// Search panel
'.cm-panels': {
backgroundColor: 'var(--bg-2)',
color: 'var(--text)',
borderBottom: '1px solid var(--border)',
},
'.cm-panels.cm-panels-top': {
borderBottom: '1px solid var(--border)',
},
'.cm-panels.cm-panels-bottom': {
borderTop: '1px solid var(--border)',
},
'.cm-search input, .cm-search button': {
backgroundColor: 'var(--bg-surface)',
color: 'var(--text)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius, 4px)',
fontSize: '13px',
},
'.cm-search input:focus': {
borderColor: 'var(--accent)',
outline: 'none',
},
// Autocomplete
'.cm-tooltip': {
backgroundColor: 'var(--bg-2)',
color: 'var(--text)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius, 4px)',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
},
'.cm-tooltip-autocomplete > ul > li': {
padding: '4px 8px',
},
'.cm-tooltip-autocomplete > ul > li[aria-selected]': {
backgroundColor: 'var(--accent)',
color: '#fff',
},
// Matching brackets
'.cm-matchingBracket': {
backgroundColor: 'var(--accent-muted, rgba(99, 102, 241, 0.3))',
outline: '1px solid var(--accent)',
},
'.cm-nonmatchingBracket': {
color: 'var(--danger, #e74c3c)',
},
// Scrollbar
'.cm-scroller': {
fontFamily: 'var(--mono, monospace)',
overflow: 'auto',
},
});
/**
* Minimal theme for the chat input — removes gutters,
* line numbers, and active line highlighting.
*/
export const chatInputTheme = EditorView.theme({
'&': {
backgroundColor: 'transparent',
color: 'var(--text)',
fontSize: 'var(--msg-font, 14px)',
},
'.cm-content': {
fontFamily: 'var(--font, system-ui, sans-serif)',
caretColor: 'var(--text)',
padding: '0',
minHeight: '20px',
},
'&.cm-focused': {
outline: 'none',
},
// Cursor: high-contrast, always visible in both themes
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--text)',
borderLeftWidth: '2px',
},
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground': {
backgroundColor: 'var(--accent-dim, rgba(99, 102, 241, 0.2))',
},
'.cm-line': {
padding: '0',
},
'.cm-scroller': {
fontFamily: 'var(--font, system-ui, sans-serif)',
overflow: 'auto',
},
// Hide scrollbar but keep scrollable for auto-grow
'.cm-scroller::-webkit-scrollbar': {
display: 'none',
},
// Placeholder — vertically aligned with first line of input
'.cm-placeholder': {
color: 'var(--text-3, #888)',
fontStyle: 'normal',
lineHeight: 'inherit',
pointerEvents: 'none',
},
// Tooltip/autocomplete inherits from base
'.cm-tooltip': {
backgroundColor: 'var(--bg-2)',
color: 'var(--text)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius, 4px)',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
},
// ── Fenced code block decorations (WYSIWYG) ──
// Lines inside fenced code blocks get a visual container
'.cm-codeblock': {
backgroundColor: 'var(--bg-surface, #18181b)',
fontFamily: 'var(--mono, monospace)',
fontSize: '13px',
padding: '0 10px',
borderLeft: '2px solid var(--accent, #6c9fff)',
},
'.cm-codeblock.cm-codeblock-open': {
borderTopLeftRadius: 'var(--radius, 6px)',
borderTopRightRadius: 'var(--radius, 6px)',
paddingTop: '6px',
color: 'var(--text-3, #6b6b7b)',
},
'.cm-codeblock.cm-codeblock-close': {
borderBottomLeftRadius: 'var(--radius, 6px)',
borderBottomRightRadius: 'var(--radius, 6px)',
paddingBottom: '6px',
color: 'var(--text-3, #6b6b7b)',
},
});

View File

@@ -172,7 +172,9 @@
<div class="popup-menu kb-popup" id="kbPopup"></div> <div class="popup-menu kb-popup" id="kbPopup"></div>
</div> </div>
</div> </div>
<textarea id="messageInput" placeholder="Send a message..." rows="1"></textarea> <div id="messageInputWrap">
<textarea id="messageInput" placeholder="Send a message..." rows="1"></textarea>
</div>
<div class="input-actions"> <div class="input-actions">
<button class="action-btn stop-btn" id="stopBtn" title="Stop"> <button class="action-btn stop-btn" id="stopBtn" title="Stop">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
@@ -409,6 +411,26 @@
</div> </div>
<!-- Appearance Tab --> <!-- Appearance Tab -->
<div class="settings-tab-content" id="settingsAppearanceTab" style="display:none"> <div class="settings-tab-content" id="settingsAppearanceTab" style="display:none">
<section class="settings-section">
<h3>Theme</h3>
<div class="form-group">
<label>Mode</label>
<div class="theme-toggle" id="themeToggle">
<button class="theme-btn" data-theme="light" title="Light">
<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="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
Light
</button>
<button class="theme-btn" data-theme="dark" title="Dark">
<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 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
Dark
</button>
<button class="theme-btn" data-theme="system" title="System">
<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="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
System
</button>
</div>
</div>
</section>
<section class="settings-section"> <section class="settings-section">
<h3>Display</h3> <h3>Display</h3>
<div class="form-group"> <div class="form-group">
@@ -422,6 +444,18 @@
<div class="range-labels"><span>12px</span><span>20px</span></div> <div class="range-labels"><span>12px</span><span>20px</span></div>
</div> </div>
</section> </section>
<section class="settings-section">
<h3>Editor</h3>
<div class="form-group">
<label>Keybindings</label>
<div class="theme-toggle" id="keymapToggle">
<button class="theme-btn" data-keymap="standard" title="Standard keybindings">Standard</button>
<button class="theme-btn" data-keymap="vim" title="Vim keybindings">Vim</button>
<button class="theme-btn" data-keymap="emacs" title="Emacs keybindings">Emacs</button>
</div>
<span class="form-hint">Applies to code editors only — chat input always uses standard.</span>
</div>
</section>
</div> </div>
<!-- Providers Tab --> <!-- Providers Tab -->
<div class="settings-tab-content" id="settingsProvidersTab" style="display:none"> <div class="settings-tab-content" id="settingsProvidersTab" style="display:none">
@@ -984,6 +1018,7 @@
<!-- Vendor libs --> <!-- Vendor libs -->
<script src="vendor/marked.min.js" onerror="this.onerror=null;this.src='https://cdnjs.cloudflare.com/ajax/libs/marked/16.3.0/lib/marked.umd.min.js'"></script> <script src="vendor/marked.min.js" onerror="this.onerror=null;this.src='https://cdnjs.cloudflare.com/ajax/libs/marked/16.3.0/lib/marked.umd.min.js'"></script>
<script src="vendor/purify.min.js" onerror="this.onerror=null;this.src='https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.4/purify.min.js'"></script> <script src="vendor/purify.min.js" onerror="this.onerror=null;this.src='https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.4/purify.min.js'"></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/events.js?v=%%APP_VERSION%%"></script> <script src="js/events.js?v=%%APP_VERSION%%"></script>

View File

@@ -693,9 +693,40 @@ async function deleteAdminExtension(id, name) {
} catch (e) { UI.toast(e.message, 'error'); } } catch (e) { UI.toast(e.message, 'error'); }
} }
// Track CM6 editor instances per extension edit form
var _extEditors = {};
// Listen for theme changes to update CM6 editors
if (typeof Events !== 'undefined') {
Events.on('theme.changed', (data) => {
const isDark = data.resolved === 'dark';
for (const editors of Object.values(_extEditors)) {
editors.manifest?.setDarkMode(isDark);
editors.script?.setDarkMode(isDark);
}
});
// Listen for keymap changes to update CM6 editors in real time
Events.on('keymap.changed', (data) => {
const mode = data.mode || 'standard';
for (const editors of Object.values(_extEditors)) {
editors.manifest?.setKeymap(mode);
editors.script?.setKeymap(mode);
}
});
}
function editAdminExtension(id) { function editAdminExtension(id) {
// Close any existing edit form // Close any existing edit form (and destroy CM6 instances)
document.querySelectorAll('.ext-edit-form').forEach(el => el.remove()); document.querySelectorAll('.ext-edit-form').forEach(el => {
const eid = el.dataset.extId;
if (_extEditors[eid]) {
_extEditors[eid].manifest?.destroy();
_extEditors[eid].script?.destroy();
delete _extEditors[eid];
}
el.remove();
});
const ext = (UI._adminExtensions || []).find(e => e.id === id); const ext = (UI._adminExtensions || []).find(e => e.id === id);
if (!ext) return UI.toast('Extension not found', 'error'); if (!ext) return UI.toast('Extension not found', 'error');
@@ -716,6 +747,15 @@ function editAdminExtension(id) {
</div>` </div>`
: ''; : '';
// Use container divs instead of textareas when CM6 is available
const useCM6 = !!window.CM?.codeEditor;
const manifestEditor = useCM6
? `<div id="extEdit-manifest-${id}" style="width:100%;min-height:180px;border:1px solid var(--border);border-radius:var(--radius,4px);overflow:hidden"></div>`
: `<textarea id="extEdit-manifest-${id}" rows="8" style="width:100%;font-family:var(--mono);font-size:12px;tab-size:2">${esc(manifestJSON)}</textarea>`;
const scriptEditor = useCM6
? `<div id="extEdit-script-${id}" style="width:100%;min-height:320px;border:1px solid var(--border);border-radius:var(--radius,4px);overflow:hidden"></div>`
: `<textarea id="extEdit-script-${id}" rows="16" style="width:100%;font-family:var(--mono);font-size:12px;tab-size:2;white-space:pre">${esc(script)}</textarea>`;
const formHTML = ` const formHTML = `
<div class="ext-edit-form" data-ext-id="${id}" style="border-top:1px solid var(--border);padding:12px 0;margin-top:8px"> <div class="ext-edit-form" data-ext-id="${id}" style="border-top:1px solid var(--border);padding:12px 0;margin-top:8px">
${systemWarning} ${systemWarning}
@@ -729,14 +769,14 @@ function editAdminExtension(id) {
</div> </div>
<div class="form-group" style="margin-bottom:8px"> <div class="form-group" style="margin-bottom:8px">
<label style="font-size:12px;font-weight:600">Manifest JSON <span class="text-muted">(without _script)</span></label> <label style="font-size:12px;font-weight:600">Manifest JSON <span class="text-muted">(without _script)</span></label>
<textarea id="extEdit-manifest-${id}" rows="8" style="width:100%;font-family:var(--mono);font-size:12px;tab-size:2">${esc(manifestJSON)}</textarea> ${manifestEditor}
</div> </div>
<div class="form-group" style="margin-bottom:8px"> <div class="form-group" style="margin-bottom:8px">
<label style="font-size:12px;font-weight:600">Script <span class="text-muted">(${script.length.toLocaleString()} chars)</span></label> <label style="font-size:12px;font-weight:600">Script <span class="text-muted">(${script.length.toLocaleString()} chars)</span></label>
<textarea id="extEdit-script-${id}" rows="16" style="width:100%;font-family:var(--mono);font-size:12px;tab-size:2;white-space:pre">${esc(script)}</textarea> ${scriptEditor}
</div> </div>
<div style="display:flex;gap:8px;justify-content:flex-end"> <div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn-small" onclick="this.closest('.ext-edit-form').remove()">Cancel</button> <button class="btn-small" onclick="_closeExtEditForm('${id}')">Cancel</button>
<button class="btn-small btn-primary" onclick="saveAdminExtension('${id}')">Save Changes</button> <button class="btn-small btn-primary" onclick="saveAdminExtension('${id}')">Save Changes</button>
</div> </div>
</div> </div>
@@ -748,42 +788,80 @@ function editAdminExtension(id) {
if (row.querySelector(`[onclick*="editAdminExtension('${id}')"]`)) { if (row.querySelector(`[onclick*="editAdminExtension('${id}')"]`)) {
row.insertAdjacentHTML('afterend', formHTML); row.insertAdjacentHTML('afterend', formHTML);
// Tab key inserts tab in script textarea if (useCM6) {
const scriptEl = document.getElementById(`extEdit-script-${id}`); // Get user keybinding preference
if (scriptEl) { const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
scriptEl.addEventListener('keydown', (e) => { const keymapMode = prefs.editorKeymap || 'standard';
if (e.key === 'Tab') {
e.preventDefault(); // Initialize CM6 editors
const start = scriptEl.selectionStart; _extEditors[id] = {
const end = scriptEl.selectionEnd; manifest: CM.codeEditor(document.getElementById(`extEdit-manifest-${id}`), {
scriptEl.value = scriptEl.value.substring(0, start) + ' ' + scriptEl.value.substring(end); language: 'json',
scriptEl.selectionStart = scriptEl.selectionEnd = start + 4; value: manifestJSON,
} lineNumbers: true,
}); keymap: keymapMode,
}),
script: CM.codeEditor(document.getElementById(`extEdit-script-${id}`), {
language: 'javascript',
value: script,
lineNumbers: true,
keymap: keymapMode,
}),
};
} else {
// Fallback: Tab key inserts tab in script textarea
const scriptEl = document.getElementById(`extEdit-script-${id}`);
if (scriptEl) {
scriptEl.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
e.preventDefault();
const start = scriptEl.selectionStart;
const end = scriptEl.selectionEnd;
scriptEl.value = scriptEl.value.substring(0, start) + ' ' + scriptEl.value.substring(end);
scriptEl.selectionStart = scriptEl.selectionEnd = start + 4;
}
});
}
} }
break; break;
} }
} }
} }
function _closeExtEditForm(id) {
if (_extEditors[id]) {
_extEditors[id].manifest?.destroy();
_extEditors[id].script?.destroy();
delete _extEditors[id];
}
document.querySelector(`.ext-edit-form[data-ext-id="${id}"]`)?.remove();
}
async function saveAdminExtension(id) { async function saveAdminExtension(id) {
const nameEl = document.getElementById(`extEdit-name-${id}`); const nameEl = document.getElementById(`extEdit-name-${id}`);
const descEl = document.getElementById(`extEdit-desc-${id}`); const descEl = document.getElementById(`extEdit-desc-${id}`);
const manifestEl = document.getElementById(`extEdit-manifest-${id}`);
const scriptEl = document.getElementById(`extEdit-script-${id}`); // Read from CM6 if available, otherwise from textarea
if (!nameEl || !manifestEl || !scriptEl) return; const editors = _extEditors[id];
const manifestText = editors?.manifest
? editors.manifest.getValue()
: document.getElementById(`extEdit-manifest-${id}`)?.value;
const scriptText = editors?.script
? editors.script.getValue()
: document.getElementById(`extEdit-script-${id}`)?.value;
if (!nameEl || manifestText == null || scriptText == null) return;
// Parse manifest and merge _script back in // Parse manifest and merge _script back in
let manifest; let manifest;
try { try {
manifest = JSON.parse(manifestEl.value.trim() || '{}'); manifest = JSON.parse((manifestText || '').trim() || '{}');
} catch (e) { } catch (e) {
return UI.toast('Invalid manifest JSON: ' + e.message, 'error'); return UI.toast('Invalid manifest JSON: ' + e.message, 'error');
} }
const script = scriptEl.value; if (scriptText.trim()) {
if (script.trim()) { manifest._script = scriptText;
manifest._script = script;
} }
try { try {
@@ -793,6 +871,7 @@ async function saveAdminExtension(id) {
manifest: manifest, manifest: manifest,
}); });
UI.toast('Extension updated — reload page to apply changes'); UI.toast('Extension updated — reload page to apply changes');
_closeExtEditForm(id);
await UI.loadAdminExtensions(); await UI.loadAdminExtensions();
} catch (e) { } catch (e) {
UI.toast(e.message, 'error'); UI.toast(e.message, 'error');

View File

@@ -721,7 +721,6 @@ function initAttachments() {
function _initAttachmentListeners() { function _initAttachmentListeners() {
const attachBtn = document.getElementById('attachBtn'); const attachBtn = document.getElementById('attachBtn');
const fileInput = document.getElementById('fileInput'); const fileInput = document.getElementById('fileInput');
const messageInput = document.getElementById('messageInput');
const chatArea = document.getElementById('chatMessages'); const chatArea = document.getElementById('chatMessages');
const lightbox = document.getElementById('lightbox'); const lightbox = document.getElementById('lightbox');
@@ -741,8 +740,12 @@ function _initAttachmentListeners() {
} }
// ── Smart Paste ───────────────────────── // ── Smart Paste ─────────────────────────
if (messageInput) { // Attach to the active input element (CM6 contentDOM or textarea)
messageInput.addEventListener('paste', (e) => { const pasteTarget = (typeof ChatInput !== 'undefined' && ChatInput.getDom())
? ChatInput.getDom()
: document.getElementById('messageInput');
if (pasteTarget) {
pasteTarget.addEventListener('paste', (e) => {
if (!App.storageConfigured) return; // fall through to normal paste if (!App.storageConfigured) return; // fall through to normal paste
const items = [...(e.clipboardData?.items || [])]; const items = [...(e.clipboardData?.items || [])];

View File

@@ -4,6 +4,75 @@
// Chat management, send, stream, regenerate, edit, branch, // Chat management, send, stream, regenerate, edit, branch,
// summarize, per-chat model persistence. // summarize, per-chat model persistence.
// ── Chat Input Abstraction ──────────────────
// Wraps CM6 editor (when available) or fallback textarea.
// All code that reads/writes the message input goes through this.
const ChatInput = {
_editor: null, // CM6 instance, set during init
_textarea: null, // fallback textarea element
getValue() {
if (this._editor) return this._editor.getValue();
return this._textarea?.value || '';
},
setValue(text) {
if (this._editor) {
this._editor.setValue(text || '');
} else if (this._textarea) {
this._textarea.value = text || '';
this._textarea.style.height = 'auto';
}
},
focus() {
if (this._editor) this._editor.focus();
else this._textarea?.focus();
},
/** Get the DOM element (for paste listeners, etc.) */
getDom() {
if (this._editor) return this._editor.getView().contentDOM;
return this._textarea;
},
/** Get the wrapper DOM element (for resize, etc.) */
getWrapDom() {
if (this._editor) return this._editor.getView().dom;
return this._textarea;
},
/** Initialize — try CM6, fall back to textarea */
init() {
this._textarea = document.getElementById('messageInput');
const wrap = document.getElementById('messageInputWrap');
if (window.CM?.chatInput && wrap) {
// Hide the textarea, create CM6 editor in its place
this._textarea.style.display = 'none';
this._editor = CM.chatInput(wrap, {
placeholder: 'Send a message...',
onSubmit: () => sendMessage(),
onChange: () => updateInputTokens(),
maxHeight: 200,
});
DebugLog?.push?.('chat', '[CM6] Chat input initialized');
} else {
// Fallback: textarea with manual event handling
DebugLog?.push?.('chat', '[CM6] Not available — using textarea fallback');
this._textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
this._textarea.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 200) + 'px';
updateInputTokens();
});
}
},
};
// ── Summarize & Continue ────────────────── // ── Summarize & Continue ──────────────────
async function summarizeAndContinue() { async function summarizeAndContinue() {
@@ -266,7 +335,7 @@ async function newChat() {
Tokens._warningDismissed = false; Tokens._warningDismissed = false;
updateContextWarning(); updateChatTokenCount(); updateContextWarning(); updateChatTokenCount();
updateInputTokens(); updateInputTokens();
document.getElementById('messageInput').focus(); ChatInput.focus();
if (window.innerWidth <= 768) { if (window.innerWidth <= 768) {
document.getElementById('sidebar').classList.add('collapsed'); document.getElementById('sidebar').classList.add('collapsed');
const ov = document.getElementById('sidebarOverlay'); const ov = document.getElementById('sidebarOverlay');
@@ -377,15 +446,13 @@ function startRenameChat(chatId) {
// ── Send Message ───────────────────────────── // ── Send Message ─────────────────────────────
async function sendMessage() { async function sendMessage() {
const input = document.getElementById('messageInput'); const text = ChatInput.getValue().trim();
const text = input.value.trim();
const hasAttachments = hasStagedAttachments(); const hasAttachments = hasStagedAttachments();
// Need text or attachments, not generating, not blocked by uploads // Need text or attachments, not generating, not blocked by uploads
if ((!text && !hasAttachments) || App.isGenerating || isSendBlocked()) return; if ((!text && !hasAttachments) || App.isGenerating || isSendBlocked()) return;
input.value = ''; ChatInput.setValue('');
input.style.height = 'auto';
// Snapshot attachment IDs before clearing staged state // Snapshot attachment IDs before clearing staged state
const attachmentIds = getStagedAttachmentIds(); const attachmentIds = getStagedAttachmentIds();
@@ -749,16 +816,8 @@ function _initChatListeners() {
UI.toast(`Loaded ${visible} model${visible !== 1 ? 's' : ''}`, 'success'); UI.toast(`Loaded ${visible} model${visible !== 1 ? 's' : ''}`, 'success');
}); });
// Input // Input — CM6 or textarea fallback
const input = document.getElementById('messageInput'); ChatInput.init();
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
input.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 200) + 'px';
updateInputTokens();
});
// Close modals on overlay click // Close modals on overlay click
document.querySelectorAll('.modal-overlay').forEach(overlay => { document.querySelectorAll('.modal-overlay').forEach(overlay => {

View File

@@ -272,6 +272,11 @@ const DebugLog = {
snap.extensions = Extensions.debug(); snap.extensions = Extensions.debug();
} }
// CM6 editor state
snap.cm6 = window.CM
? { version: window.CM.version, languages: Object.keys(window.CM.languages || {}) }
: { available: false };
return snap; return snap;
}, },

View File

@@ -69,8 +69,7 @@ function updateInputTokens() {
const el = document.getElementById('inputTokenCount'); const el = document.getElementById('inputTokenCount');
if (!el) return; if (!el) return;
const input = document.getElementById('messageInput'); const inputText = (typeof ChatInput !== 'undefined') ? ChatInput.getValue() : '';
const inputText = input?.value || '';
const inputTokens = Tokens.estimate(inputText); const inputTokens = Tokens.estimate(inputText);
if (!inputText.trim()) { if (!inputText.trim()) {

View File

@@ -11,17 +11,30 @@ Object.assign(UI, {
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}'); const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
const scale = prefs.scale || 100; const scale = prefs.scale || 100;
const msgFont = prefs.msgFont || 14; const msgFont = prefs.msgFont || 14;
const theme = prefs.theme || 'dark';
const keymap = prefs.editorKeymap || 'standard';
const scaleEl = document.getElementById('settingsScale'); const scaleEl = document.getElementById('settingsScale');
const msgFontEl = document.getElementById('settingsMsgFont'); const msgFontEl = document.getElementById('settingsMsgFont');
if (scaleEl) { scaleEl.value = scale; document.getElementById('scaleValue').textContent = scale + '%'; } if (scaleEl) { scaleEl.value = scale; document.getElementById('scaleValue').textContent = scale + '%'; }
if (msgFontEl) { msgFontEl.value = msgFont; document.getElementById('msgFontValue').textContent = msgFont + 'px'; } if (msgFontEl) { msgFontEl.value = msgFont; document.getElementById('msgFontValue').textContent = msgFont + 'px'; }
// Highlight active theme button
document.querySelectorAll('#themeToggle .theme-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.theme === theme);
});
// Highlight active keymap button
document.querySelectorAll('#keymapToggle .theme-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.keymap === keymap);
});
}, },
initAppearance() { initAppearance() {
// Load saved prefs on startup // Load saved prefs on startup
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}'); const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
UI.applyAppearance(prefs.scale || 100, prefs.msgFont || 14); UI.applyAppearance(prefs.scale || 100, prefs.msgFont || 14);
UI.applyTheme(prefs.theme || 'dark');
// Live preview on slider change // Live preview on slider change
const scaleEl = document.getElementById('settingsScale'); const scaleEl = document.getElementById('settingsScale');
@@ -36,6 +49,70 @@ Object.assign(UI, {
document.getElementById('msgFontValue').textContent = v + 'px'; document.getElementById('msgFontValue').textContent = v + 'px';
UI.applyAppearance(parseInt(scaleEl?.value || 100), v); UI.applyAppearance(parseInt(scaleEl?.value || 100), v);
}); });
// Theme toggle buttons
document.querySelectorAll('#themeToggle .theme-btn').forEach(btn => {
btn.addEventListener('click', () => {
const theme = btn.dataset.theme;
UI.applyTheme(theme);
document.querySelectorAll('#themeToggle .theme-btn').forEach(b =>
b.classList.toggle('active', b === btn)
);
const p = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
p.theme = theme;
localStorage.setItem('cs-appearance', JSON.stringify(p));
});
});
// Editor keymap toggle buttons
document.querySelectorAll('#keymapToggle .theme-btn').forEach(btn => {
btn.addEventListener('click', () => {
const mode = btn.dataset.keymap;
document.querySelectorAll('#keymapToggle .theme-btn').forEach(b =>
b.classList.toggle('active', b === btn)
);
// Persist
const p = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
p.editorKeymap = mode;
localStorage.setItem('cs-appearance', JSON.stringify(p));
// Notify open editors
if (typeof Events !== 'undefined' && Events.emit) {
Events.emit('keymap.changed', { mode });
}
});
});
// System theme change listener (for "system" mode)
UI._systemThemeQuery = window.matchMedia('(prefers-color-scheme: dark)');
UI._systemThemeQuery.addEventListener('change', () => {
const p = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
if (p.theme === 'system') UI.applyTheme('system');
});
},
/**
* Apply theme to the document.
* @param {'light'|'dark'|'system'} mode
*/
applyTheme(mode) {
let resolved = mode;
if (mode === 'system') {
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
if (resolved === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
} else {
document.documentElement.removeAttribute('data-theme');
}
// Publish event for CM6 editors and extensions
if (typeof Events !== 'undefined' && Events.emit) {
Events.emit('theme.changed', { mode, resolved });
}
},
/** Get the currently resolved theme ('dark' or 'light') */
getResolvedTheme() {
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
}, },
applyAppearance(scale, msgFont) { applyAppearance(scale, msgFont) {

View File

@@ -68,11 +68,12 @@ self.addEventListener('activate', (event) => {
self.addEventListener('fetch', (event) => { self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url); const url = new URL(event.request.url);
// Never cache API calls, WebSocket upgrades, branding, or extension assets // Never cache API calls, WebSocket upgrades, branding, extensions, or CM6 bundle
if (url.pathname.includes('/api/') || if (url.pathname.includes('/api/') ||
url.pathname.includes('/ws') || url.pathname.includes('/ws') ||
url.pathname.includes('/branding/') || url.pathname.includes('/branding/') ||
url.pathname.includes('/extensions/') || url.pathname.includes('/extensions/') ||
url.pathname.includes('/vendor/codemirror/') ||
event.request.method !== 'GET') { event.request.method !== 'GET') {
return; return;
} }