Implemented the next missing parity slice around a local web GUI for the agent.
This commit is contained in:
@@ -822,6 +822,7 @@ Mirrored / scaffold areas needing real implementation:
|
|||||||
- [ ] Token budget calculations
|
- [ ] Token budget calculations
|
||||||
|
|
||||||
### Tier 3 — Nice-to-Have Features
|
### 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
|
- [ ] TUI/Ink component library
|
||||||
- [ ] Voice mode
|
- [ ] Voice mode
|
||||||
- [ ] VIM mode and keybinding system
|
- [ ] VIM mode and keybinding system
|
||||||
|
|||||||
+7
-1
@@ -32,7 +32,11 @@ classifiers = [
|
|||||||
"Topic :: Software Development",
|
"Topic :: Software Development",
|
||||||
"Topic :: Software Development :: Build Tools",
|
"Topic :: Software Development :: Build Tools",
|
||||||
]
|
]
|
||||||
dependencies = []
|
dependencies = [
|
||||||
|
"fastapi>=0.110",
|
||||||
|
"uvicorn>=0.27",
|
||||||
|
"pydantic>=2.5",
|
||||||
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://github.com/HarnessLab/claw-code-agent"
|
Homepage = "https://github.com/HarnessLab/claw-code-agent"
|
||||||
@@ -40,6 +44,7 @@ Repository = "https://github.com/HarnessLab/claw-code-agent"
|
|||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
claw-code-agent = "src.main:main"
|
claw-code-agent = "src.main:main"
|
||||||
|
claw-code-gui = "src.gui.__main__:main"
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
include-package-data = true
|
include-package-data = true
|
||||||
@@ -51,4 +56,5 @@ include = ["src*"]
|
|||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
src = [
|
src = [
|
||||||
"reference_data/*.json",
|
"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