| 1 | import * as vscode from "vscode"; |
| 2 | import type { RuntimeState, SnapshotEntry, ThreadSummary } from "./runtime"; |
| 3 | |
| 4 | export class RuntimeStatusView implements vscode.WebviewViewProvider { |
| 5 | public static readonly viewType = "codewhale.runtimeStatus"; |
| 6 | |
| 7 | private view?: vscode.WebviewView; |
| 8 | private state: RuntimeState = { |
| 9 | kind: "offline", |
| 10 | baseUrl: "http://127.0.0.1:7878", |
| 11 | detail: "Runtime has not been checked yet.", |
| 12 | }; |
| 13 | private threads: ThreadSummary[] = []; |
| 14 | private threadsDetail = "Connect to the runtime to load recent threads."; |
| 15 | private snapshots: SnapshotEntry[] = []; |
| 16 | private snapshotsDetail = "Connect to the runtime to load restore points."; |
| 17 | |
| 18 | resolveWebviewView(view: vscode.WebviewView): void { |
| 19 | this.view = view; |
| 20 | view.webview.options = { enableScripts: true }; |
| 21 | view.webview.onDidReceiveMessage((message: { command?: string }) => { |
| 22 | if (message.command === "check") { |
| 23 | void vscode.commands.executeCommand("codewhale.checkRuntime"); |
| 24 | } else if (message.command === "start") { |
| 25 | void vscode.commands.executeCommand("codewhale.startRuntime"); |
| 26 | } else if (message.command === "terminal") { |
| 27 | void vscode.commands.executeCommand("codewhale.openTerminal"); |
| 28 | } else if (message.command === "threads") { |
| 29 | void vscode.commands.executeCommand("codewhale.refreshAgentView"); |
| 30 | } else if (message.command === "snapshots") { |
| 31 | void vscode.commands.executeCommand("codewhale.refreshSnapshots"); |
| 32 | } |
| 33 | }); |
| 34 | this.render(); |
| 35 | } |
| 36 | |
| 37 | update(state: RuntimeState): void { |
| 38 | this.state = state; |
| 39 | this.render(); |
| 40 | } |
| 41 | |
| 42 | updateThreads(threads: ThreadSummary[], detail: string): void { |
| 43 | this.threads = threads; |
| 44 | this.threadsDetail = detail; |
| 45 | this.render(); |
| 46 | } |
| 47 | |
| 48 | updateSnapshots(snapshots: SnapshotEntry[], detail: string): void { |
| 49 | this.snapshots = snapshots; |
| 50 | this.snapshotsDetail = detail; |
| 51 | this.render(); |
| 52 | } |
| 53 | |
| 54 | private render(): void { |
| 55 | if (!this.view) { |
| 56 | return; |
| 57 | } |
| 58 | |
| 59 | const badge = labelFor(this.state.kind); |
| 60 | const nonce = makeNonce(); |
| 61 | const threadsHtml = |
| 62 | this.threads.length > 0 |
| 63 | ? this.threads.map((thread) => renderThread(thread)).join("") |
| 64 | : `<p class="detail">${escapeHtml(this.threadsDetail)}</p>`; |
| 65 | const snapshotsHtml = |
| 66 | this.snapshots.length > 0 |
| 67 | ? this.snapshots.map((snapshot) => renderSnapshot(snapshot)).join("") |
| 68 | : `<p class="detail">${escapeHtml(this.snapshotsDetail)}</p>`; |
| 69 | this.view.webview.html = `<!doctype html> |
| 70 | <html lang="en"> |
| 71 | <head> |
| 72 | <meta charset="UTF-8"> |
| 73 | <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';"> |
| 74 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| 75 | <style> |
| 76 | body { padding: 14px; color: var(--vscode-foreground); font-family: var(--vscode-font-family); } |
| 77 | .status { margin-bottom: 12px; font-weight: 600; } |
| 78 | .detail { margin: 0 0 14px; color: var(--vscode-descriptionForeground); line-height: 1.45; } |
| 79 | .section-title { margin: 18px 0 8px; font-size: 11px; font-weight: 700; letter-spacing: 0; text-transform: uppercase; color: var(--vscode-descriptionForeground); } |
| 80 | .thread { padding: 8px 0; border-top: 1px solid var(--vscode-sideBarSectionHeader-border, var(--vscode-panel-border)); } |
| 81 | .snapshot { padding: 8px 0; border-top: 1px solid var(--vscode-sideBarSectionHeader-border, var(--vscode-panel-border)); } |
| 82 | .thread-title, .snapshot-title { margin-bottom: 4px; font-weight: 600; overflow-wrap: anywhere; } |
| 83 | .thread-preview { margin-bottom: 5px; color: var(--vscode-descriptionForeground); line-height: 1.35; overflow-wrap: anywhere; } |
| 84 | .thread-meta { color: var(--vscode-descriptionForeground); font-size: 11px; overflow-wrap: anywhere; } |
| 85 | code { color: var(--vscode-textLink-foreground); } |
| 86 | button { width: 100%; margin: 4px 0; } |
| 87 | </style> |
| 88 | </head> |
| 89 | <body> |
| 90 | <div class="status">${escapeHtml(badge)}</div> |
| 91 | <p class="detail">${escapeHtml(this.state.detail)}</p> |
| 92 | <p class="detail"><code>${escapeHtml(this.state.baseUrl)}</code></p> |
| 93 | <button data-command="check">Check Runtime</button> |
| 94 | <button data-command="threads">Refresh Threads</button> |
| 95 | <button data-command="snapshots">Refresh Restore Points</button> |
| 96 | <button data-command="start">Start Local Runtime</button> |
| 97 | <button data-command="terminal">Open CodeWhale Terminal</button> |
| 98 | <div class="section-title">Agent View</div> |
| 99 | ${threadsHtml} |
| 100 | <div class="section-title">Restore Points</div> |
| 101 | ${snapshotsHtml} |
| 102 | <script nonce="${nonce}"> |
| 103 | const vscode = acquireVsCodeApi(); |
| 104 | for (const button of document.querySelectorAll("button[data-command]")) { |
| 105 | button.addEventListener("click", () => vscode.postMessage({ command: button.dataset.command })); |
| 106 | } |
| 107 | </script> |
| 108 | </body> |
| 109 | </html>`; |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | function renderSnapshot(snapshot: SnapshotEntry): string { |
| 114 | return `<div class="snapshot"> |
| 115 | <div class="snapshot-title">${escapeHtml(snapshot.label)}</div> |
| 116 | <div class="thread-meta">${escapeHtml(`${snapshot.id} · ${formatUnixTimestamp(snapshot.timestamp)}`)}</div> |
| 117 | </div>`; |
| 118 | } |
| 119 | |
| 120 | function renderThread(thread: ThreadSummary): string { |
| 121 | const status = thread.latestTurnStatus ? ` · ${thread.latestTurnStatus}` : ""; |
| 122 | const archived = thread.archived ? " · archived" : ""; |
| 123 | const git = renderGitMetadata(thread); |
| 124 | const workspace = thread.workspace ? ` · ${thread.workspace}` : ""; |
| 125 | const updated = thread.updatedAt ? ` · ${formatTimestamp(thread.updatedAt)}` : ""; |
| 126 | return `<div class="thread"> |
| 127 | <div class="thread-title">${escapeHtml(thread.title)}</div> |
| 128 | <div class="thread-preview">${escapeHtml(thread.preview || "No recent message.")}</div> |
| 129 | <div class="thread-meta">${escapeHtml(`${thread.mode} · ${thread.model}${status}${git}${archived}${updated}${workspace}`)}</div> |
| 130 | </div>`; |
| 131 | } |
| 132 | |
| 133 | function renderGitMetadata(thread: ThreadSummary): string { |
| 134 | if (!thread.branch && !thread.head && !thread.dirty) { |
| 135 | return ""; |
| 136 | } |
| 137 | |
| 138 | const parts: string[] = []; |
| 139 | if (thread.branch) { |
| 140 | parts.push(`branch ${thread.branch}`); |
| 141 | } |
| 142 | if (thread.head) { |
| 143 | parts.push(`@ ${thread.head}`); |
| 144 | } |
| 145 | if (thread.dirty) { |
| 146 | parts.push("dirty"); |
| 147 | } |
| 148 | return ` · ${parts.join(" ")}`; |
| 149 | } |
| 150 | |
| 151 | function labelFor(kind: RuntimeState["kind"]): string { |
| 152 | switch (kind) { |
| 153 | case "connected": |
| 154 | return "Connected"; |
| 155 | case "auth-required": |
| 156 | return "Token Required"; |
| 157 | case "error": |
| 158 | return "Runtime Error"; |
| 159 | case "offline": |
| 160 | return "Offline"; |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | function formatTimestamp(value: string): string { |
| 165 | const date = new Date(value); |
| 166 | if (Number.isNaN(date.getTime())) { |
| 167 | return value; |
| 168 | } |
| 169 | return date.toLocaleString(); |
| 170 | } |
| 171 | |
| 172 | function formatUnixTimestamp(value: number): string { |
| 173 | const date = new Date(value * 1000); |
| 174 | if (Number.isNaN(date.getTime())) { |
| 175 | return String(value); |
| 176 | } |
| 177 | return date.toLocaleString(); |
| 178 | } |
| 179 | |
| 180 | function escapeHtml(value: string): string { |
| 181 | return value |
| 182 | .replace(/&/g, "&") |
| 183 | .replace(/</g, "<") |
| 184 | .replace(/>/g, ">") |
| 185 | .replace(/"/g, """); |
| 186 | } |
| 187 | |
| 188 | function makeNonce(): string { |
| 189 | const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; |
| 190 | let nonce = ""; |
| 191 | for (let index = 0; index < 32; index += 1) { |
| 192 | nonce += alphabet.charAt(Math.floor(Math.random() * alphabet.length)); |
| 193 | } |
| 194 | return nonce; |
| 195 | } |
| 196 |