Skip to content

Commit 4d4c119

Browse files
Merge pull request #336 from sammyyakk/feat/vscode-extension
feat(vscode): add a VS Code extension (ACP client, no reverse-engineering involved)
2 parents 00db2b6 + 9e636e5 commit 4d4c119

15 files changed

Lines changed: 1059 additions & 0 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
name: VS Code Extension CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths:
7+
- 'editors/vscode/**'
8+
- '.github/workflows/vscode-extension-ci.yml'
9+
pull_request:
10+
branches: [main]
11+
paths:
12+
- 'editors/vscode/**'
13+
- '.github/workflows/vscode-extension-ci.yml'
14+
15+
concurrency:
16+
group: vscode-extension-ci-${{ github.ref }}
17+
cancel-in-progress: true
18+
19+
jobs:
20+
build:
21+
runs-on: ubuntu-latest
22+
defaults:
23+
run:
24+
working-directory: editors/vscode
25+
26+
steps:
27+
- uses: actions/checkout@v4
28+
29+
- uses: actions/setup-node@v4
30+
with:
31+
node-version: '20'
32+
cache: 'npm'
33+
cache-dependency-path: editors/vscode/package-lock.json
34+
35+
- name: Install dependencies
36+
run: npm ci
37+
38+
- name: Compile
39+
run: npm run compile
40+
41+
- name: Unit tests
42+
run: npm test

.gitignore

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
# Rust
22
src-rust/target/
33

4+
# VS Code extension
5+
editors/vscode/node_modules/
6+
editors/vscode/out/
7+
editors/vscode/*.vsix
8+
49
# OS
510
.DS_Store
611

@@ -18,3 +23,8 @@ refs/
1823
.vscode/
1924
.claurst/
2025

26+
# ...except the VS Code extension's own launch/build config, which is
27+
# required (not personal editor prefs) for F5 debugging to work.
28+
!editors/vscode/.vscode/
29+
!editors/vscode/.vscode/*.json
30+

editors/vscode/.vscode/launch.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"version": "0.2.0",
3+
"configurations": [
4+
{
5+
"name": "Run Claurst Extension",
6+
"type": "extensionHost",
7+
"request": "launch",
8+
"runtimeExecutable": "${execPath}",
9+
"args": ["--extensionDevelopmentPath=${workspaceFolder}"],
10+
"outFiles": ["${workspaceFolder}/out/**/*.js"],
11+
"preLaunchTask": "npm: compile"
12+
}
13+
]
14+
}

editors/vscode/.vscode/tasks.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"version": "2.0.0",
3+
"tasks": [
4+
{
5+
"type": "npm",
6+
"script": "compile",
7+
"group": { "kind": "build", "isDefault": true },
8+
"problemMatcher": ["$tsc"]
9+
}
10+
]
11+
}

