Restructure web app directories
This commit is contained in:
@@ -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,616 @@
|
||||
"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;
|
||||
}
|
||||
|
||||
function parseAssistantToolCalls(entry) {
|
||||
if (!Array.isArray(entry?.tool_calls)) return [];
|
||||
return entry.tool_calls.map((tc) => {
|
||||
const fn = tc?.function || {};
|
||||
let args = fn.arguments ?? tc?.arguments ?? "";
|
||||
try {
|
||||
if (typeof args === "string") args = JSON.parse(args);
|
||||
} catch {}
|
||||
return {
|
||||
id: tc?.id || "",
|
||||
name: fn.name || tc?.name || "tool",
|
||||
args,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function renderTranscriptEntries(entries) {
|
||||
const pendingToolCalls = new Map();
|
||||
for (const entry of entries || []) {
|
||||
if (!entry || entry.role === "system") continue;
|
||||
if (entry.role === "assistant") {
|
||||
if (entry.content && entry.content.trim()) {
|
||||
appendMessage({ role: "assistant", content: entry.content });
|
||||
}
|
||||
for (const call of parseAssistantToolCalls(entry)) {
|
||||
if (call.id) pendingToolCalls.set(call.id, call);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (entry.role === "tool") {
|
||||
const call = pendingToolCalls.get(entry.tool_call_id) || {};
|
||||
let parsedResult = null;
|
||||
try {
|
||||
parsedResult = JSON.parse(entry.content || "{}");
|
||||
} catch {}
|
||||
appendToolCall({
|
||||
name: call.name || entry.name || parsedResult?.tool || "tool",
|
||||
args: call.args ?? "",
|
||||
result: entry.content || "",
|
||||
isError: parsedResult?.ok === false || entry.metadata?.error_kind,
|
||||
});
|
||||
if (entry.tool_call_id) pendingToolCalls.delete(entry.tool_call_id);
|
||||
continue;
|
||||
}
|
||||
renderTranscriptEntry(entry);
|
||||
}
|
||||
for (const call of pendingToolCalls.values()) {
|
||||
appendToolCall({
|
||||
name: call.name,
|
||||
args: call.args,
|
||||
result: "(pending — no tool result message found)",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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();
|
||||
renderTranscriptEntries(session.messages);
|
||||
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);
|
||||
renderTranscriptEntries(tail);
|
||||
// transcript 通常已经包含最终 assistant 消息;只有缺失时才用 final_output 兜底。
|
||||
const finalOutput = (data.final_output || "").trim();
|
||||
const lastAssistant = [...tail]
|
||||
.reverse()
|
||||
.find((entry) => entry?.role === "assistant" && (entry.content || "").trim());
|
||||
const lastAssistantContent = (lastAssistant?.content || "").trim();
|
||||
if (finalOutput && finalOutput !== lastAssistantContent) {
|
||||
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>
|
||||
Reference in New Issue
Block a user