feat(vscode): add read-only agent view preview

This commit is contained in:
Hunter B
2026-06-05 19:35:08 -07:00
parent 90234c1729
commit ab299865dd
13 changed files with 273 additions and 23 deletions
+30
View File
@@ -1,6 +1,7 @@
import * as vscode from "vscode";
import {
checkRuntime,
listThreadSummaries,
openCodeWhaleTerminal,
readRuntimeConfig,
runtimeBaseUrl,
@@ -19,6 +20,13 @@ export function activate(context: vscode.ExtensionContext): void {
vscode.window.registerWebviewViewProvider(RuntimeStatusView.viewType, statusView),
);
const refreshAgentView = async (): Promise<void> => {
const config = readRuntimeConfig();
const threads = await listThreadSummaries(config);
statusView.updateThreads(threads, "Showing recent runtime threads.");
output.appendLine(`Loaded ${threads.length} runtime thread summaries.`);
};
const updateStatus = (text: string, tooltip: string): void => {
status.text = text;
status.tooltip = tooltip;
@@ -56,13 +64,22 @@ export function activate(context: vscode.ExtensionContext): void {
switch (state.kind) {
case "connected":
updateStatus("$(check) CodeWhale", state.detail);
try {
await refreshAgentView();
} catch (error: unknown) {
const detail = error instanceof Error ? error.message : String(error);
statusView.updateThreads([], detail);
output.appendLine(`Runtime thread summaries unavailable: ${detail}`);
}
break;
case "auth-required":
updateStatus("$(lock) CodeWhale", state.detail);
statusView.updateThreads([], "Runtime token is required before threads can load.");
break;
case "offline":
case "error":
updateStatus("$(warning) CodeWhale", state.detail);
statusView.updateThreads([], "Connect to the runtime to load recent threads.");
break;
}
@@ -71,6 +88,19 @@ export function activate(context: vscode.ExtensionContext): void {
}),
);
context.subscriptions.push(
vscode.commands.registerCommand("codewhale.refreshAgentView", async () => {
try {
await refreshAgentView();
} catch (error: unknown) {
const detail = error instanceof Error ? error.message : String(error);
statusView.updateThreads([], detail);
output.appendLine(`Runtime thread summaries unavailable: ${detail}`);
void vscode.window.showWarningMessage(detail);
}
}),
);
context.subscriptions.push(
vscode.commands.registerCommand("codewhale.openRuntimeDocs", () => {
void vscode.env.openExternal(
+65
View File
@@ -10,6 +10,17 @@ export interface RuntimeState {
version?: string;
}
export interface ThreadSummary {
id: string;
title: string;
preview: string;
model: string;
mode: string;
archived: boolean;
updatedAt: string;
latestTurnStatus?: string;
}
export interface RuntimeConfig {
commandPath: string;
host: string;
@@ -66,6 +77,26 @@ export async function checkRuntime(config: RuntimeConfig): Promise<RuntimeState>
};
}
export async function listThreadSummaries(
config: RuntimeConfig,
limit = 8,
): Promise<ThreadSummary[]> {
const baseUrl = runtimeBaseUrl(config);
const response = await requestJson(
`${baseUrl}/v1/threads/summary?limit=${encodeURIComponent(String(limit))}`,
config.token,
);
if (response.statusCode === 401) {
throw new Error("Thread summaries require the runtime bearer token.");
}
if (response.statusCode !== 200) {
throw new Error(`Thread summary returned HTTP ${response.statusCode}.`);
}
return readThreadSummaries(response.body);
}
export function startRuntimeTerminal(config: RuntimeConfig): vscode.Terminal {
const terminal = vscode.window.createTerminal("CodeWhale Runtime");
const args = [
@@ -145,6 +176,40 @@ function readVersion(value: unknown): string | undefined {
return typeof version === "string" ? version : undefined;
}
function readThreadSummaries(value: unknown): ThreadSummary[] {
if (!Array.isArray(value)) {
return [];
}
return value.flatMap((item) => {
if (!item || typeof item !== "object") {
return [];
}
const record = item as Record<string, unknown>;
const id = readString(record.id);
if (!id) {
return [];
}
return [
{
id,
title: readString(record.title) ?? "New Thread",
preview: readString(record.preview) ?? "",
model: readString(record.model) ?? "unknown",
mode: readString(record.mode) ?? "agent",
archived: record.archived === true,
updatedAt: readString(record.updated_at) ?? "",
latestTurnStatus: readString(record.latest_turn_status),
},
];
});
}
function readString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function shellQuote(value: string): string {
if (/^[A-Za-z0-9_./:=+-]+$/.test(value)) {
return value;
+42 -1
View File
@@ -1,5 +1,5 @@
import * as vscode from "vscode";
import type { RuntimeState } from "./runtime";
import type { RuntimeState, ThreadSummary } from "./runtime";
export class RuntimeStatusView implements vscode.WebviewViewProvider {
public static readonly viewType = "codewhale.runtimeStatus";
@@ -10,6 +10,8 @@ export class RuntimeStatusView implements vscode.WebviewViewProvider {
baseUrl: "http://127.0.0.1:7878",
detail: "Runtime has not been checked yet.",
};
private threads: ThreadSummary[] = [];
private threadsDetail = "Connect to the runtime to load recent threads.";
resolveWebviewView(view: vscode.WebviewView): void {
this.view = view;
@@ -21,6 +23,8 @@ export class RuntimeStatusView implements vscode.WebviewViewProvider {
void vscode.commands.executeCommand("codewhale.startRuntime");
} else if (message.command === "terminal") {
void vscode.commands.executeCommand("codewhale.openTerminal");
} else if (message.command === "threads") {
void vscode.commands.executeCommand("codewhale.refreshAgentView");
}
});
this.render();
@@ -31,6 +35,12 @@ export class RuntimeStatusView implements vscode.WebviewViewProvider {
this.render();
}
updateThreads(threads: ThreadSummary[], detail: string): void {
this.threads = threads;
this.threadsDetail = detail;
this.render();
}
private render(): void {
if (!this.view) {
return;
@@ -38,6 +48,10 @@ export class RuntimeStatusView implements vscode.WebviewViewProvider {
const badge = labelFor(this.state.kind);
const nonce = makeNonce();
const threadsHtml =
this.threads.length > 0
? this.threads.map((thread) => renderThread(thread)).join("")
: `<p class="detail">${escapeHtml(this.threadsDetail)}</p>`;
this.view.webview.html = `<!doctype html>
<html lang="en">
<head>
@@ -48,6 +62,11 @@ export class RuntimeStatusView implements vscode.WebviewViewProvider {
body { padding: 14px; color: var(--vscode-foreground); font-family: var(--vscode-font-family); }
.status { margin-bottom: 12px; font-weight: 600; }
.detail { margin: 0 0 14px; color: var(--vscode-descriptionForeground); line-height: 1.45; }
.section-title { margin: 18px 0 8px; font-size: 11px; font-weight: 700; letter-spacing: 0; text-transform: uppercase; color: var(--vscode-descriptionForeground); }
.thread { padding: 8px 0; border-top: 1px solid var(--vscode-sideBarSectionHeader-border, var(--vscode-panel-border)); }
.thread-title { margin-bottom: 4px; font-weight: 600; overflow-wrap: anywhere; }
.thread-preview { margin-bottom: 5px; color: var(--vscode-descriptionForeground); line-height: 1.35; overflow-wrap: anywhere; }
.thread-meta { color: var(--vscode-descriptionForeground); font-size: 11px; overflow-wrap: anywhere; }
code { color: var(--vscode-textLink-foreground); }
button { width: 100%; margin: 4px 0; }
</style>
@@ -57,8 +76,11 @@ export class RuntimeStatusView implements vscode.WebviewViewProvider {
<p class="detail">${escapeHtml(this.state.detail)}</p>
<p class="detail"><code>${escapeHtml(this.state.baseUrl)}</code></p>
<button data-command="check">Check Runtime</button>
<button data-command="threads">Refresh Threads</button>
<button data-command="start">Start Local Runtime</button>
<button data-command="terminal">Open CodeWhale Terminal</button>
<div class="section-title">Agent View</div>
${threadsHtml}
<script nonce="${nonce}">
const vscode = acquireVsCodeApi();
for (const button of document.querySelectorAll("button[data-command]")) {
@@ -70,6 +92,17 @@ export class RuntimeStatusView implements vscode.WebviewViewProvider {
}
}
function renderThread(thread: ThreadSummary): string {
const status = thread.latestTurnStatus ? ` · ${thread.latestTurnStatus}` : "";
const archived = thread.archived ? " · archived" : "";
const updated = thread.updatedAt ? ` · ${formatTimestamp(thread.updatedAt)}` : "";
return `<div class="thread">
<div class="thread-title">${escapeHtml(thread.title)}</div>
<div class="thread-preview">${escapeHtml(thread.preview || "No recent message.")}</div>
<div class="thread-meta">${escapeHtml(`${thread.mode} · ${thread.model}${status}${archived}${updated}`)}</div>
</div>`;
}
function labelFor(kind: RuntimeState["kind"]): string {
switch (kind) {
case "connected":
@@ -83,6 +116,14 @@ function labelFor(kind: RuntimeState["kind"]): string {
}
}
function formatTimestamp(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString();
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")