Merge pull request #28 from HarnessLab/update_17_4

Update 17 4
This commit is contained in:
Abdelrahman Abdallah
2026-04-20 06:14:48 +02:00
committed by GitHub
10 changed files with 2021 additions and 2 deletions
+1
View File
@@ -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
+43 -1
View File
@@ -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
View File
@@ -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/*",
]
+4
View File
@@ -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``.
"""
+85
View File
@@ -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()
+340
View File
@@ -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'),
}
+719
View File
@@ -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;
}
}
+570
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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();
+108
View File
@@ -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&nbsp;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>
+144
View File
@@ -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()