Add chat interface to kilo-vscode sidebar

- Add server discovery with health checking on localhost:4096
- Add SDK client wrapper for browser context
- Add session and message state management with SSE events
- Add chat UI components (MessageList, Message, PromptInput, StatusIndicator)
- Add VS Code themed styles
- Add AGENTS.md documentation
This commit is contained in:
Mark IJbema
2026-02-05 10:09:51 +01:00
parent 7fa2bac565
commit 76d374f12a
17 changed files with 1162 additions and 62 deletions
+104
View File
@@ -0,0 +1,104 @@
# packages/kilo-vscode
VS Code extension providing a chat interface to connect with an opencode server.
## Architecture
The extension has two main parts:
1. **Extension (Node.js)** - `src/extension.ts`, `src/sidebar.ts`, `src/server.ts`
- Runs in VS Code's extension host (Node.js environment)
- Handles server discovery and health checking
- Manages webview lifecycle and message passing
2. **Webview (Browser)** - `src/webview/`
- Runs in a sandboxed browser context within VS Code
- Uses SolidJS for UI (same as main app)
- Communicates with extension via `postMessage`
## Key Patterns
### Extension <-> Webview Communication
```typescript
// Extension sends to webview
webview.postMessage({ type: "server", server: { url, version } })
// Webview receives
window.addEventListener("message", (e) => handleMessage(e.data))
// Webview sends to extension
vscode.postMessage({ type: "ready" })
```
### State Persistence
Webview state survives hide/show cycles via VS Code API:
```typescript
const vscode = acquireVsCodeApi<State>()
vscode.getState() // Restore
vscode.setState(newState) // Persist
```
### Server Discovery
The extension polls `http://localhost:4096/global/health` to detect a running opencode server. When found, it passes the server URL to the webview.
## File Structure
```
src/
├── extension.ts # Extension entry point
├── sidebar.ts # WebviewViewProvider implementation
├── server.ts # Server discovery logic
└── webview/
├── App.tsx # Main component composition
├── index.tsx # Webview entry point
├── types.ts # Shared type definitions
├── styles.css # VS Code themed styles
├── context/
│ ├── server.tsx # Server connection state
│ └── session.tsx # Session and message state
└── components/
├── StatusIndicator.tsx
├── MessageList.tsx
├── Message.tsx
└── PromptInput.tsx
```
## Development
1. Install dependencies: `pnpm install`
2. Start watchers: `pnpm dev`
3. Press F5 in VS Code to launch Extension Development Host
4. Open the Kilo sidebar to see the webview
## Build
- `pnpm compile` - Full build
- `pnpm build:webview` - Build webview only
- `pnpm package` - Production build
## Testing
- Run `pnpm test` to execute VS Code integration tests
- Tests require VS Code to be installed
## VS Code CSS Variables
Use VS Code CSS variables for theming to match the user's theme:
- `--vscode-foreground`, `--vscode-background`
- `--vscode-button-background`, `--vscode-button-foreground`
- `--vscode-input-background`, `--vscode-input-border`
- See full list: https://code.visualstudio.com/api/references/theme-color
## Content Security Policy
The webview CSP allows:
- Scripts: nonce-based only
- Styles: inline and local resources
- Connections: localhost HTTP/HTTPS only
- Images: local, HTTPS, and data URIs
+1
View File
@@ -54,6 +54,7 @@
"test": "vscode-test" "test": "vscode-test"
}, },
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*",
"solid-js": "^1.9.11" "solid-js": "^1.9.11"
}, },
"devDependencies": { "devDependencies": {
+94
View File
@@ -0,0 +1,94 @@
import * as vscode from "vscode"
const DEFAULT_PORT = 4096
const HEALTH_TIMEOUT = 3000
export interface ServerInfo {
url: string
version: string
}
export async function discoverServer(): Promise<ServerInfo | null> {
const ports = [DEFAULT_PORT]
for (const port of ports) {
const url = `http://localhost:${port}`
const info = await checkHealth(url)
if (info) {
return info
}
}
return null
}
export async function checkHealth(url: string): Promise<ServerInfo | null> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), HEALTH_TIMEOUT)
try {
const response = await fetch(`${url}/global/health`, {
signal: controller.signal,
})
if (!response.ok) {
return null
}
const data = (await response.json()) as { healthy: boolean; version: string }
if (!data.healthy) {
return null
}
return { url, version: data.version }
} catch {
return null
} finally {
clearTimeout(timeout)
}
}
export class ServerWatcher implements vscode.Disposable {
private interval: ReturnType<typeof setInterval> | undefined
private disposed = false
private current: ServerInfo | null = null
private readonly onChangeEmitter = new vscode.EventEmitter<ServerInfo | null>()
readonly onChange = this.onChangeEmitter.event
constructor(private readonly pollInterval = 5000) {}
async start(): Promise<ServerInfo | null> {
this.current = await discoverServer()
this.startPolling()
return this.current
}
private startPolling() {
if (this.disposed) {
return
}
this.interval = setInterval(async () => {
const info = await discoverServer()
const changed = info?.url !== this.current?.url || info?.version !== this.current?.version
if (changed) {
this.current = info
this.onChangeEmitter.fire(info)
}
}, this.pollInterval)
}
get server(): ServerInfo | null {
return this.current
}
dispose() {
this.disposed = true
if (this.interval) {
clearInterval(this.interval)
this.interval = undefined
}
this.onChangeEmitter.dispose()
}
}
+53 -10
View File
@@ -1,15 +1,27 @@
import * as vscode from "vscode" import * as vscode from "vscode"
import { ServerWatcher, type ServerInfo } from "./server"
export interface WebviewMessage {
type: string
[key: string]: unknown
}
export class SidebarProvider implements vscode.WebviewViewProvider { export class SidebarProvider implements vscode.WebviewViewProvider {
private view?: vscode.WebviewView private view?: vscode.WebviewView
private serverWatcher: ServerWatcher
constructor(private context: vscode.ExtensionContext) {} constructor(private context: vscode.ExtensionContext) {
this.serverWatcher = new ServerWatcher()
this.serverWatcher.onChange((info) => {
this.sendServerInfo(info)
})
}
resolveWebviewView( async resolveWebviewView(
webviewView: vscode.WebviewView, webviewView: vscode.WebviewView,
_context: vscode.WebviewViewResolveContext, _context: vscode.WebviewViewResolveContext,
_token: vscode.CancellationToken, _token: vscode.CancellationToken,
): void | Thenable<void> { ): Promise<void> {
this.view = webviewView this.view = webviewView
webviewView.webview.options = { webviewView.webview.options = {
@@ -19,17 +31,48 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
webviewView.webview.html = this.getHtml(webviewView.webview) webviewView.webview.html = this.getHtml(webviewView.webview)
webviewView.webview.onDidReceiveMessage((message) => { webviewView.webview.onDidReceiveMessage((message: WebviewMessage) => {
switch (message.type) { this.handleMessage(message)
case "increment":
vscode.window.showInformationMessage(`Count: ${message.count}`)
break
}
}) })
if (this.context.extensionMode === vscode.ExtensionMode.Development) { if (this.context.extensionMode === vscode.ExtensionMode.Development) {
this.setupDevReload(webviewView) this.setupDevReload(webviewView)
} }
const server = await this.serverWatcher.start()
this.sendServerInfo(server)
webviewView.onDidDispose(() => {
this.serverWatcher.dispose()
})
}
private handleMessage(message: WebviewMessage) {
switch (message.type) {
case "ready":
this.sendServerInfo(this.serverWatcher.server)
this.sendWorkspaceInfo()
break
case "log":
console.log("[webview]", message.message)
break
}
}
private sendServerInfo(info: ServerInfo | null) {
this.view?.webview.postMessage({
type: "server",
server: info,
})
}
private sendWorkspaceInfo() {
const folders = vscode.workspace.workspaceFolders
const directory = folders?.[0]?.uri.fsPath ?? null
this.view?.webview.postMessage({
type: "workspace",
directory,
})
} }
private setupDevReload(webviewView: vscode.WebviewView) { private setupDevReload(webviewView: vscode.WebviewView) {
@@ -63,7 +106,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'none'; style-src 'unsafe-inline' ${webview.cspSource}; script-src 'nonce-${nonce}' ${webview.cspSource}; font-src ${webview.cspSource}; img-src ${webview.cspSource} https: data:;" content="default-src 'none'; style-src 'unsafe-inline' ${webview.cspSource}; script-src 'nonce-${nonce}' ${webview.cspSource}; font-src ${webview.cspSource}; img-src ${webview.cspSource} https: data:; connect-src http://localhost:* https://localhost:*;"
/> />
<link rel="stylesheet" href="${styleUri}?v=${bust}" /> <link rel="stylesheet" href="${styleUri}?v=${bust}" />
<title>Kilo</title> <title>Kilo</title>
@@ -0,0 +1,19 @@
import * as assert from "assert"
import { checkHealth, discoverServer } from "../server"
suite("Server Discovery Test Suite", () => {
test("checkHealth returns null for non-existent server", async () => {
const result = await checkHealth("http://localhost:59999")
assert.strictEqual(result, null)
})
test("checkHealth returns null for invalid URL", async () => {
const result = await checkHealth("http://invalid-host-that-does-not-exist:4096")
assert.strictEqual(result, null)
})
test("discoverServer returns expected shape", async () => {
const result = await discoverServer()
assert.ok(result === null || (typeof result.url === "string" && typeof result.version === "string"))
})
})
+20 -30
View File
@@ -1,35 +1,25 @@
import { createSignal, createEffect } from "solid-js" import { ServerProvider } from "./context/server"
import { SessionProvider } from "./context/session"
interface State { import { StatusIndicator } from "./components/StatusIndicator"
count: number import { MessageList } from "./components/MessageList"
} import { PromptInput } from "./components/PromptInput"
const vscode = acquireVsCodeApi<State>()
function getInitialState(): State {
return vscode.getState() ?? { count: 0 }
}
export default function App() { export default function App() {
const initial = getInitialState()
const [count, setCount] = createSignal(initial.count)
createEffect(() => {
vscode.setState({ count: count() })
})
function increment() {
setCount((c) => c + 1)
vscode.postMessage({ type: "increment", count: count() })
}
return ( return (
<div class="container"> <ServerProvider>
<h1>Kilo Sidebar</h1> <SessionProvider>
<p>Welcome to the Kilo VS Code extension sidebar.</p> <div class="app">
<div class="counter"> <header class="app-header">
<button onClick={increment}>Count: {count()}</button> <StatusIndicator />
</div> </header>
</div> <main class="app-main">
<MessageList />
</main>
<footer class="app-footer">
<PromptInput />
</footer>
</div>
</SessionProvider>
</ServerProvider>
) )
} }
@@ -0,0 +1,35 @@
import { For, Show } from "solid-js"
import type { Message as MessageType, Part, TextPart, ToolPart } from "../sdk"
interface Props {
message: MessageType
parts: Part[]
}
export function Message(props: Props) {
const isUser = () => props.message.role === "user"
const textParts = () => props.parts.filter((p): p is TextPart => p.type === "text")
const toolParts = () => props.parts.filter((p): p is ToolPart => p.type === "tool")
return (
<div class={`message ${isUser() ? "message-user" : "message-assistant"}`}>
<div class="message-role">{isUser() ? "You" : "Assistant"}</div>
<div class="message-content">
<For each={textParts()}>{(part) => <div class="message-text">{part.text}</div>}</For>
<Show when={toolParts().length > 0}>
<div class="message-tools">
<For each={toolParts()}>
{(part) => (
<div class={`tool-call tool-${part.state.status}`}>
<span class="tool-name">{part.tool}</span>
<span class="tool-status">{part.state.status}</span>
</div>
)}
</For>
</div>
</Show>
</div>
</div>
)
}
@@ -0,0 +1,40 @@
import { For, Show, createEffect, onMount } from "solid-js"
import { useSession } from "../context/session"
import { Message } from "./Message"
export function MessageList() {
const { messages, parts, status } = useSession()
let container: HTMLDivElement | undefined
createEffect(() => {
const msgs = messages()
if (msgs.length > 0 && container) {
container.scrollTop = container.scrollHeight
}
})
onMount(() => {
if (container) {
container.scrollTop = container.scrollHeight
}
})
return (
<div class="message-list" ref={container}>
<Show when={messages().length === 0}>
<div class="empty-state">
<p>No messages yet.</p>
<p>Start a conversation below.</p>
</div>
</Show>
<For each={messages()}>{(message) => <Message message={message} parts={parts()(message.id)} />}</For>
<Show when={status() === "running"}>
<div class="typing-indicator">
<span />
<span />
<span />
</div>
</Show>
</div>
)
}
@@ -0,0 +1,51 @@
import { createSignal, Show } from "solid-js"
import { useSession } from "../context/session"
import { useServer } from "../context/server"
export function PromptInput() {
const { sendMessage, abort, status } = useSession()
const { status: serverStatus } = useServer()
const [text, setText] = createSignal("")
const isDisabled = () => serverStatus() !== "connected"
const isRunning = () => status() === "running"
async function handleSubmit(e: Event) {
e.preventDefault()
const message = text().trim()
if (!message || isDisabled()) return
setText("")
await sendMessage(message)
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSubmit(e)
}
}
return (
<form class="prompt-input" onSubmit={handleSubmit}>
<textarea
value={text()}
onInput={(e) => setText(e.currentTarget.value)}
onKeyDown={handleKeyDown}
placeholder={isDisabled() ? "Waiting for server..." : "Type your message..."}
disabled={isDisabled()}
rows={3}
/>
<div class="prompt-actions">
<Show when={isRunning()}>
<button type="button" class="abort-button" onClick={() => abort()}>
Stop
</button>
</Show>
<button type="submit" disabled={isDisabled() || !text().trim() || isRunning()}>
Send
</button>
</div>
</form>
)
}
@@ -0,0 +1,29 @@
import { useServer } from "../context/server"
export function StatusIndicator() {
const { server, status } = useServer()
const statusText = () => {
const s = status()
if (s === "connected") {
const info = server()
return info ? `Connected (v${info.version})` : "Connected"
}
if (s === "connecting") return "Connecting..."
return "Disconnected"
}
const statusColor = () => {
const s = status()
if (s === "connected") return "var(--vscode-testing-iconPassed)"
if (s === "connecting") return "var(--vscode-testing-iconQueued)"
return "var(--vscode-testing-iconFailed)"
}
return (
<div class="status-indicator">
<span class="status-dot" style={{ "background-color": statusColor() }} />
<span class="status-text">{statusText()}</span>
</div>
)
}
@@ -0,0 +1,83 @@
import {
createContext,
useContext,
createSignal,
createEffect,
onMount,
onCleanup,
type ParentProps,
type Accessor,
} from "solid-js"
import { createOpencodeClient, type OpencodeClient } from "../sdk"
import type { ServerInfo, ExtensionMessage } from "../types"
export interface ServerContextValue {
server: Accessor<ServerInfo | null>
directory: Accessor<string | null>
client: Accessor<OpencodeClient | null>
status: Accessor<"disconnected" | "connecting" | "connected">
}
const ServerContext = createContext<ServerContextValue>()
export function useServer() {
const ctx = useContext(ServerContext)
if (!ctx) {
throw new Error("useServer must be used within ServerProvider")
}
return ctx
}
const vscode = acquireVsCodeApi<{ server: ServerInfo | null; directory: string | null }>()
export function ServerProvider(props: ParentProps) {
const initial = vscode.getState()
const [server, setServer] = createSignal<ServerInfo | null>(initial?.server ?? null)
const [directory, setDirectory] = createSignal<string | null>(initial?.directory ?? null)
const [client, setClient] = createSignal<OpencodeClient | null>(null)
const [status, setStatus] = createSignal<"disconnected" | "connecting" | "connected">("disconnected")
function handleMessage(event: MessageEvent<ExtensionMessage>) {
const message = event.data
switch (message.type) {
case "server":
setServer(message.server ?? null)
vscode.setState({ server: message.server ?? null, directory: directory() })
break
case "workspace":
setDirectory(message.directory ?? null)
vscode.setState({ server: server(), directory: message.directory ?? null })
break
}
}
onMount(() => {
window.addEventListener("message", handleMessage)
vscode.postMessage({ type: "ready" })
})
onCleanup(() => {
window.removeEventListener("message", handleMessage)
})
createEffect(() => {
const info = server()
const dir = directory()
if (!info) {
setClient(null)
setStatus("disconnected")
return
}
setStatus("connecting")
const newClient = createOpencodeClient({
baseUrl: info.url,
directory: dir ?? undefined,
})
setClient(newClient)
setStatus("connected")
})
return <ServerContext.Provider value={{ server, directory, client, status }}>{props.children}</ServerContext.Provider>
}
@@ -0,0 +1,322 @@
import {
createContext,
useContext,
createSignal,
createEffect,
onCleanup,
type ParentProps,
type Accessor,
} from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useServer } from "./server"
import type { Session, Message, Part, Event } from "../sdk"
export type SessionStatus = "idle" | "running" | "error"
export interface SessionState {
sessions: Session[]
current: string | null
messages: Record<string, Message[]>
parts: Record<string, Part[]>
status: Record<string, SessionStatus>
}
export interface SessionContextValue {
state: SessionState
current: Accessor<Session | null>
messages: Accessor<Message[]>
parts: Accessor<(messageID: string) => Part[]>
status: Accessor<SessionStatus>
createSession: () => Promise<Session | null>
selectSession: (id: string) => void
sendMessage: (text: string) => Promise<void>
abort: () => Promise<void>
}
const SessionContext = createContext<SessionContextValue>()
export function useSession() {
const ctx = useContext(SessionContext)
if (!ctx) throw new Error("useSession must be used within SessionProvider")
return ctx
}
export function SessionProvider(props: ParentProps) {
const { client, directory, status: serverStatus } = useServer()
const [state, setState] = createStore<SessionState>({
sessions: [],
current: null,
messages: {},
parts: {},
status: {},
})
const [eventAbort, setEventAbort] = createSignal<AbortController | null>(null)
const [creating, setCreating] = createSignal(false)
const current = () => state.sessions.find((s) => s.id === state.current) ?? null
const messages = () => (state.current ? (state.messages[state.current] ?? []) : [])
const parts = () => (messageID: string) => state.parts[messageID] ?? []
const status = () => (state.current ? (state.status[state.current] ?? "idle") : "idle")
createEffect(() => {
const c = client()
if (!c || serverStatus() !== "connected") return
loadSessions()
subscribeToEvents()
})
onCleanup(() => {
eventAbort()?.abort()
})
async function loadSessions() {
const c = client()
if (!c) return
try {
const result = await c.session.list({ limit: 50 })
if (result.data) {
setState("sessions", result.data)
}
} catch (err) {
console.error("Failed to load sessions:", err)
}
}
async function subscribeToEvents() {
const c = client()
if (!c) return
eventAbort()?.abort()
const abort = new AbortController()
setEventAbort(abort)
try {
const result = await c.global.event({ signal: abort.signal })
if (!result.stream) return
for await (const event of result.stream) {
if (abort.signal.aborted) break
handleEvent(event as { directory?: string; payload: Event })
}
} catch (err) {
if (!abort.signal.aborted) {
console.error("Event stream error:", err)
}
}
}
function handleEvent(event: { directory?: string; payload: Event }) {
const payload = event.payload
const dir = directory()
if (dir && event.directory && event.directory !== dir && event.directory !== "global") {
return
}
switch (payload.type) {
case "session.created":
setState(
produce((s) => {
const existing = s.sessions.findIndex((sess) => sess.id === payload.properties.info.id)
if (existing === -1) {
s.sessions.unshift(payload.properties.info)
}
}),
)
break
case "session.updated":
setState(
produce((s) => {
const idx = s.sessions.findIndex((sess) => sess.id === payload.properties.info.id)
if (idx !== -1) {
s.sessions[idx] = payload.properties.info
}
}),
)
break
case "session.deleted":
setState(
produce((s) => {
s.sessions = s.sessions.filter((sess) => sess.id !== payload.properties.info.id)
if (s.current === payload.properties.info.id) {
s.current = null
}
}),
)
break
case "message.updated":
setState(
produce((s) => {
const msg = payload.properties.info
if (!s.messages[msg.sessionID]) {
s.messages[msg.sessionID] = []
}
const idx = s.messages[msg.sessionID].findIndex((m) => m.id === msg.id)
if (idx === -1) {
s.messages[msg.sessionID].push(msg)
} else {
s.messages[msg.sessionID][idx] = msg
}
}),
)
break
case "message.removed":
setState(
produce((s) => {
const { sessionID, messageID } = payload.properties
if (s.messages[sessionID]) {
s.messages[sessionID] = s.messages[sessionID].filter((m) => m.id !== messageID)
}
}),
)
break
case "message.part.updated":
setState(
produce((s) => {
const part = payload.properties.part
if (!s.parts[part.messageID]) {
s.parts[part.messageID] = []
}
const idx = s.parts[part.messageID].findIndex((p) => p.id === part.id)
if (idx === -1) {
s.parts[part.messageID].push(part)
} else {
s.parts[part.messageID][idx] = part
}
}),
)
break
case "message.part.removed":
setState(
produce((s) => {
const { messageID, partID } = payload.properties
if (s.parts[messageID]) {
s.parts[messageID] = s.parts[messageID].filter((p) => p.id !== partID)
}
}),
)
break
case "session.status":
setState(
produce((s) => {
for (const [sessionID, sessionStatus] of Object.entries(payload.properties.status)) {
const st = sessionStatus as { status: string }
s.status[sessionID] = st.status === "running" ? "running" : "idle"
}
}),
)
break
}
}
async function createSession(): Promise<Session | null> {
const c = client()
if (!c) return null
try {
const result = await c.session.create()
if (result.data) {
setState("current", result.data.id)
return result.data
}
} catch (err) {
console.error("Failed to create session:", err)
}
return null
}
async function selectSession(id: string) {
setState("current", id)
await loadMessages(id)
}
async function loadMessages(sessionID: string) {
const c = client()
if (!c) return
try {
const result = await c.session.messages({ sessionID })
if (result.data) {
setState("messages", sessionID, result.data.messages)
for (const msg of result.data.messages) {
setState("parts", msg.id, result.data.parts[msg.id] ?? [])
}
}
} catch (err) {
console.error("Failed to load messages:", err)
}
}
async function sendMessage(text: string): Promise<void> {
const c = client()
if (!c) return
const sessionID = state.current ?? (await getOrCreateSession())
if (!sessionID) return
setState("status", sessionID, "running")
try {
await c.session.prompt({
sessionID,
parts: [{ type: "text", text }],
})
} catch (err) {
console.error("Failed to send message:", err)
setState("status", sessionID, "error")
}
}
async function getOrCreateSession(): Promise<string | null> {
if (creating()) {
await new Promise((resolve) => setTimeout(resolve, 100))
return state.current
}
setCreating(true)
const session = await createSession()
setCreating(false)
return session?.id ?? null
}
async function abort(): Promise<void> {
const c = client()
const sessionID = state.current
if (!c || !sessionID) return
try {
await c.session.abort({ sessionID })
} catch (err) {
console.error("Failed to abort:", err)
}
}
return (
<SessionContext.Provider
value={{
state,
current,
messages,
parts,
status,
createSession,
selectSession,
sendMessage,
abort,
}}
>
{props.children}
</SessionContext.Provider>
)
}
@@ -2,6 +2,4 @@ import { render } from "solid-js/web"
import App from "./App" import App from "./App"
import "./styles.css" import "./styles.css"
console.info("hi there")
render(() => <App />, document.getElementById("root")!) render(() => <App />, document.getElementById("root")!)
+21
View File
@@ -0,0 +1,21 @@
export * from "../../../sdk/js/src/v2/gen/types.gen"
import { createClient } from "../../../sdk/js/src/v2/gen/client/client.gen"
import { type Config } from "../../../sdk/js/src/v2/gen/client/types.gen"
import { OpencodeClient } from "../../../sdk/js/src/v2/gen/sdk.gen"
export { type Config as OpencodeClientConfig, OpencodeClient }
export function createOpencodeClient(config?: Config & { directory?: string }) {
if (config?.directory) {
const isNonASCII = /[^\x00-\x7F]/.test(config.directory)
const encodedDirectory = isNonASCII ? encodeURIComponent(config.directory) : config.directory
config.headers = {
...config.headers,
"x-opencode-directory": encodedDirectory,
}
}
const client = createClient(config)
return new OpencodeClient({ client })
}
+267 -18
View File
@@ -9,39 +9,288 @@ body {
font-size: var(--vscode-font-size); font-size: var(--vscode-font-size);
color: var(--vscode-foreground); color: var(--vscode-foreground);
background-color: var(--vscode-sideBar-background); background-color: var(--vscode-sideBar-background);
padding: 12px; line-height: 1.4;
} }
.container { /* App Layout */
.app {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.app-header {
flex-shrink: 0;
padding: 8px 12px;
border-bottom: 1px solid var(--vscode-panel-border);
}
.app-main {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.app-footer {
flex-shrink: 0;
padding: 8px;
border-top: 1px solid var(--vscode-panel-border);
}
/* Status Indicator */
.status-indicator {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-text {
color: var(--vscode-descriptionForeground);
}
/* Message List */
.message-list {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
} }
h1 { .empty-state {
font-size: 1.2em; display: flex;
font-weight: 600; flex-direction: column;
color: var(--vscode-foreground); align-items: center;
} justify-content: center;
height: 100%;
p {
color: var(--vscode-descriptionForeground); color: var(--vscode-descriptionForeground);
text-align: center;
padding: 20px;
} }
button { .empty-state p {
margin: 4px 0;
}
/* Message */
.message {
display: flex;
flex-direction: column;
gap: 4px;
max-width: 100%;
}
.message-user {
align-items: flex-end;
}
.message-assistant {
align-items: flex-start;
}
.message-role {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
color: var(--vscode-descriptionForeground);
padding: 0 4px;
}
.message-content {
background-color: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border);
border-radius: 6px;
padding: 8px 12px;
max-width: 90%;
word-wrap: break-word;
white-space: pre-wrap;
}
.message-user .message-content {
background-color: var(--vscode-button-background); background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground); color: var(--vscode-button-foreground);
border: none; border-color: transparent;
padding: 8px 16px;
cursor: pointer;
border-radius: 2px;
font-size: inherit;
} }
button:hover { .message-text {
margin: 0;
}
/* Tool Calls */
.message-tools {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.tool-call {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
padding: 4px 8px;
border-radius: 4px;
background-color: var(--vscode-editor-background);
border: 1px solid var(--vscode-panel-border);
}
.tool-name {
font-family: var(--vscode-editor-font-family);
font-weight: 500;
}
.tool-status {
color: var(--vscode-descriptionForeground);
font-size: 10px;
}
.tool-pending {
opacity: 0.6;
}
.tool-running {
border-color: var(--vscode-progressBar-background);
}
.tool-completed {
border-color: var(--vscode-testing-iconPassed);
}
.tool-error {
border-color: var(--vscode-testing-iconFailed);
}
/* Typing Indicator */
.typing-indicator {
display: flex;
gap: 4px;
padding: 8px 12px;
}
.typing-indicator span {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--vscode-descriptionForeground);
animation: typing 1.4s infinite ease-in-out both;
}
.typing-indicator span:nth-child(1) {
animation-delay: -0.32s;
}
.typing-indicator span:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes typing {
0%,
80%,
100% {
transform: scale(0.6);
opacity: 0.4;
}
40% {
transform: scale(1);
opacity: 1;
}
}
/* Prompt Input */
.prompt-input {
display: flex;
flex-direction: column;
gap: 8px;
}
.prompt-input textarea {
width: 100%;
min-height: 60px;
max-height: 150px;
padding: 8px;
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
color: var(--vscode-input-foreground);
background-color: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border);
border-radius: 4px;
resize: vertical;
outline: none;
}
.prompt-input textarea:focus {
border-color: var(--vscode-focusBorder);
}
.prompt-input textarea:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.prompt-input textarea::placeholder {
color: var(--vscode-input-placeholderForeground);
}
.prompt-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.prompt-actions button {
padding: 6px 14px;
font-size: 12px;
font-family: var(--vscode-font-family);
border: none;
border-radius: 4px;
cursor: pointer;
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}
.prompt-actions button:hover:not(:disabled) {
background-color: var(--vscode-button-hoverBackground); background-color: var(--vscode-button-hoverBackground);
} }
.counter { .prompt-actions button:disabled {
margin-top: 8px; opacity: 0.5;
cursor: not-allowed;
}
.abort-button {
background-color: var(--vscode-button-secondaryBackground) !important;
color: var(--vscode-button-secondaryForeground) !important;
}
.abort-button:hover:not(:disabled) {
background-color: var(--vscode-button-secondaryHoverBackground) !important;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--vscode-scrollbarSlider-background);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--vscode-scrollbarSlider-hoverBackground);
} }
+23
View File
@@ -0,0 +1,23 @@
export interface ServerInfo {
url: string
version: string
}
export interface ExtensionMessage {
type: "server" | "workspace"
server?: ServerInfo | null
directory?: string | null
}
export interface WebviewMessage {
type: "ready" | "log"
message?: string
}
declare global {
function acquireVsCodeApi<T>(): {
postMessage(message: WebviewMessage): void
getState(): T | undefined
setState(state: T): T
}
}
-2
View File
@@ -1,8 +1,6 @@
import { defineConfig } from "vite" import { defineConfig } from "vite"
import solid from "vite-plugin-solid" import solid from "vite-plugin-solid"
const dev = process.env.NODE_ENV !== "production"
export default defineConfig({ export default defineConfig({
plugins: [solid()], plugins: [solid()],
root: "src/webview", root: "src/webview",