@@ -822,6 +822,7 @@ Mirrored / scaffold areas needing real implementation:
|
||||
- [ ] Token budget calculations
|
||||
|
||||
### Tier 3 — Nice-to-Have Features
|
||||
- [x] Local web GUI (FastAPI + vanilla JS SPA) → `src/gui/__main__.py`, `src/gui/server.py`, `src/gui/static/*`; launch with `python -m src.gui` or `claw-code-gui`
|
||||
- [ ] TUI/Ink component library
|
||||
- [ ] Voice mode
|
||||
- [ ] VIM mode and keybinding system
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
| 🆕 | **Tokenizer-Aware Context** | Cached tokenizer backends with heuristic fallback for `/context`, `/status`, and compaction |
|
||||
| 🆕 | **Prompt Budget Preflight** | Preflight prompt-length validation, token-budget reporting, and auto-compact/context collapse before backend failures |
|
||||
| 🆕 | **LSP Runtime** | Local LSP-style code intelligence for definitions, references, hover, symbols, call hierarchy, and diagnostics |
|
||||
| 🆕 | **Local Web GUI** | Browser-based chat UI via `python -m src.gui` — modern dark theme, slash command palette, session browser, settings panel |
|
||||
| 🆕 | **Daemon Commands** | Local `daemon start/ps/logs/attach/kill` wrapper over background agent sessions |
|
||||
| 🆕 | **Background Sessions** | Local `agent-bg`, `agent-ps`, `agent-logs`, `agent-attach`, and `agent-kill` flows |
|
||||
| 🆕 | **Testing Guide** | Comprehensive [TESTING_GUIDE.md](TESTING_GUIDE.md) with commands for every feature |
|
||||
@@ -85,6 +86,7 @@ Built on the public porting workspace from [instructkr/claw-code](https://github
|
||||
|---------|-------------|
|
||||
| 🤖 **Agent Loop** | Full agentic coding loop with tool calling and iterative reasoning |
|
||||
| 💬 **Interactive Chat** | Multi-turn REPL via `agent-chat` with session continuity |
|
||||
| 🖥️ **Local Web GUI** | Browser-based chat UI launched with `python -m src.gui` — sessions browser, slash command palette, live settings |
|
||||
| 🧰 **Core Tools** | File read / write / edit, glob search, grep search, shell execution |
|
||||
| 🔌 **Plugin Runtime** | Manifest-based plugins with hooks, aliases, virtual tools, and tool blocking |
|
||||
| 🪆 **Nested Delegation** | Delegate subtasks to child agents with dependency-aware topological batching |
|
||||
@@ -169,6 +171,7 @@ Built on the public porting workspace from [instructkr/claw-code](https://github
|
||||
- [x] Local workflow runtime with workflow list/get/run tools and CLI/slash flows
|
||||
- [x] Local remote trigger runtime with create/update/run flows and CLI/slash inspection
|
||||
- [x] Local managed git worktree runtime with live cwd switching and worktree CLI/slash flows
|
||||
- [x] Local web GUI (FastAPI + vanilla JS SPA) with chat, sessions browser, slash command palette, and live settings (`python -m src.gui`)
|
||||
- [x] Tokenizer-aware context accounting with cached tokenizer backends and heuristic fallback
|
||||
- [x] Plugin runtime: manifest discovery, hooks, aliases, virtual tools, tool blocking
|
||||
- [x] Plugin lifecycle hooks: resume, persist, delegate phases
|
||||
@@ -245,7 +248,11 @@ claw-code/
|
||||
│ ├── commands.py # Mirrored command inventory
|
||||
│ ├── tools.py # Mirrored tool inventory
|
||||
│ ├── runtime.py # Mirrored runtime facade
|
||||
│ └── reference_data/ # Mirrored inventory snapshots
|
||||
│ ├── reference_data/ # Mirrored inventory snapshots
|
||||
│ └── gui/ # Local web GUI (FastAPI + vanilla JS SPA)
|
||||
│ ├── __main__.py # `python -m src.gui` entry point
|
||||
│ ├── server.py # FastAPI app and JSON endpoints
|
||||
│ └── static/ # index.html, app.css, app.js
|
||||
└── tests/ # Unit tests
|
||||
├── test_agent_runtime.py
|
||||
├── test_agent_context.py
|
||||
@@ -712,6 +719,41 @@ Features:
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Local Web GUI
|
||||
|
||||
If the terminal isn't your thing, launch the bundled browser GUI:
|
||||
|
||||
```bash
|
||||
python3 -m src.gui --cwd . --allow-write --allow-shell
|
||||
```
|
||||
|
||||
Your default browser opens to `http://127.0.0.1:8765` with a modern dark-themed chat UI.
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--host <addr>` | Bind address (default `127.0.0.1`) |
|
||||
| `--port <n>` | Port to listen on (default `8765`) |
|
||||
| `--cwd <path>` | Workspace directory the agent operates in |
|
||||
| `--model <name>` | Override the model name |
|
||||
| `--base-url <url>` | Override the OpenAI-compatible API base URL |
|
||||
| `--api-key <key>` | API key for the model server |
|
||||
| `--session-dir <path>` | Where saved sessions live |
|
||||
| `--allow-write` | Allow file write/edit tools |
|
||||
| `--allow-shell` | Allow shell execution |
|
||||
| `--no-browser` | Don't auto-open a browser tab |
|
||||
|
||||
The GUI surfaces:
|
||||
|
||||
- multi-turn chat with tool-call cards (collapsible JSON args + results)
|
||||
- saved sessions sidebar with one-click resume
|
||||
- slash command and skill pickers (`/` and `★` buttons, or `Cmd/Ctrl+K`)
|
||||
- live settings panel (model, base URL, working dir, permissions)
|
||||
- usage / cost meta in the composer footer
|
||||
|
||||
> **Note:** The GUI uses [FastAPI](https://fastapi.tiangolo.com/) and [Uvicorn](https://www.uvicorn.org/) under the hood. These get installed automatically if you install the package via `pip install -e .`. The core Python agent runtime itself remains dependency-free.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Session Persistence
|
||||
|
||||
Each `agent` run automatically saves a resumable session:
|
||||
|
||||
+7
-1
@@ -32,7 +32,11 @@ classifiers = [
|
||||
"Topic :: Software Development",
|
||||
"Topic :: Software Development :: Build Tools",
|
||||
]
|
||||
dependencies = []
|
||||
dependencies = [
|
||||
"fastapi>=0.110",
|
||||
"uvicorn>=0.27",
|
||||
"pydantic>=2.5",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/HarnessLab/claw-code-agent"
|
||||
@@ -40,6 +44,7 @@ Repository = "https://github.com/HarnessLab/claw-code-agent"
|
||||
|
||||
[project.scripts]
|
||||
claw-code-agent = "src.main:main"
|
||||
claw-code-gui = "src.gui.__main__:main"
|
||||
|
||||
[tool.setuptools]
|
||||
include-package-data = true
|
||||
@@ -51,4 +56,5 @@ include = ["src*"]
|
||||
[tool.setuptools.package-data]
|
||||
src = [
|
||||
"reference_data/*.json",
|
||||
"gui/static/*",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Local web GUI for the claw-code agent.
|
||||
|
||||
Run with: ``python -m src.gui`` or ``python -m src.gui --port 8765``.
|
||||
"""
|
||||
@@ -0,0 +1,85 @@
|
||||
"""GUI entry point: ``python -m src.gui [--port N] [--cwd PATH] ...``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn
|
||||
|
||||
from ..session_store import DEFAULT_AGENT_SESSION_DIR
|
||||
from .server import AgentState, create_app
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog='claw-code-gui',
|
||||
description='Launch the local web GUI for the claw-code agent.',
|
||||
)
|
||||
parser.add_argument('--host', default='127.0.0.1')
|
||||
parser.add_argument('--port', type=int, default=8765)
|
||||
parser.add_argument(
|
||||
'--cwd',
|
||||
default='.',
|
||||
help='Working directory that the agent operates in (default: current dir).',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--model',
|
||||
default=os.environ.get('OPENAI_MODEL', 'Qwen/Qwen3-Coder-30B-A3B-Instruct'),
|
||||
)
|
||||
parser.add_argument(
|
||||
'--base-url',
|
||||
default=os.environ.get('OPENAI_BASE_URL', 'http://127.0.0.1:8000/v1'),
|
||||
)
|
||||
parser.add_argument(
|
||||
'--api-key',
|
||||
default=os.environ.get('OPENAI_API_KEY', 'local-token'),
|
||||
)
|
||||
parser.add_argument(
|
||||
'--session-dir',
|
||||
default=str(DEFAULT_AGENT_SESSION_DIR),
|
||||
help='Directory where agent sessions are saved (default: .port_sessions/agent).',
|
||||
)
|
||||
parser.add_argument('--allow-shell', action='store_true')
|
||||
parser.add_argument('--allow-write', action='store_true')
|
||||
parser.add_argument(
|
||||
'--no-browser',
|
||||
action='store_true',
|
||||
help='Do not auto-open a browser tab on launch.',
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
state = AgentState(
|
||||
cwd=Path(args.cwd),
|
||||
model=args.model,
|
||||
base_url=args.base_url,
|
||||
api_key=args.api_key,
|
||||
allow_shell=args.allow_shell,
|
||||
allow_write=args.allow_write,
|
||||
session_directory=Path(args.session_dir),
|
||||
)
|
||||
app = create_app(state)
|
||||
|
||||
url = f'http://{args.host}:{args.port}'
|
||||
print(f'Claw Code GUI listening on {url}')
|
||||
print(f' cwd : {state.cwd}')
|
||||
print(f' model : {state.model}')
|
||||
print(f' base-url : {state.base_url}')
|
||||
print(f' sessions : {state.session_directory}')
|
||||
print(f' shell : {"on" if state.allow_shell else "off"}')
|
||||
print(f' write : {"on" if state.allow_write else "off"}')
|
||||
|
||||
if not args.no_browser:
|
||||
try:
|
||||
webbrowser.open(url)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
uvicorn.run(app, host=args.host, port=args.port, log_level='info')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,340 @@
|
||||
"""FastAPI server backing the local web GUI.
|
||||
|
||||
Wraps a single global :class:`LocalCodingAgent`, exposes JSON endpoints for
|
||||
chat, slash commands, and saved sessions, and serves the static SPA.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..agent_runtime import LocalCodingAgent
|
||||
from ..agent_slash_commands import get_slash_command_specs
|
||||
from ..agent_types import (
|
||||
AgentPermissions,
|
||||
AgentRuntimeConfig,
|
||||
ModelConfig,
|
||||
)
|
||||
from ..bundled_skills import get_bundled_skills
|
||||
from ..session_store import (
|
||||
DEFAULT_AGENT_SESSION_DIR,
|
||||
StoredAgentSession,
|
||||
load_agent_session,
|
||||
)
|
||||
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / 'static'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent state holder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class AgentState:
|
||||
"""Holds the live agent instance plus a lock for serialized access."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
cwd: Path,
|
||||
model: str,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
allow_shell: bool,
|
||||
allow_write: bool,
|
||||
session_directory: Path,
|
||||
) -> None:
|
||||
self.cwd = cwd.resolve()
|
||||
self.session_directory = session_directory
|
||||
self._lock = Lock()
|
||||
self._agent: LocalCodingAgent | None = None
|
||||
self.model = model
|
||||
self.base_url = base_url
|
||||
self.api_key = api_key
|
||||
self.allow_shell = allow_shell
|
||||
self.allow_write = allow_write
|
||||
self._build_agent()
|
||||
|
||||
def _build_agent(self) -> None:
|
||||
permissions = AgentPermissions(
|
||||
allow_file_write=self.allow_write,
|
||||
allow_shell_commands=self.allow_shell,
|
||||
)
|
||||
runtime_config = AgentRuntimeConfig(
|
||||
cwd=self.cwd,
|
||||
permissions=permissions,
|
||||
session_directory=self.session_directory,
|
||||
)
|
||||
model_config = ModelConfig(
|
||||
model=self.model,
|
||||
base_url=self.base_url,
|
||||
api_key=self.api_key,
|
||||
)
|
||||
self._agent = LocalCodingAgent(
|
||||
model_config=model_config,
|
||||
runtime_config=runtime_config,
|
||||
)
|
||||
|
||||
@property
|
||||
def agent(self) -> LocalCodingAgent:
|
||||
assert self._agent is not None
|
||||
return self._agent
|
||||
|
||||
def update(
|
||||
self,
|
||||
*,
|
||||
model: str | None = None,
|
||||
base_url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
cwd: str | None = None,
|
||||
allow_shell: bool | None = None,
|
||||
allow_write: bool | None = None,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
if model is not None:
|
||||
self.model = model
|
||||
if base_url is not None:
|
||||
self.base_url = base_url
|
||||
if api_key is not None:
|
||||
self.api_key = api_key
|
||||
if cwd is not None:
|
||||
resolved = Path(cwd).expanduser().resolve()
|
||||
if not resolved.is_dir():
|
||||
raise ValueError(f'cwd does not exist: {resolved}')
|
||||
self.cwd = resolved
|
||||
if allow_shell is not None:
|
||||
self.allow_shell = allow_shell
|
||||
if allow_write is not None:
|
||||
self.allow_write = allow_write
|
||||
self._build_agent()
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
return {
|
||||
'model': self.model,
|
||||
'base_url': self.base_url,
|
||||
'cwd': str(self.cwd),
|
||||
'session_directory': str(self.session_directory),
|
||||
'allow_shell': self.allow_shell,
|
||||
'allow_write': self.allow_write,
|
||||
'active_session_id': self.agent.active_session_id,
|
||||
}
|
||||
|
||||
def lock(self) -> Lock:
|
||||
return self._lock
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
prompt: str = Field(min_length=1)
|
||||
resume_session_id: str | None = None
|
||||
|
||||
|
||||
class StateUpdate(BaseModel):
|
||||
model: str | None = None
|
||||
base_url: str | None = None
|
||||
api_key: str | None = None
|
||||
cwd: str | None = None
|
||||
allow_shell: bool | None = None
|
||||
allow_write: bool | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def create_app(state: AgentState) -> FastAPI:
|
||||
app = FastAPI(title='Claw Code GUI', version='1.0')
|
||||
|
||||
# ------------- static + index ------------------------------------------
|
||||
app.mount(
|
||||
'/static',
|
||||
StaticFiles(directory=str(STATIC_DIR)),
|
||||
name='static',
|
||||
)
|
||||
|
||||
@app.get('/', include_in_schema=False)
|
||||
async def root() -> FileResponse:
|
||||
return FileResponse(STATIC_DIR / 'index.html')
|
||||
|
||||
# ------------- info ------------------------------------------------------
|
||||
@app.get('/api/state')
|
||||
async def get_state() -> dict[str, Any]:
|
||||
return state.snapshot()
|
||||
|
||||
@app.post('/api/state')
|
||||
async def post_state(payload: StateUpdate) -> dict[str, Any]:
|
||||
try:
|
||||
state.update(**payload.model_dump(exclude_none=True))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return state.snapshot()
|
||||
|
||||
@app.get('/api/slash-commands')
|
||||
async def list_slash_commands() -> list[dict[str, Any]]:
|
||||
commands: list[dict[str, Any]] = []
|
||||
for spec in get_slash_command_specs():
|
||||
commands.append(
|
||||
{
|
||||
'names': list(spec.names),
|
||||
'primary': spec.names[0],
|
||||
'description': spec.description,
|
||||
}
|
||||
)
|
||||
return commands
|
||||
|
||||
@app.get('/api/skills')
|
||||
async def list_skills() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
'name': skill.name,
|
||||
'description': skill.description,
|
||||
'when_to_use': skill.when_to_use,
|
||||
'aliases': list(skill.aliases),
|
||||
'allowed_tools': list(skill.allowed_tools),
|
||||
}
|
||||
for skill in get_bundled_skills()
|
||||
if skill.user_invocable
|
||||
]
|
||||
|
||||
# ------------- sessions --------------------------------------------------
|
||||
@app.get('/api/sessions')
|
||||
async def list_sessions() -> list[dict[str, Any]]:
|
||||
directory = state.session_directory
|
||||
if not directory.exists():
|
||||
return []
|
||||
results: list[dict[str, Any]] = []
|
||||
for path in sorted(
|
||||
directory.glob('*.json'),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding='utf-8'))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
messages = data.get('messages') or []
|
||||
preview = ''
|
||||
for msg in messages:
|
||||
if isinstance(msg, dict) and msg.get('role') == 'user':
|
||||
content = msg.get('content', '')
|
||||
if isinstance(content, str):
|
||||
preview = content[:120]
|
||||
break
|
||||
results.append(
|
||||
{
|
||||
'session_id': data.get('session_id', path.stem),
|
||||
'turns': data.get('turns', 0),
|
||||
'tool_calls': data.get('tool_calls', 0),
|
||||
'preview': preview,
|
||||
'modified_at': path.stat().st_mtime,
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
@app.get('/api/sessions/{session_id}')
|
||||
async def get_session(session_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
stored = load_agent_session(session_id, directory=state.session_directory)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail='Session not found')
|
||||
return _serialize_stored_session(stored)
|
||||
|
||||
# ------------- chat ------------------------------------------------------
|
||||
@app.post('/api/chat')
|
||||
async def chat(request: ChatRequest) -> dict[str, Any]:
|
||||
prompt = request.prompt.strip()
|
||||
if not prompt:
|
||||
raise HTTPException(status_code=400, detail='Prompt is empty')
|
||||
|
||||
def _run() -> dict[str, Any]:
|
||||
with state.lock():
|
||||
agent = state.agent
|
||||
if request.resume_session_id is not None:
|
||||
try:
|
||||
stored = load_agent_session(
|
||||
request.resume_session_id,
|
||||
directory=state.session_directory,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail='Session to resume not found',
|
||||
)
|
||||
result = agent.resume(prompt, stored)
|
||||
else:
|
||||
result = agent.run(prompt)
|
||||
return _serialize_run_result(result)
|
||||
|
||||
try:
|
||||
payload = await asyncio.to_thread(_run)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc: # surface the error in the UI
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
'error': str(exc),
|
||||
'error_type': type(exc).__name__,
|
||||
},
|
||||
)
|
||||
return payload
|
||||
|
||||
@app.post('/api/clear')
|
||||
async def clear_state() -> dict[str, Any]:
|
||||
with state.lock():
|
||||
state.agent.clear_runtime_state()
|
||||
return state.snapshot()
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Serialization helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _serialize_run_result(result: Any) -> dict[str, Any]:
|
||||
return {
|
||||
'final_output': result.final_output,
|
||||
'turns': result.turns,
|
||||
'tool_calls': result.tool_calls,
|
||||
'transcript': [_normalize_transcript_entry(entry) for entry in result.transcript],
|
||||
'session_id': result.session_id,
|
||||
'usage': result.usage.to_dict(),
|
||||
'total_cost_usd': result.total_cost_usd,
|
||||
'stop_reason': result.stop_reason,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_transcript_entry(entry: dict[str, Any]) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {
|
||||
'role': entry.get('role', ''),
|
||||
'content': entry.get('content', ''),
|
||||
}
|
||||
for key in ('name', 'tool_call_id', 'tool_calls', 'metadata', 'message_id'):
|
||||
if key in entry and entry[key] not in (None, '', [], {}):
|
||||
out[key] = entry[key]
|
||||
return out
|
||||
|
||||
|
||||
def _serialize_stored_session(stored: StoredAgentSession) -> dict[str, Any]:
|
||||
return {
|
||||
'session_id': stored.session_id,
|
||||
'turns': stored.turns,
|
||||
'tool_calls': stored.tool_calls,
|
||||
'messages': [_normalize_transcript_entry(dict(m)) for m in stored.messages],
|
||||
'usage': stored.usage,
|
||||
'total_cost_usd': stored.total_cost_usd,
|
||||
'model': stored.model_config.get('model'),
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
:root {
|
||||
--bg: #0e0f13;
|
||||
--bg-elev: #15171d;
|
||||
--bg-soft: #1c1f27;
|
||||
--border: #2a2e3a;
|
||||
--text: #e6e8ee;
|
||||
--text-dim: #9aa0ad;
|
||||
--text-muted: #6c7280;
|
||||
--accent: #ff9333;
|
||||
--accent-soft: #ff933322;
|
||||
--user: #5e7cff;
|
||||
--user-soft: #5e7cff22;
|
||||
--tool: #58c79a;
|
||||
--tool-soft: #58c79a18;
|
||||
--error: #ff6b7a;
|
||||
--error-soft: #ff6b7a22;
|
||||
--code-bg: #0a0b0f;
|
||||
--radius: 10px;
|
||||
--radius-sm: 6px;
|
||||
--shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
|
||||
--font-ui:
|
||||
"Inter", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
|
||||
Arial, sans-serif;
|
||||
--font-mono:
|
||||
"JetBrains Mono", "Fira Code", "SF Mono", Menlo, Monaco, Consolas,
|
||||
"Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---------------- Sidebar ---------------- */
|
||||
.sidebar {
|
||||
background: var(--bg-elev);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-head {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(135deg, var(--accent), #c2580f);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #1a0f00;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-section:last-child {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sidebar > .sidebar-section:nth-child(2) {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.session-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.session-item {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-dim);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
.session-item:hover {
|
||||
background: var(--bg-soft);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.session-item.active {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.session-item .session-preview {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.session-item .session-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.settings label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.settings input[type="text"] {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 7px 9px;
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.settings input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.toggle {
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 8px !important;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.toggle input {
|
||||
margin: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ---------------- Buttons ---------------- */
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-ghost,
|
||||
.btn-send {
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #1a0f00;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #ffa64d;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-soft);
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
border-color: transparent;
|
||||
padding: 6px 10px;
|
||||
font-size: 14px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: var(--bg-soft);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-send {
|
||||
background: var(--accent);
|
||||
color: #1a0f00;
|
||||
font-weight: 600;
|
||||
align-self: flex-end;
|
||||
padding: 9px 18px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.btn-send:hover:not(:disabled) {
|
||||
background: #ffa64d;
|
||||
}
|
||||
|
||||
.btn-send:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ---------------- Main pane ---------------- */
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 48px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.topbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--tool);
|
||||
box-shadow: 0 0 0 3px rgba(88, 199, 154, 0.15);
|
||||
}
|
||||
|
||||
.status-dot.busy {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-dot.error {
|
||||
background: var(--error);
|
||||
box-shadow: 0 0 0 3px var(--error-soft);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.25);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- Chat ---------------- */
|
||||
.chat {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 24px 0;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.welcome {
|
||||
max-width: 780px;
|
||||
margin: 60px auto;
|
||||
padding: 0 24px;
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.welcome h1 {
|
||||
font-size: 32px;
|
||||
margin-bottom: 12px;
|
||||
background: linear-gradient(135deg, var(--accent), #ffd089);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.welcome code {
|
||||
background: var(--bg-soft);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 820px;
|
||||
margin: 0 auto 18px;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.message .avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.message.user .avatar {
|
||||
background: var(--user-soft);
|
||||
color: var(--user);
|
||||
border: 1px solid var(--user);
|
||||
}
|
||||
|
||||
.message.assistant .avatar {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
.message.tool .avatar {
|
||||
background: var(--tool-soft);
|
||||
color: var(--tool);
|
||||
border: 1px solid var(--tool);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.message.error .avatar {
|
||||
background: var(--error-soft);
|
||||
color: var(--error);
|
||||
border: 1px solid var(--error);
|
||||
}
|
||||
|
||||
.message .body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 16px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.message.user .body {
|
||||
background: var(--bg-soft);
|
||||
border-color: var(--user);
|
||||
}
|
||||
|
||||
.message.error .body {
|
||||
border-color: var(--error);
|
||||
background: var(--error-soft);
|
||||
}
|
||||
|
||||
.message .body :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.message .body :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.message .body p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.message .body h1,
|
||||
.message .body h2,
|
||||
.message .body h3 {
|
||||
margin: 14px 0 8px;
|
||||
}
|
||||
|
||||
.message .body ul,
|
||||
.message .body ol {
|
||||
padding-left: 22px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.message .body code {
|
||||
font-family: var(--font-mono);
|
||||
background: var(--code-bg);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-size: 12.5px;
|
||||
color: #ffcd91;
|
||||
}
|
||||
|
||||
.message .body pre {
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px;
|
||||
overflow-x: auto;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.message .body pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: var(--text);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.message .body a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Tool call card */
|
||||
.tool-call {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--tool);
|
||||
border-radius: var(--radius-sm);
|
||||
margin: 6px 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tool-call summary {
|
||||
cursor: pointer;
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.tool-call summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tool-call summary::before {
|
||||
content: "▶";
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.tool-call[open] summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.tool-call .tool-name {
|
||||
color: var(--tool);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tool-call .tool-args {
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tool-call .tool-body {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tool-call .tool-body pre {
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
margin: 0;
|
||||
font-size: 11.5px;
|
||||
overflow-x: auto;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tool-call .label {
|
||||
color: var(--text-muted);
|
||||
font-size: 10.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Composer */
|
||||
.composer {
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
padding: 12px 16px 14px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.composer-inner {
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-end;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 8px 8px 14px;
|
||||
transition: border-color 0.12s ease;
|
||||
}
|
||||
|
||||
.composer-inner:focus-within {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
#prompt-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
resize: none;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
padding: 6px 0;
|
||||
outline: none;
|
||||
min-height: 22px;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.composer-meta {
|
||||
max-width: 820px;
|
||||
margin: 6px auto 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Palette */
|
||||
.palette {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: grid;
|
||||
place-items: start center;
|
||||
padding-top: 12vh;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.palette.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.palette-card {
|
||||
width: min(560px, 92vw);
|
||||
max-height: 70vh;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.palette-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.palette-head input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.palette-list {
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.palette-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 9px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.palette-item:hover,
|
||||
.palette-item.active {
|
||||
background: var(--bg-soft);
|
||||
}
|
||||
|
||||
.palette-item .palette-name {
|
||||
color: var(--accent);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.palette-item .palette-desc {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Scrollbars */
|
||||
::-webkit-scrollbar {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #3b414f;
|
||||
}
|
||||
|
||||
/* Mobile-ish narrow screens */
|
||||
@media (max-width: 720px) {
|
||||
#app {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
"use strict";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
const State = {
|
||||
serverState: null,
|
||||
sessions: [],
|
||||
slashCommands: [],
|
||||
skills: [],
|
||||
activeSessionId: null, // the session we will resume on next send
|
||||
isBusy: false,
|
||||
paletteMode: null, // "slash" | "skills" | null
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DOM refs
|
||||
// ---------------------------------------------------------------------------
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const els = {
|
||||
chat: $("#chat"),
|
||||
welcome: $("#welcome"),
|
||||
input: $("#prompt-input"),
|
||||
sendBtn: $("#send-btn"),
|
||||
sessionList: $("#session-list"),
|
||||
newSessionBtn: $("#new-session-btn"),
|
||||
settingsForm: $("#settings-form"),
|
||||
statusDot: $("#status-dot"),
|
||||
statusText: $("#status-text"),
|
||||
cwdMeta: $("#cwd-meta"),
|
||||
usageMeta: $("#usage-meta"),
|
||||
slashBtn: $("#slash-btn"),
|
||||
skillsBtn: $("#skills-btn"),
|
||||
clearBtn: $("#clear-btn"),
|
||||
palette: $("#palette"),
|
||||
paletteSearch: $("#palette-search"),
|
||||
paletteList: $("#palette-list"),
|
||||
paletteClose: $("#palette-close"),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
async function apiGet(path) {
|
||||
const r = await fetch(path);
|
||||
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function apiPost(path, body) {
|
||||
const r = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body == null ? "{}" : JSON.stringify(body),
|
||||
});
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok && !data.error) {
|
||||
throw new Error(data.detail || `${r.status} ${r.statusText}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Markdown rendering (small, dependency-free)
|
||||
// ---------------------------------------------------------------------------
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function renderMarkdown(md) {
|
||||
if (md == null) return "";
|
||||
// Pull fenced code blocks out first so their contents are not parsed.
|
||||
const codeBlocks = [];
|
||||
let text = String(md).replace(/```([a-zA-Z0-9_-]*)\n([\s\S]*?)```/g, (_, lang, body) => {
|
||||
const idx = codeBlocks.length;
|
||||
codeBlocks.push({ lang, body });
|
||||
return `\u0000CODE${idx}\u0000`;
|
||||
});
|
||||
text = escapeHtml(text);
|
||||
// Headings
|
||||
text = text.replace(/^### (.+)$/gm, "<h3>$1</h3>");
|
||||
text = text.replace(/^## (.+)$/gm, "<h2>$1</h2>");
|
||||
text = text.replace(/^# (.+)$/gm, "<h1>$1</h1>");
|
||||
// Bold / italic
|
||||
text = text.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
|
||||
text = text.replace(/(^|\W)\*([^*\n]+)\*(\W|$)/g, "$1<em>$2</em>$3");
|
||||
// Inline code
|
||||
text = text.replace(/`([^`\n]+)`/g, "<code>$1</code>");
|
||||
// Links
|
||||
text = text.replace(
|
||||
/\[([^\]]+)\]\(([^)\s]+)\)/g,
|
||||
'<a href="$2" target="_blank" rel="noopener">$1</a>'
|
||||
);
|
||||
// Lists (simple, line-based)
|
||||
text = text.replace(/(?:^|\n)((?:- .+\n?)+)/g, (_, block) => {
|
||||
const items = block
|
||||
.trim()
|
||||
.split(/\n/)
|
||||
.map((l) => `<li>${l.replace(/^- /, "")}</li>`)
|
||||
.join("");
|
||||
return `\n<ul>${items}</ul>\n`;
|
||||
});
|
||||
text = text.replace(/(?:^|\n)((?:\d+\. .+\n?)+)/g, (_, block) => {
|
||||
const items = block
|
||||
.trim()
|
||||
.split(/\n/)
|
||||
.map((l) => `<li>${l.replace(/^\d+\. /, "")}</li>`)
|
||||
.join("");
|
||||
return `\n<ol>${items}</ol>\n`;
|
||||
});
|
||||
// Paragraphs (split on blank lines)
|
||||
text = text
|
||||
.split(/\n{2,}/)
|
||||
.map((chunk) => {
|
||||
const t = chunk.trim();
|
||||
if (!t) return "";
|
||||
if (/^<(h\d|ul|ol|pre|blockquote)/.test(t)) return t;
|
||||
return `<p>${t.replace(/\n/g, "<br/>")}</p>`;
|
||||
})
|
||||
.join("\n");
|
||||
// Restore code blocks
|
||||
text = text.replace(/\u0000CODE(\d+)\u0000/g, (_, idx) => {
|
||||
const { lang, body } = codeBlocks[Number(idx)];
|
||||
const langClass = lang ? ` class="lang-${escapeHtml(lang)}"` : "";
|
||||
return `<pre><code${langClass}>${escapeHtml(body)}</code></pre>`;
|
||||
});
|
||||
return text;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function setStatus(state, text) {
|
||||
els.statusDot.classList.remove("busy", "error");
|
||||
if (state === "busy") els.statusDot.classList.add("busy");
|
||||
if (state === "error") els.statusDot.classList.add("error");
|
||||
els.statusText.textContent = text;
|
||||
}
|
||||
|
||||
function setBusy(busy) {
|
||||
State.isBusy = busy;
|
||||
els.sendBtn.disabled = busy;
|
||||
els.input.disabled = busy;
|
||||
if (busy) setStatus("busy", "Working…");
|
||||
else setStatus("ready", "Ready");
|
||||
}
|
||||
|
||||
function clearChat() {
|
||||
els.chat.innerHTML = "";
|
||||
if (els.welcome) {
|
||||
els.chat.appendChild(els.welcome);
|
||||
}
|
||||
}
|
||||
|
||||
function hideWelcome() {
|
||||
if (els.welcome && els.welcome.parentElement) {
|
||||
els.welcome.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function avatarFor(role) {
|
||||
if (role === "user") return "U";
|
||||
if (role === "assistant") return "AI";
|
||||
if (role === "tool") return "⚙";
|
||||
if (role === "error") return "!";
|
||||
return "•";
|
||||
}
|
||||
|
||||
function appendMessage({ role, content, html }) {
|
||||
hideWelcome();
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = `message ${role}`;
|
||||
|
||||
const avatar = document.createElement("div");
|
||||
avatar.className = "avatar";
|
||||
avatar.textContent = avatarFor(role);
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "body";
|
||||
if (html != null) {
|
||||
body.innerHTML = html;
|
||||
} else if (role === "user") {
|
||||
body.textContent = content;
|
||||
} else {
|
||||
body.innerHTML = renderMarkdown(content);
|
||||
}
|
||||
wrap.appendChild(avatar);
|
||||
wrap.appendChild(body);
|
||||
els.chat.appendChild(wrap);
|
||||
els.chat.scrollTop = els.chat.scrollHeight;
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function appendToolCall({ name, args, result, isError }) {
|
||||
hideWelcome();
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "message tool";
|
||||
wrap.innerHTML = `<div class="avatar">⚙</div>`;
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "body";
|
||||
|
||||
const details = document.createElement("details");
|
||||
details.className = "tool-call";
|
||||
|
||||
const summary = document.createElement("summary");
|
||||
const argsPreview = typeof args === "string" ? args : JSON.stringify(args);
|
||||
summary.innerHTML = `
|
||||
<span class="tool-name">${escapeHtml(name)}</span>
|
||||
<span class="tool-args">${escapeHtml(argsPreview).slice(0, 240)}</span>
|
||||
`;
|
||||
details.appendChild(summary);
|
||||
|
||||
const inner = document.createElement("div");
|
||||
inner.className = "tool-body";
|
||||
inner.innerHTML = `
|
||||
<div class="label">Arguments</div>
|
||||
<pre>${escapeHtml(typeof args === "string" ? args : JSON.stringify(args, null, 2))}</pre>
|
||||
<div class="label">Result${isError ? " (error)" : ""}</div>
|
||||
<pre>${escapeHtml(result || "")}</pre>
|
||||
`;
|
||||
details.appendChild(inner);
|
||||
body.appendChild(details);
|
||||
|
||||
wrap.appendChild(body);
|
||||
els.chat.appendChild(wrap);
|
||||
els.chat.scrollTop = els.chat.scrollHeight;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings
|
||||
// ---------------------------------------------------------------------------
|
||||
function applyServerState(state) {
|
||||
State.serverState = state;
|
||||
els.settingsForm.model.value = state.model || "";
|
||||
els.settingsForm.base_url.value = state.base_url || "";
|
||||
els.settingsForm.cwd.value = state.cwd || "";
|
||||
els.settingsForm.allow_shell.checked = !!state.allow_shell;
|
||||
els.settingsForm.allow_write.checked = !!state.allow_write;
|
||||
els.cwdMeta.textContent = `cwd: ${state.cwd || "?"}`;
|
||||
}
|
||||
|
||||
async function loadServerState() {
|
||||
try {
|
||||
const state = await apiGet("/api/state");
|
||||
applyServerState(state);
|
||||
} catch (e) {
|
||||
setStatus("error", `state: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings(ev) {
|
||||
ev.preventDefault();
|
||||
const fd = new FormData(els.settingsForm);
|
||||
const payload = {
|
||||
model: fd.get("model") || "",
|
||||
base_url: fd.get("base_url") || "",
|
||||
cwd: fd.get("cwd") || "",
|
||||
allow_shell: els.settingsForm.allow_shell.checked,
|
||||
allow_write: els.settingsForm.allow_write.checked,
|
||||
};
|
||||
try {
|
||||
setStatus("busy", "Saving settings…");
|
||||
const state = await apiPost("/api/state", payload);
|
||||
applyServerState(state);
|
||||
setStatus("ready", "Settings saved");
|
||||
} catch (e) {
|
||||
setStatus("error", `save: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sessions
|
||||
// ---------------------------------------------------------------------------
|
||||
async function loadSessions() {
|
||||
try {
|
||||
const sessions = await apiGet("/api/sessions");
|
||||
State.sessions = sessions;
|
||||
renderSessions();
|
||||
} catch (e) {
|
||||
els.sessionList.innerHTML = `<div class="empty-state">${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSessions() {
|
||||
els.sessionList.innerHTML = "";
|
||||
if (!State.sessions.length) {
|
||||
els.sessionList.innerHTML = `<div class="empty-state">No saved sessions yet.</div>`;
|
||||
return;
|
||||
}
|
||||
for (const s of State.sessions) {
|
||||
const item = document.createElement("button");
|
||||
item.type = "button";
|
||||
item.className = "session-item";
|
||||
if (s.session_id === State.activeSessionId) item.classList.add("active");
|
||||
const preview = s.preview || "(no preview)";
|
||||
item.innerHTML = `
|
||||
<div class="session-preview">${escapeHtml(preview)}</div>
|
||||
<div class="session-meta">${s.turns} turns · ${s.tool_calls} tools</div>
|
||||
`;
|
||||
item.addEventListener("click", () => openSession(s.session_id));
|
||||
els.sessionList.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
async function openSession(sessionId) {
|
||||
try {
|
||||
setStatus("busy", "Loading session…");
|
||||
const session = await apiGet(`/api/sessions/${encodeURIComponent(sessionId)}`);
|
||||
State.activeSessionId = sessionId;
|
||||
clearChat();
|
||||
for (const msg of session.messages) {
|
||||
renderTranscriptEntry(msg);
|
||||
}
|
||||
renderSessions();
|
||||
setStatus("ready", `Session ${sessionId.slice(0, 8)}…`);
|
||||
} catch (e) {
|
||||
setStatus("error", `session: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function newSession() {
|
||||
State.activeSessionId = null;
|
||||
clearChat();
|
||||
renderSessions();
|
||||
setStatus("ready", "New chat");
|
||||
els.input.focus();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transcript rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
function renderTranscriptEntry(entry) {
|
||||
if (!entry || !entry.role) return;
|
||||
if (entry.role === "system") return;
|
||||
if (entry.role === "user") {
|
||||
appendMessage({ role: "user", content: entry.content || "" });
|
||||
return;
|
||||
}
|
||||
if (entry.role === "assistant") {
|
||||
if (entry.content && entry.content.trim()) {
|
||||
appendMessage({ role: "assistant", content: entry.content });
|
||||
}
|
||||
if (Array.isArray(entry.tool_calls)) {
|
||||
for (const tc of entry.tool_calls) {
|
||||
const name = tc?.function?.name || tc?.name || "tool";
|
||||
let args = tc?.function?.arguments ?? tc?.arguments ?? "";
|
||||
try {
|
||||
if (typeof args === "string") args = JSON.parse(args);
|
||||
} catch {}
|
||||
appendToolCall({ name, args, result: "(pending — see following tool message)" });
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (entry.role === "tool") {
|
||||
const name = entry.name || "tool";
|
||||
const meta = entry.metadata || {};
|
||||
appendToolCall({
|
||||
name,
|
||||
args: meta.action || "",
|
||||
result: entry.content || "",
|
||||
isError: meta.ok === false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slash commands / skills palette
|
||||
// ---------------------------------------------------------------------------
|
||||
async function loadSlashCommands() {
|
||||
try {
|
||||
State.slashCommands = await apiGet("/api/slash-commands");
|
||||
} catch (e) {
|
||||
State.slashCommands = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSkills() {
|
||||
try {
|
||||
State.skills = await apiGet("/api/skills");
|
||||
} catch (e) {
|
||||
State.skills = [];
|
||||
}
|
||||
}
|
||||
|
||||
function openPalette(mode) {
|
||||
State.paletteMode = mode;
|
||||
els.palette.classList.remove("hidden");
|
||||
els.paletteSearch.value = "";
|
||||
renderPalette("");
|
||||
els.paletteSearch.focus();
|
||||
}
|
||||
|
||||
function closePalette() {
|
||||
els.palette.classList.add("hidden");
|
||||
State.paletteMode = null;
|
||||
}
|
||||
|
||||
function renderPalette(filter) {
|
||||
const items = State.paletteMode === "skills" ? State.skills : State.slashCommands;
|
||||
const f = (filter || "").toLowerCase().trim();
|
||||
els.paletteList.innerHTML = "";
|
||||
for (const item of items) {
|
||||
const display =
|
||||
State.paletteMode === "skills"
|
||||
? { name: item.name, desc: item.description }
|
||||
: { name: `/${item.primary}`, desc: item.description };
|
||||
if (f && !display.name.toLowerCase().includes(f) && !display.desc.toLowerCase().includes(f))
|
||||
continue;
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "palette-item";
|
||||
btn.innerHTML = `<span class="palette-name">${escapeHtml(display.name)}</span><span class="palette-desc">${escapeHtml(display.desc || "")}</span>`;
|
||||
btn.addEventListener("click", () => {
|
||||
if (State.paletteMode === "skills") {
|
||||
els.input.value = `Use the ${item.name} skill`;
|
||||
} else {
|
||||
els.input.value = `/${item.primary} `;
|
||||
}
|
||||
closePalette();
|
||||
autoSizeInput();
|
||||
els.input.focus();
|
||||
});
|
||||
els.paletteList.appendChild(btn);
|
||||
}
|
||||
if (!els.paletteList.children.length) {
|
||||
els.paletteList.innerHTML = `<div class="empty-state" style="padding:10px;">No matches.</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composer
|
||||
// ---------------------------------------------------------------------------
|
||||
function autoSizeInput() {
|
||||
els.input.style.height = "auto";
|
||||
els.input.style.height = Math.min(els.input.scrollHeight, 200) + "px";
|
||||
}
|
||||
|
||||
async function send() {
|
||||
if (State.isBusy) return;
|
||||
const prompt = els.input.value.trim();
|
||||
if (!prompt) return;
|
||||
appendMessage({ role: "user", content: prompt });
|
||||
els.input.value = "";
|
||||
autoSizeInput();
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = {
|
||||
prompt,
|
||||
resume_session_id: State.activeSessionId || null,
|
||||
};
|
||||
const data = await apiPost("/api/chat", payload);
|
||||
if (data.error) {
|
||||
appendMessage({ role: "error", content: `${data.error_type}: ${data.error}` });
|
||||
setStatus("error", "Error");
|
||||
} else {
|
||||
// Render tool calls + final response from the transcript so the user
|
||||
// sees what the agent actually did, not just the final reply.
|
||||
renderRunResult(data);
|
||||
State.activeSessionId = data.session_id || State.activeSessionId;
|
||||
els.usageMeta.textContent =
|
||||
`turns: ${data.turns} · tools: ${data.tool_calls}` +
|
||||
(data.usage?.total_tokens
|
||||
? ` · tokens: ${data.usage.total_tokens}`
|
||||
: "");
|
||||
await loadSessions();
|
||||
}
|
||||
} catch (e) {
|
||||
appendMessage({ role: "error", content: e.message });
|
||||
setStatus("error", "Error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
els.input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function renderRunResult(data) {
|
||||
// We already rendered the user prompt. The transcript starts with system +
|
||||
// user + assistant turns; we want to show: any tool messages that happened
|
||||
// since the last user message, then the final assistant response.
|
||||
const transcript = data.transcript || [];
|
||||
let lastUserIndex = -1;
|
||||
for (let i = transcript.length - 1; i >= 0; i--) {
|
||||
if (transcript[i].role === "user") {
|
||||
lastUserIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const tail = transcript.slice(lastUserIndex + 1);
|
||||
for (const entry of tail) {
|
||||
if (entry.role === "assistant") {
|
||||
// Only render if it has visible content; tool_calls already rendered.
|
||||
if (Array.isArray(entry.tool_calls) && entry.tool_calls.length) {
|
||||
// Skip — corresponding tool messages will follow.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
renderTranscriptEntry(entry);
|
||||
}
|
||||
// Always show the canonical final output if it isn't already present.
|
||||
const lastMsg = els.chat.querySelector(".message.assistant:last-of-type .body");
|
||||
if (!lastMsg || lastMsg.textContent.trim() !== (data.final_output || "").trim()) {
|
||||
if (data.final_output && data.final_output.trim()) {
|
||||
appendMessage({ role: "assistant", content: data.final_output });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wire up
|
||||
// ---------------------------------------------------------------------------
|
||||
function bind() {
|
||||
els.input.addEventListener("input", autoSizeInput);
|
||||
els.input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
});
|
||||
els.sendBtn.addEventListener("click", send);
|
||||
els.newSessionBtn.addEventListener("click", newSession);
|
||||
els.settingsForm.addEventListener("submit", saveSettings);
|
||||
els.slashBtn.addEventListener("click", () => openPalette("slash"));
|
||||
els.skillsBtn.addEventListener("click", () => openPalette("skills"));
|
||||
els.clearBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
setStatus("busy", "Clearing…");
|
||||
await apiPost("/api/clear", {});
|
||||
newSession();
|
||||
setStatus("ready", "Cleared");
|
||||
} catch (e) {
|
||||
setStatus("error", e.message);
|
||||
}
|
||||
});
|
||||
els.paletteClose.addEventListener("click", closePalette);
|
||||
els.palette.addEventListener("click", (e) => {
|
||||
if (e.target === els.palette) closePalette();
|
||||
});
|
||||
els.paletteSearch.addEventListener("input", (e) => renderPalette(e.target.value));
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && !els.palette.classList.contains("hidden")) {
|
||||
closePalette();
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
openPalette("slash");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
bind();
|
||||
setStatus("ready", "Ready");
|
||||
await Promise.all([
|
||||
loadServerState(),
|
||||
loadSessions(),
|
||||
loadSlashCommands(),
|
||||
loadSkills(),
|
||||
]);
|
||||
els.input.focus();
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -0,0 +1,108 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Claw Code</title>
|
||||
<link rel="stylesheet" href="/static/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-head">
|
||||
<div class="brand">
|
||||
<span class="brand-mark">CC</span>
|
||||
<span class="brand-name">Claw Code</span>
|
||||
</div>
|
||||
<button id="new-session-btn" class="btn-primary" type="button">+ New chat</button>
|
||||
</div>
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-title">Sessions</div>
|
||||
<div id="session-list" class="session-list">
|
||||
<div class="empty-state">Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-title">Settings</div>
|
||||
<form id="settings-form" class="settings">
|
||||
<label>
|
||||
<span>Model</span>
|
||||
<input type="text" name="model" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Base URL</span>
|
||||
<input type="text" name="base_url" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Working dir</span>
|
||||
<input type="text" name="cwd" autocomplete="off" />
|
||||
</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" name="allow_shell" />
|
||||
<span>Allow shell commands</span>
|
||||
</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" name="allow_write" />
|
||||
<span>Allow write/edit</span>
|
||||
</label>
|
||||
<button type="submit" class="btn-secondary">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<span class="status-dot" id="status-dot"></span>
|
||||
<span id="status-text">Ready</span>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<button id="slash-btn" class="btn-ghost" type="button" title="Slash commands">/</button>
|
||||
<button id="skills-btn" class="btn-ghost" type="button" title="Skills">★</button>
|
||||
<button id="clear-btn" class="btn-ghost" type="button" title="Clear runtime state">⟲</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="chat" class="chat">
|
||||
<div id="welcome" class="welcome">
|
||||
<h1>Claw Code</h1>
|
||||
<p>Type a message below to start. Use the <code>/</code> button for slash commands or <code>★</code> for skills.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="composer">
|
||||
<div class="composer-inner">
|
||||
<textarea
|
||||
id="prompt-input"
|
||||
placeholder="Send a message… (Enter to send, Shift+Enter for newline)"
|
||||
rows="1"
|
||||
autocomplete="off"
|
||||
></textarea>
|
||||
<button id="send-btn" type="button" class="btn-send" title="Send">Send</button>
|
||||
</div>
|
||||
<div class="composer-meta">
|
||||
<span id="cwd-meta"></span>
|
||||
<span id="usage-meta"></span>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="palette" class="palette hidden">
|
||||
<div class="palette-card">
|
||||
<div class="palette-head">
|
||||
<input
|
||||
id="palette-search"
|
||||
type="text"
|
||||
placeholder="Filter…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button class="btn-ghost" id="palette-close" type="button">✕</button>
|
||||
</div>
|
||||
<div id="palette-list" class="palette-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Integration tests for the local web GUI FastAPI server.
|
||||
|
||||
These tests exercise the JSON endpoints against a real :class:`AgentState`
|
||||
without booting uvicorn, using ``fastapi.testclient.TestClient``. Slash
|
||||
commands are dispatched locally inside :class:`LocalCodingAgent` and never
|
||||
hit the network, so the chat endpoint can be exercised end-to-end against
|
||||
``/help``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.gui.server import AgentState, create_app
|
||||
|
||||
|
||||
def _build_client(tmp: Path) -> tuple[TestClient, AgentState]:
|
||||
state = AgentState(
|
||||
cwd=tmp,
|
||||
model='test-model',
|
||||
base_url='http://127.0.0.1:8000/v1',
|
||||
api_key='local-token',
|
||||
allow_shell=False,
|
||||
allow_write=False,
|
||||
session_directory=tmp / 'sessions',
|
||||
)
|
||||
return TestClient(create_app(state)), state
|
||||
|
||||
|
||||
class GuiServerTests(unittest.TestCase):
|
||||
def test_root_serves_html(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
client, _ = _build_client(Path(d))
|
||||
response = client.get('/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('Claw Code', response.text)
|
||||
|
||||
def test_static_assets_served(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
client, _ = _build_client(Path(d))
|
||||
self.assertEqual(client.get('/static/app.css').status_code, 200)
|
||||
self.assertEqual(client.get('/static/app.js').status_code, 200)
|
||||
|
||||
def test_state_snapshot_round_trip(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
client, _ = _build_client(Path(d))
|
||||
response = client.get('/api/state')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload['model'], 'test-model')
|
||||
self.assertFalse(payload['allow_shell'])
|
||||
|
||||
updated = client.post(
|
||||
'/api/state',
|
||||
json={'allow_shell': True, 'model': 'other-model'},
|
||||
)
|
||||
self.assertEqual(updated.status_code, 200)
|
||||
data = updated.json()
|
||||
self.assertTrue(data['allow_shell'])
|
||||
self.assertEqual(data['model'], 'other-model')
|
||||
|
||||
def test_state_update_rejects_missing_cwd(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
client, _ = _build_client(Path(d))
|
||||
response = client.post(
|
||||
'/api/state',
|
||||
json={'cwd': str(Path(d) / 'does-not-exist')},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_slash_commands_listed(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
client, _ = _build_client(Path(d))
|
||||
response = client.get('/api/slash-commands')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
commands = response.json()
|
||||
self.assertTrue(commands)
|
||||
primaries = {entry['primary'] for entry in commands}
|
||||
self.assertIn('help', primaries)
|
||||
|
||||
def test_skills_listed(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
client, _ = _build_client(Path(d))
|
||||
response = client.get('/api/skills')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
skills = response.json()
|
||||
self.assertTrue(skills)
|
||||
names = {entry['name'] for entry in skills}
|
||||
self.assertIn('simplify', names)
|
||||
|
||||
def test_chat_runs_local_slash_command(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
client, _ = _build_client(Path(d))
|
||||
response = client.post('/api/chat', json={'prompt': '/help'})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload['turns'], 0)
|
||||
self.assertEqual(payload['tool_calls'], 0)
|
||||
self.assertIn('slash commands', payload['final_output'].lower())
|
||||
self.assertIn('/help', payload['final_output'])
|
||||
self.assertIn('total_tokens', payload['usage'])
|
||||
|
||||
def test_chat_rejects_blank_prompt(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
client, _ = _build_client(Path(d))
|
||||
response = client.post('/api/chat', json={'prompt': ' '})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_chat_resume_unknown_session_returns_404(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
client, _ = _build_client(Path(d))
|
||||
response = client.post(
|
||||
'/api/chat',
|
||||
json={'prompt': '/help', 'resume_session_id': 'missing'},
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_sessions_list_empty_when_directory_absent(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
client, _ = _build_client(Path(d))
|
||||
response = client.get('/api/sessions')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), [])
|
||||
|
||||
def test_session_detail_404_when_missing(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
client, _ = _build_client(Path(d))
|
||||
response = client.get('/api/sessions/nope')
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_clear_runtime_state(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
client, _ = _build_client(Path(d))
|
||||
response = client.post('/api/clear')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('model', response.json())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user