Add a Claude Code-like chat experience to the VS Code extension with: - Streaming chat panel (sidebar + editor tab) with markdown rendering - Tool use visualization with inline diffs (replace/with display) - Session history browser with JSONL transcript parsing - Thinking block indicator with elapsed time and token count - Clickable file paths that open in the editor - Permission mode setting (acceptEdits default) - Multi-turn conversation support via NDJSON stream-json protocol - Status bar with live activity indicators - Ctrl+Shift+L keybinding to open chat panel Made-with: Cursor Co-authored-by: henriquepasquini2 <henriquepasquini2@users.noreply.github.com>
1355 lines
46 KiB
JavaScript
1355 lines
46 KiB
JavaScript
/**
|
|
* chatRenderer — produces the full self-contained HTML document for the chat
|
|
* webview. All CSS and JS are inlined (no external bundles).
|
|
*
|
|
* The webview JS communicates with the extension host via postMessage.
|
|
* Incoming messages update the DOM incrementally so streaming feels fluid.
|
|
*/
|
|
|
|
function escapeHtml(value) {
|
|
return String(value)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function renderChatHtml({ nonce, platform }) {
|
|
const modKey = platform === 'darwin' ? 'Cmd' : 'Ctrl';
|
|
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta http-equiv="Content-Security-Policy"
|
|
content="default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<style>
|
|
:root {
|
|
--oc-bg: #0a0908;
|
|
--oc-panel: #110d0c;
|
|
--oc-panel-strong: #17110f;
|
|
--oc-panel-soft: #1d1512;
|
|
--oc-border: #645041;
|
|
--oc-border-soft: rgba(220,195,170,0.14);
|
|
--oc-text: #f7efe5;
|
|
--oc-text-dim: #dcc3aa;
|
|
--oc-text-soft: #aa9078;
|
|
--oc-accent: #d77757;
|
|
--oc-accent-bright: #f09464;
|
|
--oc-accent-soft: rgba(240,148,100,0.18);
|
|
--oc-positive: #e8b86b;
|
|
--oc-warning: #f3c969;
|
|
--oc-critical: #ff8a6c;
|
|
--oc-focus: #ffd3a1;
|
|
--oc-user-bg: rgba(240,148,100,0.12);
|
|
--oc-user-border: rgba(240,148,100,0.28);
|
|
--oc-assistant-bg: rgba(255,255,255,0.03);
|
|
--oc-assistant-border: rgba(220,195,170,0.10);
|
|
--oc-code-bg: #1a1310;
|
|
--oc-code-border: rgba(220,195,170,0.12);
|
|
--oc-tool-bg: rgba(232,184,107,0.06);
|
|
--oc-tool-border: rgba(232,184,107,0.22);
|
|
--oc-perm-bg: rgba(255,138,108,0.08);
|
|
--oc-perm-border: rgba(255,138,108,0.35);
|
|
}
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
html, body { height: 100%; overflow: hidden; }
|
|
body {
|
|
font-family: var(--vscode-font-family, "Segoe UI", system-ui, sans-serif);
|
|
font-size: 13px;
|
|
color: var(--oc-text);
|
|
background: var(--oc-bg);
|
|
display: flex;
|
|
flex-direction: column;
|
|
position: relative;
|
|
}
|
|
|
|
/* ── Header ── */
|
|
.chat-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 8px 12px;
|
|
border-bottom: 1px solid var(--oc-border-soft);
|
|
background: var(--oc-panel);
|
|
flex-shrink: 0;
|
|
}
|
|
.chat-header .brand {
|
|
font-weight: 700;
|
|
font-size: 14px;
|
|
color: var(--oc-text);
|
|
flex: 1;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.chat-header .brand-accent { color: var(--oc-accent-bright); }
|
|
.header-btn {
|
|
border: 1px solid var(--oc-border-soft);
|
|
border-radius: 6px;
|
|
background: rgba(255,255,255,0.04);
|
|
color: var(--oc-text-dim);
|
|
padding: 4px 8px;
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
}
|
|
.header-btn:hover { border-color: var(--oc-accent); color: var(--oc-text); }
|
|
.header-btn.danger { border-color: var(--oc-critical); color: var(--oc-critical); }
|
|
.header-btn.danger:hover { background: rgba(255,138,108,0.12); }
|
|
#abortBtn { display: none; }
|
|
|
|
/* ── Status bar ── */
|
|
.status-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 4px 12px;
|
|
font-size: 11px;
|
|
color: var(--oc-text-soft);
|
|
border-bottom: 1px solid var(--oc-border-soft);
|
|
background: var(--oc-panel);
|
|
flex-shrink: 0;
|
|
}
|
|
.status-bar .status-dot {
|
|
width: 6px; height: 6px;
|
|
border-radius: 50%;
|
|
background: var(--oc-text-soft);
|
|
flex-shrink: 0;
|
|
}
|
|
.status-bar .status-dot.connected { background: var(--oc-positive); }
|
|
.status-bar .status-dot.streaming { background: var(--oc-accent-bright); animation: pulse 1s infinite; }
|
|
.status-bar .status-dot.error { background: var(--oc-critical); }
|
|
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
|
.status-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.status-usage { color: var(--oc-text-soft); }
|
|
|
|
/* ── Message list ── */
|
|
.messages {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 12px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
.messages::-webkit-scrollbar { width: 6px; }
|
|
.messages::-webkit-scrollbar-track { background: transparent; }
|
|
.messages::-webkit-scrollbar-thumb { background: rgba(220,195,170,0.18); border-radius: 3px; }
|
|
|
|
/* ── Welcome screen ── */
|
|
.welcome {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex: 1;
|
|
text-align: center;
|
|
padding: 32px 16px;
|
|
gap: 16px;
|
|
}
|
|
.welcome-title { font-size: 20px; font-weight: 700; color: var(--oc-text); }
|
|
.welcome-title .accent { color: var(--oc-accent-bright); }
|
|
.welcome-sub { font-size: 13px; color: var(--oc-text-dim); max-width: 36ch; }
|
|
.welcome-hint { font-size: 11px; color: var(--oc-text-soft); }
|
|
.welcome-hint kbd {
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
border: 1px solid var(--oc-border-soft);
|
|
background: rgba(255,255,255,0.04);
|
|
font-family: inherit;
|
|
font-size: 11px;
|
|
}
|
|
|
|
/* ── User message ── */
|
|
.msg-user {
|
|
align-self: flex-end;
|
|
max-width: 85%;
|
|
padding: 10px 14px;
|
|
border-radius: 14px 14px 4px 14px;
|
|
background: var(--oc-user-bg);
|
|
border: 1px solid var(--oc-user-border);
|
|
word-break: break-word;
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
/* ── Assistant message ── */
|
|
.msg-assistant {
|
|
align-self: flex-start;
|
|
max-width: 95%;
|
|
padding: 10px 14px;
|
|
border-radius: 4px 14px 14px 14px;
|
|
background: var(--oc-assistant-bg);
|
|
border: 1px solid var(--oc-assistant-border);
|
|
word-break: break-word;
|
|
}
|
|
.msg-assistant .md-content { line-height: 1.55; }
|
|
.msg-assistant .md-content:empty { display: none; }
|
|
.msg-assistant .md-content p { margin-bottom: 8px; }
|
|
.msg-assistant .md-content p:last-child { margin-bottom: 0; }
|
|
.msg-assistant .md-content ul,
|
|
.msg-assistant .md-content ol { padding-left: 20px; margin-bottom: 8px; }
|
|
.msg-assistant .md-content li { margin-bottom: 4px; }
|
|
.msg-assistant .md-content h1,
|
|
.msg-assistant .md-content h2,
|
|
.msg-assistant .md-content h3 {
|
|
color: var(--oc-text);
|
|
margin: 12px 0 6px;
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
}
|
|
.msg-assistant .md-content h1 { font-size: 16px; }
|
|
.msg-assistant .md-content a { color: var(--oc-accent-bright); text-decoration: underline; }
|
|
.msg-assistant .md-content strong { color: var(--oc-text); font-weight: 700; }
|
|
.msg-assistant .md-content em { font-style: italic; color: var(--oc-text-dim); }
|
|
.msg-assistant .md-content blockquote {
|
|
border-left: 3px solid var(--oc-accent);
|
|
padding: 4px 12px;
|
|
margin: 8px 0;
|
|
color: var(--oc-text-dim);
|
|
}
|
|
.msg-assistant .md-content hr {
|
|
border: none;
|
|
border-top: 1px solid var(--oc-border-soft);
|
|
margin: 12px 0;
|
|
}
|
|
|
|
/* inline code */
|
|
.md-content code:not(.code-block code) {
|
|
padding: 1px 5px;
|
|
border-radius: 4px;
|
|
background: var(--oc-code-bg);
|
|
border: 1px solid var(--oc-code-border);
|
|
font-family: var(--vscode-editor-font-family, Consolas, monospace);
|
|
font-size: 12px;
|
|
color: var(--oc-accent-bright);
|
|
}
|
|
|
|
/* fenced code */
|
|
.code-wrapper {
|
|
position: relative;
|
|
margin: 8px 0;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--oc-code-border);
|
|
background: var(--oc-code-bg);
|
|
overflow: hidden;
|
|
}
|
|
.code-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 4px 10px;
|
|
font-size: 11px;
|
|
color: var(--oc-text-soft);
|
|
border-bottom: 1px solid var(--oc-code-border);
|
|
background: rgba(255,255,255,0.02);
|
|
}
|
|
.code-copy-btn {
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--oc-text-soft);
|
|
cursor: pointer;
|
|
font-size: 11px;
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
}
|
|
.code-copy-btn:hover { background: rgba(255,255,255,0.08); color: var(--oc-text); }
|
|
.code-block {
|
|
display: block;
|
|
padding: 10px 12px;
|
|
overflow-x: auto;
|
|
font-family: var(--vscode-editor-font-family, Consolas, monospace);
|
|
font-size: 12px;
|
|
line-height: 1.5;
|
|
white-space: pre;
|
|
color: var(--oc-text-dim);
|
|
}
|
|
.code-block::-webkit-scrollbar { height: 4px; }
|
|
.code-block::-webkit-scrollbar-thumb { background: rgba(220,195,170,0.2); border-radius: 2px; }
|
|
|
|
/* keyword highlighting */
|
|
.hl-keyword { color: #c586c0; }
|
|
.hl-string { color: #ce9178; }
|
|
.hl-comment { color: #6a9955; font-style: italic; }
|
|
.hl-number { color: #b5cea8; }
|
|
.hl-func { color: #dcdcaa; }
|
|
.hl-type { color: #4ec9b0; }
|
|
|
|
/* ── Tool use card ── */
|
|
.tool-card {
|
|
margin: 8px 0;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--oc-tool-border);
|
|
background: var(--oc-tool-bg);
|
|
overflow: hidden;
|
|
}
|
|
.tool-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 8px 10px;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
}
|
|
.tool-icon { font-size: 14px; flex-shrink: 0; }
|
|
.tool-name { font-weight: 600; font-size: 12px; color: var(--oc-text); flex: 1; }
|
|
.tool-status { font-size: 11px; color: var(--oc-text-soft); }
|
|
.tool-status.running { color: var(--oc-accent-bright); }
|
|
.tool-status.error { color: var(--oc-critical); }
|
|
.tool-status.complete { color: var(--oc-positive); }
|
|
.tool-chevron {
|
|
font-size: 10px;
|
|
color: var(--oc-text-soft);
|
|
transition: transform 150ms;
|
|
}
|
|
.tool-card.expanded .tool-chevron { transform: rotate(90deg); }
|
|
.tool-body {
|
|
display: none;
|
|
padding: 0 10px 10px;
|
|
font-size: 12px;
|
|
border-top: 1px solid var(--oc-tool-border);
|
|
}
|
|
.tool-card.expanded .tool-body { display: block; }
|
|
.tool-input-label,
|
|
.tool-output-label {
|
|
font-size: 10px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
color: var(--oc-text-soft);
|
|
margin: 8px 0 4px;
|
|
}
|
|
.tool-input-content,
|
|
.tool-output-content {
|
|
padding: 6px 8px;
|
|
border-radius: 6px;
|
|
background: rgba(0,0,0,0.2);
|
|
font-family: var(--vscode-editor-font-family, Consolas, monospace);
|
|
font-size: 11px;
|
|
color: var(--oc-text-dim);
|
|
white-space: pre-wrap;
|
|
word-break: break-all;
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
}
|
|
.tool-output-content.error { color: var(--oc-critical); }
|
|
.tool-path {
|
|
font-weight: 400;
|
|
color: var(--oc-text-soft);
|
|
font-size: 11px;
|
|
margin-left: 4px;
|
|
}
|
|
.file-link {
|
|
color: var(--oc-accent-bright);
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
border-bottom: 1px dotted var(--oc-accent);
|
|
transition: color 120ms, border-color 120ms;
|
|
}
|
|
.file-link:hover {
|
|
color: var(--oc-focus);
|
|
border-bottom-color: var(--oc-focus);
|
|
}
|
|
.tool-input-content.tool-diff-old {
|
|
border-left: 3px solid var(--oc-critical);
|
|
padding-left: 10px;
|
|
color: #ff9e8a;
|
|
text-decoration: line-through;
|
|
opacity: 0.7;
|
|
}
|
|
.tool-input-content.tool-diff-new {
|
|
border-left: 3px solid var(--oc-positive);
|
|
padding-left: 10px;
|
|
color: #c8e6a0;
|
|
}
|
|
.tool-diff-btn {
|
|
margin-top: 6px;
|
|
border: 1px solid var(--oc-accent);
|
|
border-radius: 6px;
|
|
background: rgba(240,148,100,0.08);
|
|
color: var(--oc-accent-bright);
|
|
padding: 4px 10px;
|
|
font-size: 11px;
|
|
cursor: pointer;
|
|
}
|
|
.tool-diff-btn:hover { background: rgba(240,148,100,0.16); }
|
|
|
|
/* ── Permission card ── */
|
|
.perm-card {
|
|
margin: 8px 0;
|
|
padding: 10px 12px;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--oc-perm-border);
|
|
background: var(--oc-perm-bg);
|
|
}
|
|
.perm-title { font-weight: 700; font-size: 12px; color: var(--oc-critical); margin-bottom: 6px; }
|
|
.perm-desc { font-size: 12px; color: var(--oc-text-dim); margin-bottom: 8px; }
|
|
.perm-input {
|
|
padding: 6px 8px;
|
|
margin-bottom: 8px;
|
|
border-radius: 6px;
|
|
background: rgba(0,0,0,0.2);
|
|
font-family: var(--vscode-editor-font-family, Consolas, monospace);
|
|
font-size: 11px;
|
|
color: var(--oc-text-dim);
|
|
white-space: pre-wrap;
|
|
max-height: 120px;
|
|
overflow-y: auto;
|
|
}
|
|
.perm-actions { display: flex; gap: 6px; }
|
|
.perm-btn {
|
|
padding: 5px 12px;
|
|
border-radius: 6px;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
border: 1px solid;
|
|
}
|
|
.perm-btn.allow {
|
|
background: rgba(232,184,107,0.14);
|
|
border-color: var(--oc-positive);
|
|
color: var(--oc-positive);
|
|
}
|
|
.perm-btn.deny {
|
|
background: rgba(255,138,108,0.1);
|
|
border-color: var(--oc-critical);
|
|
color: var(--oc-critical);
|
|
}
|
|
.perm-btn.allow-session {
|
|
background: rgba(232,184,107,0.08);
|
|
border-color: rgba(232,184,107,0.4);
|
|
color: var(--oc-text-dim);
|
|
}
|
|
.perm-btn:hover { filter: brightness(1.15); }
|
|
|
|
/* ── Status pill ── */
|
|
.msg-status {
|
|
align-self: center;
|
|
font-size: 11px;
|
|
color: var(--oc-text-soft);
|
|
padding: 4px 12px;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--oc-border-soft);
|
|
background: rgba(255,255,255,0.02);
|
|
}
|
|
|
|
/* ── Rate limit ── */
|
|
.msg-rate-limit {
|
|
align-self: center;
|
|
font-size: 11px;
|
|
color: var(--oc-warning);
|
|
padding: 6px 14px;
|
|
border-radius: 8px;
|
|
border: 1px solid rgba(243,201,105,0.3);
|
|
background: rgba(243,201,105,0.06);
|
|
}
|
|
|
|
/* ── Thinking block ── */
|
|
.thinking-block {
|
|
display: none;
|
|
align-self: flex-start;
|
|
padding: 10px 14px;
|
|
border-radius: 10px;
|
|
border: 1px solid rgba(200,160,255,0.25);
|
|
background: rgba(160,120,220,0.08);
|
|
margin: 4px 0;
|
|
gap: 6px;
|
|
flex-direction: column;
|
|
}
|
|
.thinking-block.visible { display: flex; }
|
|
.thinking-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 12px;
|
|
color: #c4a0ff;
|
|
font-weight: 600;
|
|
}
|
|
.thinking-spinner {
|
|
width: 12px; height: 12px;
|
|
border: 2px solid rgba(200,160,255,0.3);
|
|
border-top-color: #c4a0ff;
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
.thinking-meta {
|
|
font-size: 11px;
|
|
color: var(--oc-text-soft);
|
|
}
|
|
|
|
/* ── Typing indicator ── */
|
|
.typing-indicator {
|
|
display: none;
|
|
align-self: flex-start;
|
|
padding: 10px 14px;
|
|
gap: 4px;
|
|
}
|
|
.typing-indicator.visible { display: flex; }
|
|
.typing-dot {
|
|
width: 6px; height: 6px;
|
|
border-radius: 50%;
|
|
background: var(--oc-accent);
|
|
animation: typingBounce 1.2s infinite;
|
|
}
|
|
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
|
|
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
|
|
@keyframes typingBounce {
|
|
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
|
|
30% { transform: translateY(-4px); opacity: 1; }
|
|
}
|
|
|
|
/* ── Input area ── */
|
|
.input-area {
|
|
display: flex;
|
|
gap: 8px;
|
|
padding: 10px 12px;
|
|
border-top: 1px solid var(--oc-border-soft);
|
|
background: var(--oc-panel);
|
|
flex-shrink: 0;
|
|
align-items: flex-end;
|
|
}
|
|
.input-area textarea {
|
|
flex: 1;
|
|
min-height: 36px;
|
|
max-height: 160px;
|
|
padding: 8px 12px;
|
|
border: 1px solid var(--oc-border-soft);
|
|
border-radius: 10px;
|
|
background: rgba(255,255,255,0.04);
|
|
color: var(--oc-text);
|
|
font-family: inherit;
|
|
font-size: 13px;
|
|
resize: none;
|
|
outline: none;
|
|
line-height: 1.4;
|
|
}
|
|
.input-area textarea::placeholder { color: var(--oc-text-soft); }
|
|
.input-area textarea:focus { border-color: var(--oc-accent); }
|
|
.send-btn {
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: 10px;
|
|
border: 1px solid var(--oc-accent);
|
|
background: linear-gradient(135deg, rgba(240,148,100,0.2), rgba(215,119,87,0.12));
|
|
color: var(--oc-accent-bright);
|
|
cursor: pointer;
|
|
font-size: 16px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
.send-btn:hover { background: rgba(240,148,100,0.25); }
|
|
.send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
|
|
/* ── Session list overlay ── */
|
|
.session-overlay {
|
|
display: none;
|
|
position: absolute;
|
|
inset: 0;
|
|
z-index: 100;
|
|
background: rgba(5,5,5,0.92);
|
|
flex-direction: column;
|
|
}
|
|
.session-overlay.visible { display: flex; }
|
|
.session-overlay-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 10px 12px;
|
|
border-bottom: 1px solid var(--oc-border-soft);
|
|
}
|
|
.session-overlay-header h2 { font-size: 14px; font-weight: 700; flex: 1; }
|
|
.session-search {
|
|
margin: 8px 12px;
|
|
padding: 8px 10px;
|
|
border: 1px solid var(--oc-border-soft);
|
|
border-radius: 8px;
|
|
background: rgba(255,255,255,0.04);
|
|
color: var(--oc-text);
|
|
font-size: 13px;
|
|
outline: none;
|
|
}
|
|
.session-search:focus { border-color: var(--oc-accent); }
|
|
.session-list {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 8px 12px;
|
|
}
|
|
.session-group-label {
|
|
font-size: 10px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.1em;
|
|
color: var(--oc-text-soft);
|
|
padding: 8px 0 4px;
|
|
}
|
|
.session-item {
|
|
padding: 10px;
|
|
border-radius: 8px;
|
|
border: 1px solid transparent;
|
|
cursor: pointer;
|
|
margin-bottom: 4px;
|
|
}
|
|
.session-item:hover { background: rgba(255,255,255,0.04); border-color: var(--oc-border-soft); }
|
|
.session-item-title { font-weight: 600; font-size: 13px; color: var(--oc-text); margin-bottom: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.session-item-preview { font-size: 11px; color: var(--oc-text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.session-item-time { font-size: 10px; color: var(--oc-text-soft); margin-top: 2px; }
|
|
.session-empty { text-align: center; padding: 32px; color: var(--oc-text-soft); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="chat-header">
|
|
<div class="brand">Open<span class="brand-accent">Claude</span></div>
|
|
<button class="header-btn" id="historyBtn" title="Session history">History</button>
|
|
<button class="header-btn" id="newChatBtn" title="New chat">+ New</button>
|
|
<button class="header-btn danger" id="abortBtn" title="Abort generation">Stop</button>
|
|
</div>
|
|
<div class="status-bar">
|
|
<span class="status-dot" id="statusDot"></span>
|
|
<span class="status-text" id="statusText">Ready</span>
|
|
<span class="status-usage" id="statusUsage"></span>
|
|
</div>
|
|
|
|
<div class="messages" id="messages">
|
|
<div class="welcome" id="welcomeScreen">
|
|
<div class="welcome-title">Open<span class="accent">Claude</span></div>
|
|
<div class="welcome-sub">Ask a question, request a code change, or start a new task.</div>
|
|
<div class="welcome-hint">Press <kbd>${escapeHtml(modKey)}+L</kbd> to focus input</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="thinking-block" id="thinkingBlock">
|
|
<div class="thinking-header">
|
|
<div class="thinking-spinner"></div>
|
|
<span id="thinkingLabel">Thinking...</span>
|
|
</div>
|
|
<div class="thinking-meta" id="thinkingMeta"></div>
|
|
</div>
|
|
|
|
<div class="typing-indicator" id="typingIndicator">
|
|
<div class="typing-dot"></div>
|
|
<div class="typing-dot"></div>
|
|
<div class="typing-dot"></div>
|
|
</div>
|
|
|
|
<div class="input-area">
|
|
<textarea id="chatInput" placeholder="Message OpenClaude..." rows="1"></textarea>
|
|
<button class="send-btn" id="sendBtn" title="Send message">➤</button>
|
|
</div>
|
|
|
|
<!-- Session list overlay -->
|
|
<div class="session-overlay" id="sessionOverlay">
|
|
<div class="session-overlay-header">
|
|
<h2>Session History</h2>
|
|
<button class="header-btn" id="closeSessionsBtn">Close</button>
|
|
</div>
|
|
<input class="session-search" id="sessionSearch" type="text" placeholder="Search sessions..." />
|
|
<div class="session-list" id="sessionList">
|
|
<div class="session-empty">No sessions found</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script nonce="${nonce}">
|
|
(function() {
|
|
const vscode = acquireVsCodeApi();
|
|
|
|
const messagesEl = document.getElementById('messages');
|
|
const welcomeEl = document.getElementById('welcomeScreen');
|
|
const inputEl = document.getElementById('chatInput');
|
|
const sendBtn = document.getElementById('sendBtn');
|
|
const abortBtn = document.getElementById('abortBtn');
|
|
const newChatBtn = document.getElementById('newChatBtn');
|
|
const historyBtn = document.getElementById('historyBtn');
|
|
const statusDot = document.getElementById('statusDot');
|
|
const statusText = document.getElementById('statusText');
|
|
const statusUsage = document.getElementById('statusUsage');
|
|
const typingIndicator = document.getElementById('typingIndicator');
|
|
const sessionOverlay = document.getElementById('sessionOverlay');
|
|
const closeSessionsBtn = document.getElementById('closeSessionsBtn');
|
|
const sessionSearch = document.getElementById('sessionSearch');
|
|
const sessionList = document.getElementById('sessionList');
|
|
|
|
let isStreaming = false;
|
|
let currentAssistantEl = null;
|
|
let currentTextEl = null;
|
|
const toolResultMap = {};
|
|
|
|
/* ── Markdown renderer ── */
|
|
function renderMarkdown(text) {
|
|
if (!text) return '';
|
|
let html = escapeForMd(text);
|
|
|
|
// fenced code blocks
|
|
html = html.replace(/\`\`\`(\\w*?)\\n([\\s\\S]*?)\`\`\`/g, (_, lang, code) => {
|
|
const langLabel = lang || 'text';
|
|
const highlighted = highlightCode(code, langLabel);
|
|
const id = 'cb-' + Math.random().toString(36).slice(2, 8);
|
|
return '<div class="code-wrapper"><div class="code-header">' +
|
|
'<span>' + langLabel + '</span>' +
|
|
'<button class="code-copy-btn" data-copy-id="' + id + '">Copy</button></div>' +
|
|
'<code class="code-block" id="' + id + '">' + highlighted + '</code></div>';
|
|
});
|
|
|
|
// inline code
|
|
html = html.replace(/\`([^\`]+?)\`/g, '<code>$1</code>');
|
|
|
|
// headings
|
|
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
|
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
|
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
|
|
|
|
// blockquotes
|
|
html = html.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>');
|
|
|
|
// hr
|
|
html = html.replace(/^---$/gm, '<hr/>');
|
|
|
|
// bold / italic
|
|
html = html.replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>');
|
|
html = html.replace(/\\*(.+?)\\*/g, '<em>$1</em>');
|
|
|
|
// links
|
|
html = html.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href="$2" title="$2">$1</a>');
|
|
|
|
// unordered lists (simple)
|
|
html = html.replace(/^[\\-\\*] (.+)$/gm, '<li>$1</li>');
|
|
html = html.replace(/((?:<li>.*<\\/li>\\n?)+)/g, '<ul>$1</ul>');
|
|
|
|
// ordered lists
|
|
html = html.replace(/^\\d+\\. (.+)$/gm, '<li>$1</li>');
|
|
|
|
// paragraphs (double newline)
|
|
html = html.replace(/\\n\\n/g, '</p><p>');
|
|
html = '<p>' + html + '</p>';
|
|
html = html.replace(/<p><\\/p>/g, '');
|
|
html = html.replace(/<p>(<h[123]>)/g, '$1');
|
|
html = html.replace(/(<\\/h[123]>)<\\/p>/g, '$1');
|
|
html = html.replace(/<p>(<ul>)/g, '$1');
|
|
html = html.replace(/(<\\/ul>)<\\/p>/g, '$1');
|
|
html = html.replace(/<p>(<blockquote>)/g, '$1');
|
|
html = html.replace(/(<\\/blockquote>)<\\/p>/g, '$1');
|
|
html = html.replace(/<p>(<hr\\/>)/g, '$1');
|
|
html = html.replace(/(<hr\\/>)<\\/p>/g, '$1');
|
|
html = html.replace(/<p>(<div class="code-wrapper">)/g, '$1');
|
|
html = html.replace(/(<\\/div>)<\\/p>/g, '$1');
|
|
|
|
return html;
|
|
}
|
|
|
|
function escapeForMd(text) {
|
|
return text
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
}
|
|
|
|
function highlightCode(code, lang) {
|
|
let result = code;
|
|
const kwPattern = /\\b(const|let|var|function|return|if|else|for|while|class|import|export|from|async|await|try|catch|throw|new|typeof|instanceof|switch|case|break|default|continue|do|in|of|yield|void|delete|true|false|null|undefined|this|super|extends|implements|interface|type|enum|public|private|protected|static|readonly|abstract|def|print|self|elif|except|finally|with|as|lambda|pass|raise|None|True|False)\\b/g;
|
|
const strPattern = /("[^&]*?"|'[^&]*?'|'[^']*?'|"[^"]*?")/g;
|
|
const commentPattern = /(\\/{2}.*$|#.*$)/gm;
|
|
const numPattern = /\\b(\\d+\\.?\\d*)\\b/g;
|
|
|
|
result = result.replace(commentPattern, '<span class="hl-comment">$1</span>');
|
|
result = result.replace(strPattern, '<span class="hl-string">$1</span>');
|
|
result = result.replace(kwPattern, '<span class="hl-keyword">$1</span>');
|
|
result = result.replace(numPattern, '<span class="hl-number">$1</span>');
|
|
|
|
return result;
|
|
}
|
|
|
|
/* ── DOM helpers ── */
|
|
function scrollToBottom() {
|
|
requestAnimationFrame(() => {
|
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
});
|
|
}
|
|
|
|
function hideWelcome() {
|
|
if (welcomeEl) welcomeEl.style.display = 'none';
|
|
}
|
|
|
|
function showWelcome() {
|
|
if (welcomeEl) welcomeEl.style.display = 'flex';
|
|
}
|
|
|
|
function setStreaming(val, label) {
|
|
isStreaming = val;
|
|
abortBtn.style.display = val ? 'block' : 'none';
|
|
sendBtn.disabled = val;
|
|
typingIndicator.classList.toggle('visible', val);
|
|
statusDot.className = 'status-dot ' + (val ? 'streaming' : 'connected');
|
|
statusText.textContent = label || (val ? 'Generating...' : 'Ready');
|
|
}
|
|
|
|
function setStatusLabel(label) {
|
|
statusText.textContent = label;
|
|
}
|
|
|
|
function appendUserMessage(text) {
|
|
hideWelcome();
|
|
const el = document.createElement('div');
|
|
el.className = 'msg-user';
|
|
el.textContent = text;
|
|
messagesEl.appendChild(el);
|
|
scrollToBottom();
|
|
}
|
|
|
|
function getOrCreateAssistantEl() {
|
|
if (!currentAssistantEl) {
|
|
hideWelcome();
|
|
currentAssistantEl = document.createElement('div');
|
|
currentAssistantEl.className = 'msg-assistant';
|
|
currentTextEl = document.createElement('div');
|
|
currentTextEl.className = 'md-content';
|
|
currentAssistantEl.appendChild(currentTextEl);
|
|
messagesEl.appendChild(currentAssistantEl);
|
|
}
|
|
return { container: currentAssistantEl, textEl: currentTextEl };
|
|
}
|
|
|
|
function finalizeAssistant() {
|
|
// Hide the text div if it's empty (model went straight to tool use)
|
|
if (currentTextEl && !currentTextEl.textContent.trim()) {
|
|
currentTextEl.style.display = 'none';
|
|
}
|
|
// Remove the entire bubble if it has no visible content at all
|
|
if (currentAssistantEl) {
|
|
const hasText = currentTextEl && currentTextEl.textContent.trim();
|
|
const hasToolCards = currentAssistantEl.querySelector('.tool-card');
|
|
if (!hasText && !hasToolCards) {
|
|
currentAssistantEl.remove();
|
|
}
|
|
}
|
|
currentAssistantEl = null;
|
|
currentTextEl = null;
|
|
}
|
|
|
|
function appendToolCard(toolUse) {
|
|
const { container } = getOrCreateAssistantEl();
|
|
const card = document.createElement('div');
|
|
card.className = 'tool-card expanded';
|
|
card.dataset.toolId = toolUse.id || '';
|
|
const statusClass = toolUse.status || 'running';
|
|
const statusLabel = statusClass === 'running' ? 'Running...'
|
|
: statusClass === 'error' ? 'Error' : 'Done';
|
|
|
|
var inputSummary = '';
|
|
if (toolUse.input && typeof toolUse.input === 'object') {
|
|
if (toolUse.input.file_path || toolUse.input.path) {
|
|
inputSummary = (toolUse.input.file_path || toolUse.input.path);
|
|
}
|
|
if (toolUse.input.command) {
|
|
inputSummary = toolUse.input.command;
|
|
}
|
|
}
|
|
if (!inputSummary) inputSummary = toolUse.inputPreview || '';
|
|
|
|
var inputDetail = '';
|
|
if (toolUse.input && typeof toolUse.input === 'object') {
|
|
if (toolUse.input.new_string || toolUse.input.content) {
|
|
var content = toolUse.input.new_string || toolUse.input.content || '';
|
|
if (content.length > 500) content = content.slice(0, 500) + '... (truncated)';
|
|
inputDetail = '<div class="tool-input-label">Changes</div>' +
|
|
'<div class="tool-input-content">' + escapeForMd(content) + '</div>';
|
|
}
|
|
if (toolUse.input.old_string && toolUse.input.new_string) {
|
|
var oldStr = toolUse.input.old_string;
|
|
var newStr = toolUse.input.new_string;
|
|
if (oldStr.length > 300) oldStr = oldStr.slice(0, 300) + '...';
|
|
if (newStr.length > 300) newStr = newStr.slice(0, 300) + '...';
|
|
inputDetail = '<div class="tool-input-label">Replace</div>' +
|
|
'<div class="tool-input-content tool-diff-old">' + escapeForMd(oldStr) + '</div>' +
|
|
'<div class="tool-input-label">With</div>' +
|
|
'<div class="tool-input-content tool-diff-new">' + escapeForMd(newStr) + '</div>';
|
|
}
|
|
}
|
|
|
|
var isFileTool = inputSummary && !toolUse.input?.command;
|
|
var fileLink = isFileTool
|
|
? '<a class="file-link" data-filepath="' + escapeForMd(inputSummary) + '" title="Open in editor">' + escapeForMd(inputSummary.split(/[\\/]/).pop() || inputSummary) + '</a>'
|
|
: (inputSummary ? escapeForMd(inputSummary.split(/[\\/]/).pop() || inputSummary) : '');
|
|
var pathDisplay = isFileTool
|
|
? '<div class="tool-input-label">Path</div><div class="tool-input-content"><a class="file-link" data-filepath="' + escapeForMd(inputSummary) + '" title="Open in editor">' + escapeForMd(inputSummary) + '</a></div>'
|
|
: (inputSummary ? '<div class="tool-input-label">' + (toolUse.input?.command ? 'Command' : 'Path') + '</div><div class="tool-input-content">' + escapeForMd(inputSummary) + '</div>' : '');
|
|
|
|
card.innerHTML =
|
|
'<div class="tool-header">' +
|
|
'<span class="tool-icon">' + (toolUse.icon || '') + '</span>' +
|
|
'<span class="tool-name">' + escapeForMd(toolUse.displayName || toolUse.name || 'Tool') +
|
|
(fileLink ? ' <span class="tool-path">' + fileLink + '</span>' : '') +
|
|
'</span>' +
|
|
'<span class="tool-status ' + statusClass + '">' + statusLabel + '</span>' +
|
|
'<span class="tool-chevron">▶</span>' +
|
|
'</div>' +
|
|
'<div class="tool-body">' +
|
|
pathDisplay +
|
|
inputDetail +
|
|
'<div class="tool-output-label">Output</div>' +
|
|
'<div class="tool-output-content" data-tool-output="' + (toolUse.id || '') + '">Running...</div>' +
|
|
'</div>';
|
|
card.querySelector('.tool-header').addEventListener('click', () => {
|
|
card.classList.toggle('expanded');
|
|
});
|
|
container.appendChild(card);
|
|
scrollToBottom();
|
|
return card;
|
|
}
|
|
|
|
function updateToolResult(toolUseId, content, isError) {
|
|
const outputEl = document.querySelector('[data-tool-output="' + toolUseId + '"]');
|
|
if (outputEl) {
|
|
outputEl.textContent = content || '(done)';
|
|
if (isError) outputEl.classList.add('error');
|
|
}
|
|
const card = document.querySelector('[data-tool-id="' + toolUseId + '"]');
|
|
if (card) {
|
|
const statusEl = card.querySelector('.tool-status');
|
|
if (statusEl) {
|
|
statusEl.className = 'tool-status ' + (isError ? 'error' : 'complete');
|
|
statusEl.textContent = isError ? 'Error' : 'Done';
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateToolProgress(toolUseId, content) {
|
|
const outputEl = document.querySelector('[data-tool-output="' + toolUseId + '"]');
|
|
if (outputEl && (outputEl.textContent === 'Waiting...' || outputEl.textContent === 'Running...')) {
|
|
outputEl.textContent = content || '';
|
|
}
|
|
}
|
|
|
|
function updateToolInput(toolUseId, input, toolName) {
|
|
const card = document.querySelector('[data-tool-id="' + toolUseId + '"]');
|
|
if (!card) return;
|
|
const body = card.querySelector('.tool-body');
|
|
if (!body) return;
|
|
|
|
if (!input || typeof input !== 'object') return;
|
|
|
|
// Update the header with clickable file path
|
|
const nameEl = card.querySelector('.tool-name');
|
|
if (nameEl && (input.file_path || input.path)) {
|
|
const fp = input.file_path || input.path;
|
|
const shortName = fp.split(/[\\/]/).pop() || fp;
|
|
if (!nameEl.querySelector('.tool-path')) {
|
|
nameEl.insertAdjacentHTML('beforeend', ' <span class="tool-path"><a class="file-link" data-filepath="' + escapeForMd(fp) + '" title="Open in editor">' + escapeForMd(shortName) + '</a></span>');
|
|
}
|
|
}
|
|
|
|
// Update path display
|
|
var pathHtml = '';
|
|
if (input.file_path || input.path) {
|
|
var fp = input.file_path || input.path;
|
|
pathHtml = '<div class="tool-input-label">Path</div><div class="tool-input-content">' +
|
|
'<a class="file-link" data-filepath="' + escapeForMd(fp) + '" title="Open in editor">' + escapeForMd(fp) + '</a></div>';
|
|
}
|
|
if (input.command) {
|
|
pathHtml = '<div class="tool-input-label">Command</div><div class="tool-input-content">' +
|
|
escapeForMd(input.command) + '</div>';
|
|
}
|
|
|
|
// Build diff display for edit operations
|
|
var diffHtml = '';
|
|
if (input.old_string && input.new_string) {
|
|
var oldStr = input.old_string;
|
|
var newStr = input.new_string;
|
|
if (oldStr.length > 500) oldStr = oldStr.slice(0, 500) + '... (truncated)';
|
|
if (newStr.length > 500) newStr = newStr.slice(0, 500) + '... (truncated)';
|
|
diffHtml = '<div class="tool-input-label">Replace</div>' +
|
|
'<div class="tool-input-content tool-diff-old">' + escapeForMd(oldStr) + '</div>' +
|
|
'<div class="tool-input-label">With</div>' +
|
|
'<div class="tool-input-content tool-diff-new">' + escapeForMd(newStr) + '</div>';
|
|
} else if (input.content || input.new_string) {
|
|
var content = input.content || input.new_string || '';
|
|
if (content.length > 800) content = content.slice(0, 800) + '... (truncated)';
|
|
diffHtml = '<div class="tool-input-label">Content</div>' +
|
|
'<div class="tool-input-content tool-diff-new">' + escapeForMd(content) + '</div>';
|
|
}
|
|
|
|
// Keep the output element
|
|
const outputEl = body.querySelector('[data-tool-output]');
|
|
const outputHtml = outputEl ? outputEl.outerHTML : '';
|
|
const outputLabel = '<div class="tool-output-label">Output</div>';
|
|
|
|
body.innerHTML = pathHtml + diffHtml + outputLabel + outputHtml;
|
|
card.classList.add('expanded');
|
|
scrollToBottom();
|
|
}
|
|
|
|
function appendPermissionCard(perm) {
|
|
hideWelcome();
|
|
const el = document.createElement('div');
|
|
el.className = 'perm-card';
|
|
el.dataset.requestId = perm.requestId || '';
|
|
el.innerHTML =
|
|
'<div class="perm-title">Permission Required: ' + escapeForMd(perm.displayName || perm.toolName || 'Tool') + '</div>' +
|
|
(perm.description ? '<div class="perm-desc">' + escapeForMd(perm.description) + '</div>' : '') +
|
|
(perm.inputPreview ? '<div class="perm-input">' + escapeForMd(perm.inputPreview) + '</div>' : '') +
|
|
'<div class="perm-actions">' +
|
|
'<button class="perm-btn allow" data-action="allow">Allow</button>' +
|
|
'<button class="perm-btn deny" data-action="deny">Deny</button>' +
|
|
'<button class="perm-btn allow-session" data-action="allow-session">Allow for session</button>' +
|
|
'</div>';
|
|
el.querySelectorAll('.perm-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const action = btn.dataset.action;
|
|
vscode.postMessage({
|
|
type: 'permission_response',
|
|
requestId: perm.requestId,
|
|
toolUseId: perm.toolUseId || null,
|
|
action: action,
|
|
});
|
|
el.querySelectorAll('.perm-btn').forEach(b => { b.disabled = true; b.style.opacity = '0.4'; });
|
|
btn.style.opacity = '1';
|
|
});
|
|
});
|
|
messagesEl.appendChild(el);
|
|
scrollToBottom();
|
|
}
|
|
|
|
function appendStatusMessage(text) {
|
|
const el = document.createElement('div');
|
|
el.className = 'msg-status';
|
|
el.textContent = text;
|
|
messagesEl.appendChild(el);
|
|
scrollToBottom();
|
|
}
|
|
|
|
function appendRateLimitMessage(text) {
|
|
const el = document.createElement('div');
|
|
el.className = 'msg-rate-limit';
|
|
el.textContent = text;
|
|
messagesEl.appendChild(el);
|
|
scrollToBottom();
|
|
}
|
|
|
|
/* ── Thinking block ── */
|
|
const thinkingBlock = document.getElementById('thinkingBlock');
|
|
const thinkingLabel = document.getElementById('thinkingLabel');
|
|
const thinkingMeta = document.getElementById('thinkingMeta');
|
|
|
|
function showThinkingBlock() {
|
|
thinkingBlock.classList.add('visible');
|
|
thinkingLabel.textContent = 'Thinking...';
|
|
thinkingMeta.textContent = '';
|
|
setStatusLabel('Thinking...');
|
|
scrollToBottom();
|
|
}
|
|
|
|
function updateThinkingBlock(tokens, elapsed) {
|
|
const elapsedStr = elapsed >= 60
|
|
? Math.floor(elapsed / 60) + 'm ' + (elapsed % 60) + 's'
|
|
: elapsed + 's';
|
|
thinkingLabel.textContent = 'Thinking...';
|
|
thinkingMeta.textContent = elapsedStr + ' · ~' + tokens + ' tokens';
|
|
setStatusLabel('Thinking... (' + elapsedStr + ')');
|
|
}
|
|
|
|
function hideThinkingBlock() {
|
|
thinkingBlock.classList.remove('visible');
|
|
setStatusLabel('Generating...');
|
|
}
|
|
|
|
/* ── Session list ── */
|
|
function renderSessionList(sessions) {
|
|
if (!sessions || sessions.length === 0) {
|
|
sessionList.innerHTML = '<div class="session-empty">No sessions found</div>';
|
|
return;
|
|
}
|
|
const groups = groupByDate(sessions);
|
|
let html = '';
|
|
for (const [label, items] of groups) {
|
|
html += '<div class="session-group-label">' + escapeForMd(label) + '</div>';
|
|
for (const s of items) {
|
|
html += '<div class="session-item" data-session-id="' + (s.id || '') + '">' +
|
|
'<div class="session-item-title">' + escapeForMd(s.title || s.id || 'Untitled') + '</div>' +
|
|
'<div class="session-item-preview">' + escapeForMd(s.preview || '') + '</div>' +
|
|
'<div class="session-item-time">' + escapeForMd(s.timeLabel || '') + '</div>' +
|
|
'</div>';
|
|
}
|
|
}
|
|
sessionList.innerHTML = html;
|
|
sessionList.querySelectorAll('.session-item').forEach(el => {
|
|
el.addEventListener('click', () => {
|
|
vscode.postMessage({ type: 'resume_session', sessionId: el.dataset.sessionId });
|
|
sessionOverlay.classList.remove('visible');
|
|
});
|
|
});
|
|
}
|
|
|
|
function groupByDate(sessions) {
|
|
const now = new Date();
|
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
|
const yesterday = today - 86400000;
|
|
const weekAgo = today - 604800000;
|
|
const groups = new Map();
|
|
for (const s of sessions) {
|
|
const t = s.timestamp || 0;
|
|
let label;
|
|
if (t >= today) label = 'Today';
|
|
else if (t >= yesterday) label = 'Yesterday';
|
|
else if (t >= weekAgo) label = 'This Week';
|
|
else label = 'Older';
|
|
if (!groups.has(label)) groups.set(label, []);
|
|
groups.get(label).push(s);
|
|
}
|
|
return groups;
|
|
}
|
|
|
|
/* ── Input handling ── */
|
|
function sendMessage() {
|
|
const text = inputEl.value.trim();
|
|
if (!text || isStreaming) return;
|
|
appendUserMessage(text);
|
|
vscode.postMessage({ type: 'send_message', text });
|
|
inputEl.value = '';
|
|
autoResizeInput();
|
|
setStreaming(true);
|
|
}
|
|
|
|
function autoResizeInput() {
|
|
inputEl.style.height = 'auto';
|
|
inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + 'px';
|
|
}
|
|
|
|
inputEl.addEventListener('input', autoResizeInput);
|
|
inputEl.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
sendMessage();
|
|
}
|
|
});
|
|
sendBtn.addEventListener('click', sendMessage);
|
|
abortBtn.addEventListener('click', () => vscode.postMessage({ type: 'abort' }));
|
|
newChatBtn.addEventListener('click', () => vscode.postMessage({ type: 'new_session' }));
|
|
historyBtn.addEventListener('click', () => {
|
|
sessionOverlay.classList.toggle('visible');
|
|
if (sessionOverlay.classList.contains('visible')) {
|
|
vscode.postMessage({ type: 'request_sessions' });
|
|
sessionSearch.focus();
|
|
}
|
|
});
|
|
closeSessionsBtn.addEventListener('click', () => sessionOverlay.classList.remove('visible'));
|
|
sessionSearch.addEventListener('input', () => {
|
|
const q = sessionSearch.value.toLowerCase();
|
|
sessionList.querySelectorAll('.session-item').forEach(el => {
|
|
const text = el.textContent.toLowerCase();
|
|
el.style.display = text.includes(q) ? '' : 'none';
|
|
});
|
|
});
|
|
|
|
// Copy code handler (event delegation)
|
|
document.addEventListener('click', (e) => {
|
|
const copyBtn = e.target.closest('.code-copy-btn');
|
|
if (copyBtn) {
|
|
const id = copyBtn.dataset.copyId;
|
|
const codeEl = document.getElementById(id);
|
|
if (codeEl) {
|
|
const text = codeEl.textContent;
|
|
vscode.postMessage({ type: 'copy_code', text });
|
|
copyBtn.textContent = 'Copied!';
|
|
setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const fileLink = e.target.closest('.file-link');
|
|
if (fileLink) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const filepath = fileLink.dataset.filepath;
|
|
if (filepath) {
|
|
vscode.postMessage({ type: 'open_file', path: filepath });
|
|
}
|
|
return;
|
|
}
|
|
});
|
|
|
|
/* ── Message handling from extension ── */
|
|
window.addEventListener('message', (event) => {
|
|
const msg = event.data;
|
|
if (!msg) return;
|
|
|
|
switch (msg.type) {
|
|
case 'stream_start':
|
|
setStreaming(true, 'Generating...');
|
|
getOrCreateAssistantEl();
|
|
break;
|
|
|
|
case 'stream_delta': {
|
|
setStatusLabel('Generating...');
|
|
const { textEl } = getOrCreateAssistantEl();
|
|
textEl.innerHTML = renderMarkdown(msg.text || '');
|
|
scrollToBottom();
|
|
break;
|
|
}
|
|
|
|
case 'stream_end':
|
|
if (msg.text) {
|
|
const { textEl } = getOrCreateAssistantEl();
|
|
textEl.innerHTML = renderMarkdown(msg.text);
|
|
}
|
|
finalizeAssistant();
|
|
if (msg.usage) {
|
|
const u = msg.usage;
|
|
statusUsage.textContent = (u.input_tokens || 0) + ' in / ' + (u.output_tokens || 0) + ' out';
|
|
}
|
|
if (msg.final) {
|
|
setStreaming(false);
|
|
}
|
|
scrollToBottom();
|
|
break;
|
|
|
|
case 'tool_use':
|
|
appendToolCard(msg.toolUse);
|
|
setStatusLabel('Running: ' + (msg.toolUse.displayName || msg.toolUse.name || 'tool') + '...');
|
|
break;
|
|
|
|
case 'tool_result':
|
|
updateToolResult(msg.toolUseId, msg.content, msg.isError);
|
|
break;
|
|
|
|
case 'tool_input_ready':
|
|
updateToolInput(msg.toolUseId, msg.input, msg.name);
|
|
break;
|
|
|
|
case 'tool_progress':
|
|
updateToolProgress(msg.toolUseId, msg.content);
|
|
break;
|
|
|
|
case 'permission_request':
|
|
appendPermissionCard(msg);
|
|
break;
|
|
|
|
case 'status':
|
|
setStatusLabel(msg.content || 'Working...');
|
|
break;
|
|
|
|
case 'rate_limit':
|
|
appendRateLimitMessage(msg.message || 'Rate limited');
|
|
break;
|
|
|
|
case 'thinking_start':
|
|
showThinkingBlock();
|
|
break;
|
|
|
|
case 'thinking_delta':
|
|
updateThinkingBlock(msg.tokens || 0, msg.elapsed || 0);
|
|
break;
|
|
|
|
case 'thinking_end':
|
|
hideThinkingBlock();
|
|
break;
|
|
|
|
case 'system_info':
|
|
if (msg.model) {
|
|
statusUsage.textContent = msg.model;
|
|
}
|
|
break;
|
|
|
|
case 'error':
|
|
setStreaming(false);
|
|
finalizeAssistant();
|
|
statusDot.className = 'status-dot error';
|
|
statusText.textContent = 'Error: ' + (msg.message || 'Unknown error');
|
|
break;
|
|
|
|
case 'session_list':
|
|
renderSessionList(msg.sessions);
|
|
break;
|
|
|
|
case 'session_cleared':
|
|
messagesEl.innerHTML = '';
|
|
if (welcomeEl) {
|
|
messagesEl.appendChild(welcomeEl);
|
|
showWelcome();
|
|
}
|
|
currentAssistantEl = null;
|
|
currentTextEl = null;
|
|
statusUsage.textContent = '';
|
|
statusDot.className = 'status-dot connected';
|
|
statusText.textContent = 'Ready';
|
|
break;
|
|
|
|
case 'restore_messages':
|
|
hideWelcome();
|
|
if (msg.messages) {
|
|
for (const m of msg.messages) {
|
|
if (m.role === 'user') {
|
|
appendUserMessage(m.text || '');
|
|
} else if (m.role === 'assistant') {
|
|
const { textEl } = getOrCreateAssistantEl();
|
|
textEl.innerHTML = renderMarkdown(m.text || '');
|
|
if (m.toolUses && m.toolUses.length > 0) {
|
|
for (const tu of m.toolUses) {
|
|
var displayName = tu.name || 'Tool';
|
|
var icon = '';
|
|
var inputPreview = '';
|
|
if (tu.input && typeof tu.input === 'object') {
|
|
inputPreview = tu.input.file_path || tu.input.path || tu.input.command || '';
|
|
}
|
|
var card = appendToolCard({
|
|
id: tu.id,
|
|
name: tu.name,
|
|
displayName: displayName,
|
|
icon: icon,
|
|
inputPreview: inputPreview,
|
|
input: tu.input,
|
|
status: tu.status || 'complete',
|
|
});
|
|
if (tu.input) {
|
|
updateToolInput(String(tu.id), tu.input, tu.name);
|
|
}
|
|
if (tu.result !== undefined && tu.result !== null) {
|
|
updateToolResult(String(tu.id), tu.result, tu.isError || false);
|
|
} else {
|
|
updateToolResult(String(tu.id), '(done)', false);
|
|
}
|
|
}
|
|
}
|
|
finalizeAssistant();
|
|
}
|
|
}
|
|
}
|
|
scrollToBottom();
|
|
break;
|
|
|
|
case 'connected':
|
|
setStreaming(false);
|
|
statusDot.className = 'status-dot connected';
|
|
statusText.textContent = msg.message || 'Connected';
|
|
break;
|
|
|
|
default:
|
|
break;
|
|
}
|
|
});
|
|
|
|
// Focus input on Ctrl/Cmd+L
|
|
document.addEventListener('keydown', (e) => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
|
|
e.preventDefault();
|
|
inputEl.focus();
|
|
}
|
|
});
|
|
|
|
// Restore state
|
|
const prevState = vscode.getState();
|
|
if (prevState && prevState.hasMessages) {
|
|
vscode.postMessage({ type: 'restore_request' });
|
|
}
|
|
|
|
// Notify ready
|
|
vscode.postMessage({ type: 'webview_ready' });
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
module.exports = { renderChatHtml };
|