editors/vscode/README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Claurst for VS Code
2+
3+
Chat with [Claurst](https://github.com/Kuberwastaken/claurst) without leaving VS Code.
4+
5+
This extension does not reimplement the agent — it spawns `claurst acp` (the
6+
Agent Client Protocol server already built into the `claurst` CLI, see
7+
`src-rust/crates/acp`) as a child process and speaks newline-delimited
8+
JSON-RPC 2.0 to it over stdio. All prompting, tool execution, and permission
9+
logic live in `claurst` itself; this extension is a thin ACP client plus a
10+
webview for rendering the conversation.
11+
12+
## Requirements
13+
14+
- The `claurst` CLI installed and on `PATH` (or configure
15+
`claurst.executablePath` to an absolute path).
16+
- A workspace folder open (the session's `cwd` is the first workspace folder).
17+
18+
## Commands
19+
20+
- **Claurst: Open Chat** — opens the chat panel and starts a session.
21+
- **Claurst: New Session** — discards the current session and starts fresh.
22+
- **Claurst: Stop Current Turn** — sends `session/cancel` for the in-flight turn.
23+
24+
## Development
25+
26+
```bash
27+
cd editors/vscode
28+
npm install
29+
npm run compile # or `npm run watch`
30+
```
31+
32+
Then open this directory in VS Code and press F5 to launch an Extension
33+
Development Host with the extension loaded.
34+
35+
## Permission requests
36+
37+
When `claurst` needs approval to run a tool (e.g. `Bash`, `Edit`), the
38+
extension shows a quick pick with the options the agent offered (allow once,
39+
allow always, reject). Dismissing the quick pick without choosing defaults to
40+
the first (least-privileged) option offered, per the ACP spec's guidance to
41+
avoid hanging the agent indefinitely.
42+
43+
## Scope
44+
45+
This is an MVP: a single chat panel, streamed text/thinking chunks, tool-call
46+
status lines, and permission prompts. It does not yet support multiple
47+
concurrent sessions, inline diffs, or `@file` mentions — contributions welcome.

editors/vscode/media/main.css

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
:root {
2+
--bubble-radius: 10px;
3+
}
4+
5+
body {
6+
font-family: var(--vscode-font-family);
7+
font-size: var(--vscode-font-size);
8+
color: var(--vscode-foreground);
9+
padding: 0;
10+
margin: 0;
11+
display: flex;
12+
flex-direction: column;
13+
height: 100vh;
14+
}
15+
16+
#messages {
17+
flex: 1;
18+
overflow-y: auto;
19+
padding: 10px 12px;
20+
display: flex;
21+
flex-direction: column;
22+
gap: 4px;
23+
}
24+
25+
.row {
26+
display: flex;
27+
}
28+
29+
.row.user { justify-content: flex-end; }
30+
.row.agent, .row.thought, .row.system { justify-content: flex-start; }
31+
32+
.bubble {
33+
max-width: 85%;
34+
padding: 7px 11px;
35+
border-radius: var(--bubble-radius);
36+
white-space: pre-wrap;
37+
line-height: 1.45;
38+
overflow-wrap: anywhere;
39+
}
40+
41+
.bubble.user {
42+
background: var(--vscode-button-background);
43+
color: var(--vscode-button-foreground);
44+
border-bottom-right-radius: 2px;
45+
}
46+
47+
.bubble.agent {
48+
background: var(--vscode-editorWidget-background, var(--vscode-input-background));
49+
border-bottom-left-radius: 2px;
50+
}
51+
52+
.bubble.thought {
53+
background: transparent;
54+
color: var(--vscode-descriptionForeground);
55+
font-style: italic;
56+
padding-left: 0;
57+
}
58+
59+
.bubble.system {
60+
background: transparent;
61+
color: var(--vscode-descriptionForeground);
62+
font-size: 0.9em;
63+
padding: 2px 0;
64+
}
65+
66+
.tool-call {
67+
align-self: flex-start;
68+
max-width: 90%;
69+
border-left: 3px solid var(--vscode-textLink-foreground);
70+
background: var(--vscode-textCodeBlock-background, transparent);
71+
padding: 4px 10px;
72+
border-radius: 4px;
73+
color: var(--vscode-descriptionForeground);
74+
font-size: 0.88em;
75+
font-family: var(--vscode-editor-font-family, monospace);
76+
}
77+
78+
.tool-call.completed { border-left-color: var(--vscode-testing-iconPassed, #4caf50); }
79+
.tool-call.failed { border-left-color: var(--vscode-testing-iconFailed, #f44336); }
80+
81+
#input-row {
82+
display: flex;
83+
align-items: flex-end;
84+
gap: 6px;
85+
padding: 8px;
86+
border-top: 1px solid var(--vscode-panel-border);
87+
flex-shrink: 0;
88+
}
89+
90+
#input-box {
91+
flex: 1;
92+
resize: none;
93+
background: var(--vscode-input-background);
94+
color: var(--vscode-input-foreground);
95+
border: 1px solid var(--vscode-input-border, transparent);
96+
border-radius: 6px;
97+
padding: 7px 9px;
98+
font-family: inherit;
99+
font-size: inherit;
100+
max-height: 200px;
101+
overflow-y: auto;
102+
}
103+
104+
#input-box:focus {
105+
outline: 1px solid var(--vscode-focusBorder);
106+
}
107+
108+
button {
109+
background: var(--vscode-button-background);
110+
color: var(--vscode-button-foreground);
111+
border: none;
112+
border-radius: 4px;
113+
padding: 6px 12px;
114+
cursor: pointer;
115+
}
116+
117+
button:hover {
118+
background: var(--vscode-button-hoverBackground);
119+
}
120+
121+
button:disabled {
122+
opacity: 0.5;
123+
cursor: default;
124+
}
125+
126+
#stop-btn {
127+
background: var(--vscode-inputValidation-errorBackground, #a1260d);
128+
}
129+
130+
#stop-btn:hover {
131+
background: var(--vscode-inputValidation-errorBorder, #be1100);
132+
}
133+
134+
#stop-btn.hidden {
135+
display: none;
136+
}

editors/vscode/media/main.js

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// Webview-side script. Runs in a restricted context with no Node access;
2+
// all agent communication goes through the extension host via postMessage.
3+
(function () {
4+
const vscode = acquireVsCodeApi();
5+
const messagesEl = document.getElementById('messages');
6+
const inputEl = document.getElementById('input-box');
7+
const sendBtn = document.getElementById('send-btn');
8+
const stopBtn = document.getElementById('stop-btn');
9+
10+
let currentAgentBubble = null;
11+
const toolCallEls = new Map();
12+
13+
function appendRow(text, cls) {
14+
const row = document.createElement('div');
15+
row.className = 'row ' + cls;
16+
const bubble = document.createElement('div');
17+
bubble.className = 'bubble ' + cls;
18+
bubble.textContent = text;
19+
row.appendChild(bubble);
20+
messagesEl.appendChild(row);
21+
messagesEl.scrollTop = messagesEl.scrollHeight;
22+
return bubble;
23+
}
24+
25+
function statusIcon(status) {
26+
if (status === 'completed') return '✓';
27+
if (status === 'failed') return '✗';
28+
if (status === 'in_progress' || status === 'pending') return '◌';
29+
return '•';
30+
}
31+
32+
// The initial tool_call event carries a title; the tool_call_update sent
33+
// on completion never does (the agent only sends status + content there).
34+
// Remember the title on the element itself so completion doesn't blank it.
35+
function upsertToolCall(id, title, status) {
36+
let el = id ? toolCallEls.get(id) : null;
37+
if (!el) {
38+
el = document.createElement('div');
39+
el.className = 'tool-call';
40+
messagesEl.appendChild(el);
41+
if (id) {
42+
toolCallEls.set(id, el);
43+
}
44+
}
45+
if (title) {
46+
el.dataset.title = title;
47+
}
48+
el.className = 'tool-call ' + (status || '');
49+
el.textContent = `${statusIcon(status)} ${el.dataset.title || '(tool call)'}`;
50+
messagesEl.scrollTop = messagesEl.scrollHeight;
51+
}
52+
53+
function setBusy(busy) {
54+
sendBtn.disabled = busy;
55+
stopBtn.classList.toggle('hidden', !busy);
56+
}
57+
58+
function autoResize() {
59+
inputEl.style.height = 'auto';
60+
inputEl.style.height = Math.min(inputEl.scrollHeight, 200) + 'px';
61+
}
62+
inputEl.addEventListener('input', autoResize);
63+
64+
function send() {
65+
const text = inputEl.value.trim();
66+
if (!text) {
67+
return;
68+
}
69+
appendRow(text, 'user');
70+
inputEl.value = '';
71+
autoResize();
72+
currentAgentBubble = null;
73+
setBusy(true);
74+
vscode.postMessage({ type: 'prompt', text });
75+
}
76+
77+
sendBtn.addEventListener('click', send);
78+
stopBtn.addEventListener('click', () => vscode.postMessage({ type: 'stop' }));
79+
inputEl.addEventListener('keydown', (e) => {
80+
if (e.key === 'Enter' && !e.shiftKey) {
81+
e.preventDefault();
82+
send();
83+
}
84+
});
85+
86+
setBusy(false);
87+
88+
window.addEventListener('message', (event) => {
89+
const msg = event.data;
90+
switch (msg.type) {
91+
case 'textChunk': {
92+
const cls = msg.isThought ? 'thought' : 'agent';
93+
if (!currentAgentBubble || currentAgentBubble.dataset.cls !== cls) {
94+
currentAgentBubble = appendRow('', cls);
95+
currentAgentBubble.dataset.cls = cls;
96+
}
97+
currentAgentBubble.textContent += msg.text;
98+
messagesEl.scrollTop = messagesEl.scrollHeight;
99+
break;
100+
}
101+
case 'toolCall':
102+
case 'toolCallUpdate': {
103+
currentAgentBubble = null;
104+
upsertToolCall(msg.toolCallId, msg.title, msg.status);
105+
break;
106+
}
107+
case 'status': {
108+
currentAgentBubble = null;
109+
appendRow(msg.text, 'system');
110+
break;
111+
}
112+
case 'turnEnded': {
113+
currentAgentBubble = null;
114+
setBusy(false);
115+
break;
116+
}
117+
default:
118+
break;
119+
}
120+
});
121+
})();

0 commit comments

Comments
 (0)