mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
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:
@@ -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
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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")!)
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user