返回 CodeWhale
status.ts
根目录 / extensions / vscode / src / status.ts
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, "&amp;")
183 .replace(/</g, "&lt;")
184 .replace(/>/g, "&gt;")
185 .replace(/"/g, "&quot;");
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
196 lines TYPESCRIPT