kapi-ui 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,138 @@
1
+ // Mirrors claude-agent.ts's workflow: one session is established at server
2
+ // start (the KAPI_SESSION_PROMPT turn) and every later submission resumes it,
3
+ // so all tabs share one continuous session in submission order. The one thing
4
+ // codex can't do that Claude can: `codex exec` is one-shot (no stdin-driven
5
+ // warm process), so each turn still pays process cold-start — session
6
+ // continuity is preserved via `exec resume <id>`, but the process isn't warm.
7
+ // ponytail: cold-start per turn is inherent to `codex exec`; only a codex
8
+ // server/stdin mode could remove it — none exists in the CLI today.
9
+ import { spawn } from 'child_process';
10
+ const KAPI_SESSION_PROMPT = "You're applying UI edits from visual comments. Each comment gives the exact file:line:col — go straight there, don't search for it. Make only the requested edit. " +
11
+ 'Before finishing, check whether a higher-precedence rule on the same element would override the property you just changed — an inline `style` attribute, `!important`, a more specific selector, or a utility class applied after it. You have no way to see the rendered result, so if such a conflict exists, edit the highest-precedence source instead of (or in addition to) the one you found first, so the change actually takes visible effect. ' +
12
+ 'Stay terse: no narration between steps, and finish with at most one short sentence.';
13
+ function startSession(cwd, prompt) {
14
+ return spawn('codex', ['exec', '--json', '--skip-git-repo-check', '--sandbox', 'workspace-write', '--cd', cwd, prompt], {
15
+ stdio: ['ignore', 'pipe', 'pipe'],
16
+ });
17
+ }
18
+ function resumeSession(activeSession, prompt) {
19
+ return spawn('codex', ['exec', 'resume', activeSession.id, '--json', '--skip-git-repo-check', prompt], {
20
+ stdio: ['ignore', 'pipe', 'pipe'],
21
+ });
22
+ }
23
+ function sessionIdFromEvent(event) {
24
+ return typeof event?.thread_id === 'string' ? event.thread_id : null;
25
+ }
26
+ function describeEvent(event) {
27
+ const item = event?.item;
28
+ if (item?.type === 'command_execution' && item.command)
29
+ return `Running: ${String(item.command).slice(0, 60)}`;
30
+ if (item?.type === 'agent_message' && item.text)
31
+ return String(item.text).slice(0, 120);
32
+ if (item?.type === 'file_change' && item.path)
33
+ return `Editing ${item.path}`;
34
+ return null;
35
+ }
36
+ let cwd = process.cwd();
37
+ let session = null;
38
+ let child = null;
39
+ let busy = false; // a turn is currently running
40
+ // Which client to notify when the running turn finishes. Null for the startup
41
+ // turn that sends KAPI_SESSION_PROMPT — there's no tab yet.
42
+ let activeClient = null;
43
+ // FIFO queue of turns waiting for the current one to finish. The first entry,
44
+ // queued at server startup, is the KAPI_SESSION_PROMPT itself.
45
+ const pendingPrompts = [];
46
+ function processQueue() {
47
+ if (busy || pendingPrompts.length === 0)
48
+ return;
49
+ const next = pendingPrompts.shift();
50
+ busy = true;
51
+ activeClient = next.client;
52
+ next.client?.send('kapi:processing', { status: session ? 'Continuing session...' : 'Starting Codex...' });
53
+ const proc = session ? resumeSession(session, next.prompt) : startSession(cwd, next.prompt);
54
+ child = proc;
55
+ let buffer = '';
56
+ let failed = false;
57
+ const handleEvent = (event) => {
58
+ const nextSessionId = sessionIdFromEvent(event);
59
+ if (nextSessionId && nextSessionId !== session?.id) {
60
+ session = { agent: 'codex', id: nextSessionId };
61
+ console.log(`[kapi] codex session started: ${nextSessionId}`);
62
+ }
63
+ const status = describeEvent(event);
64
+ if (status)
65
+ activeClient?.send('kapi:processing', { status });
66
+ };
67
+ proc.stdout.on('data', (chunk) => {
68
+ buffer += chunk.toString();
69
+ let newlineIndex;
70
+ while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
71
+ const line = buffer.slice(0, newlineIndex).trim();
72
+ buffer = buffer.slice(newlineIndex + 1);
73
+ if (!line)
74
+ continue;
75
+ try {
76
+ handleEvent(JSON.parse(line));
77
+ }
78
+ catch {
79
+ // Ignore non-JSON progress from a CLI and continue reading its stream.
80
+ }
81
+ }
82
+ });
83
+ proc.stderr.pipe(process.stderr);
84
+ proc.on('error', (error) => {
85
+ failed = true;
86
+ activeClient?.send('kapi:error', { message: error.message });
87
+ });
88
+ proc.on('close', (code) => {
89
+ if (buffer.trim()) {
90
+ try {
91
+ handleEvent(JSON.parse(buffer));
92
+ }
93
+ catch {
94
+ // A final non-JSON line is only CLI output, not a Kapi protocol event.
95
+ }
96
+ }
97
+ if (code !== 0 && !proc.killed && !failed) {
98
+ activeClient?.send('kapi:error', { message: `codex exited with code ${code}.` });
99
+ }
100
+ else if (!failed) {
101
+ activeClient?.send('kapi:done');
102
+ }
103
+ if (child === proc)
104
+ child = null;
105
+ busy = false;
106
+ activeClient = null;
107
+ processQueue();
108
+ });
109
+ }
110
+ export const codexAgent = {
111
+ start(startCwd) {
112
+ cwd = startCwd;
113
+ // Establish the session up front (before any tab connects) so the user's
114
+ // first real submission resumes an existing session instead of creating one.
115
+ pendingPrompts.push({ prompt: KAPI_SESSION_PROMPT, client: null });
116
+ processQueue();
117
+ },
118
+ submit(prompt, client) {
119
+ pendingPrompts.push({ prompt, client });
120
+ processQueue();
121
+ },
122
+ stop(client) {
123
+ // Drop anything this client queued but hasn't started.
124
+ for (let i = pendingPrompts.length - 1; i >= 0; i--) {
125
+ if (pendingPrompts[i].client === client)
126
+ pendingPrompts.splice(i, 1);
127
+ }
128
+ // Kill the running turn only if it's this client's. The close handler
129
+ // clears state and starts the next queued turn (resuming the session).
130
+ if (activeClient === client && child) {
131
+ console.log('[kapi] stopping codex process');
132
+ child.kill();
133
+ }
134
+ },
135
+ onClose(client) {
136
+ codexAgent.stop(client);
137
+ },
138
+ };
@@ -0,0 +1,34 @@
1
+ import { defineNuxtModule, addVitePlugin } from '@nuxt/kit';
2
+ import kapiVitePlugin from './vite-plugin.js';
3
+ export default defineNuxtModule({
4
+ meta: {
5
+ name: 'kapi-ui',
6
+ configKey: 'kapi',
7
+ },
8
+ setup(options, nuxt) {
9
+ var _a;
10
+ if (!nuxt.options.dev)
11
+ return;
12
+ // The vite plugin does everything now — instrumentation, the overlay
13
+ // module, and wiring the agent onto Vite's HMR websocket (its
14
+ // configureServer hook). Registering it here means Nuxt's Vite server
15
+ // gets that wiring too; the overlay talks back over the same HMR channel
16
+ // Nitro already proxies, so there's no separate port to inject.
17
+ addVitePlugin(kapiVitePlugin(options));
18
+ // Nitro renders HTML, not Vite's transformIndexHtml, so the overlay script
19
+ // still has to be injected here via unhead. Nuxt mounts Vite's dev
20
+ // middleware under app.buildAssetsDir (e.g. /_nuxt/), not at the site
21
+ // root, so request it from under that prefix or Nitro's page renderer
22
+ // intercepts it before Vite ever sees it.
23
+ (_a = nuxt.options.app.head).script || (_a.script = []);
24
+ // Tell the overlay the agent session is off so it hides the AI button.
25
+ // Classic inline script runs before the deferred overlay module reads it.
26
+ if (options.agent === false) {
27
+ nuxt.options.app.head.script.push({ innerHTML: 'window.__KAPI_AGENT_ENABLED__=false' });
28
+ }
29
+ nuxt.options.app.head.script.push({
30
+ src: `${nuxt.options.app.buildAssetsDir}@kapi-ui/overlay`,
31
+ type: 'module',
32
+ });
33
+ },
34
+ });
@@ -0,0 +1,4 @@
1
+ // Shared type definitions for the node tier (vite plugin, nuxt module,
2
+ // agents). `KapiOptions` and `KapiAgent` are re-exported by vite-plugin.ts as
3
+ // part of the package's public API.
4
+ export {};
@@ -0,0 +1,213 @@
1
+ import path from 'path';
2
+ import { fileURLToPath } from 'url';
3
+ import { searchForWorkspaceRoot } from 'vite';
4
+ import { walk } from 'estree-walker';
5
+ import MagicString from 'magic-string';
6
+ import { SourceMapConsumer } from 'source-map-js';
7
+ import { claudeAgent } from './claude-agent.js';
8
+ import { codexAgent } from './codex-agent.js';
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
+ const overlayPath = path.resolve(__dirname, '../browser/overlay.js');
11
+ const traceRecordPath = path.resolve(__dirname, '../browser/trace-record.js');
12
+ // Names Vue's SFC compiler emits in a component's compiled render function
13
+ // for every vnode it creates. Wrapping calls to these — rather than parsing
14
+ // the original template — means coverage always matches whatever Vue itself
15
+ // just compiled, including dynamic `h()` calls a template-source parser would
16
+ // never see.
17
+ const VNODE_FUNCTIONS = [
18
+ 'h',
19
+ '_createElementVNode',
20
+ '_createElementBlock',
21
+ '_createBlock',
22
+ '_createVNode',
23
+ '_createStaticVNode',
24
+ ];
25
+ const vnodeCallRe = new RegExp(`\\b(?:${VNODE_FUNCTIONS.join('|')})\\(`);
26
+ const INSTRUMENTED_MARKER = '/* kapi-ui:instrumented */';
27
+ // Byte offset of the start of each line (line 1 starts at offset 0). Built
28
+ // once per file so looking up a call's line/column is a binary search
29
+ // instead of re-scanning from the start of the file for every vnode call.
30
+ function buildLineOffsets(code) {
31
+ const offsets = [0];
32
+ for (let i = 0; i < code.length; i++) {
33
+ if (code[i] === '\n')
34
+ offsets.push(i + 1);
35
+ }
36
+ return offsets;
37
+ }
38
+ function offsetToPos(lineOffsets, index) {
39
+ let lo = 0;
40
+ let hi = lineOffsets.length - 1;
41
+ while (lo < hi) {
42
+ const mid = (lo + hi + 1) >> 1;
43
+ if (lineOffsets[mid] <= index)
44
+ lo = mid;
45
+ else
46
+ hi = mid - 1;
47
+ }
48
+ return { line: lo + 1, column: index - lineOffsets[lo] };
49
+ }
50
+ function createUniqueIdentifier(base, identifiers) {
51
+ let identifier = base;
52
+ let suffix = 1;
53
+ while (identifiers.has(identifier)) {
54
+ identifier = `${base}_${suffix++}`;
55
+ }
56
+ identifiers.add(identifier);
57
+ return identifier;
58
+ }
59
+ // Normalize + validate the `agent` option once, up front. `agent` is required
60
+ // (no default): a missing or bad value throws at dev-server startup with a
61
+ // clear message instead of silently falling back to Claude — which would be
62
+ // especially confusing for a value the user meant as "off" (e.g. 'none').
63
+ function resolveAgent(agent) {
64
+ if (agent === undefined) {
65
+ throw new Error("[kapi-ui] The `agent` option is required. Set it to 'claude', 'codex', or false (manual copy/paste).");
66
+ }
67
+ if (agent === false)
68
+ return false; // manual copy/paste
69
+ if (agent === 'claude' || agent === 'codex')
70
+ return agent;
71
+ throw new Error(`[kapi-ui] Invalid \`agent\` option: ${JSON.stringify(agent)}. Use 'claude', 'codex', or false (manual copy/paste).`);
72
+ }
73
+ export default function kapi(options) {
74
+ const agent = resolveAgent(options?.agent);
75
+ let started = false;
76
+ return {
77
+ name: 'kapi-ui',
78
+ // Must run after @vitejs/plugin-vue has compiled templates into
79
+ // vnode-creation calls — this transform rewrites that compiled output,
80
+ // not the raw SFC source.
81
+ enforce: 'post',
82
+ apply: 'serve',
83
+ config() {
84
+ return {
85
+ server: {
86
+ fs: {
87
+ allow: [searchForWorkspaceRoot(process.cwd()), path.dirname(overlayPath)],
88
+ },
89
+ },
90
+ };
91
+ },
92
+ // Ride Vite's own HMR websocket instead of standing up a second server on
93
+ // its own port: the overlay talks to us via custom HMR events (see
94
+ // browser/socket.ts), and we dispatch them to the configured agent. In
95
+ // Nuxt this runs too, since nuxt-module.ts registers this same plugin.
96
+ configureServer(server) {
97
+ if (agent === false)
98
+ return; // manual copy/paste only — no agent session
99
+ if (started)
100
+ return; // configureServer can fire more than once; agent.start() must not
101
+ started = true;
102
+ const cwd = process.cwd();
103
+ const runtime = agent === 'codex' ? codexAgent : claudeAgent;
104
+ runtime.start(cwd);
105
+ // Vite hands the event handler a fresh WebSocketClient wrapper but a
106
+ // stable underlying `.socket` per connection; key one AgentClient per
107
+ // socket so submit/stop/close all agree on identity and share a sender.
108
+ const clients = new Map();
109
+ const clientFor = (c) => {
110
+ let existing = clients.get(c.socket);
111
+ if (!existing) {
112
+ existing = { send: (event, data) => c.send(event, data) };
113
+ clients.set(c.socket, existing);
114
+ }
115
+ return existing;
116
+ };
117
+ server.ws.on('kapi:submit', (data, client) => runtime.submit(data.prompt, clientFor(client), cwd));
118
+ server.ws.on('kapi:stop', (_data, client) => runtime.stop(clientFor(client)));
119
+ server.ws.on('connection', (socket) => {
120
+ socket.on('close', () => {
121
+ const existing = clients.get(socket);
122
+ if (existing) {
123
+ runtime.onClose(existing);
124
+ clients.delete(socket);
125
+ }
126
+ });
127
+ });
128
+ },
129
+ resolveId(id) {
130
+ if (id === '/@kapi-ui/overlay')
131
+ return overlayPath;
132
+ if (id === '/@kapi-ui/trace-record')
133
+ return traceRecordPath;
134
+ },
135
+ transform(code, id) {
136
+ // `this.environment` only exists on Vite 6+ (the Environment API); vite
137
+ // is a devDependency here, not a peerDependency, so kapi has no control
138
+ // over the Vite version in the host project. Treat its absence as
139
+ // "client" so pre-6 Vite still runs the transform instead of throwing
140
+ // on every module.
141
+ if (this.environment && this.environment.name !== 'client')
142
+ return;
143
+ if (id.includes('/node_modules/'))
144
+ return;
145
+ // Only Vue SFC compiled output contains this — cheap enough to check
146
+ // before parsing every transformed module in the project.
147
+ if (!code.includes('_sfc_render('))
148
+ return;
149
+ if (!vnodeCallRe.test(code))
150
+ return;
151
+ if (code.includes(INSTRUMENTED_MARKER))
152
+ return;
153
+ const map = this.getCombinedSourcemap();
154
+ const consumer = new SourceMapConsumer(map);
155
+ const s = new MagicString(code);
156
+ const ast = this.parse(code);
157
+ const lineOffsets = buildLineOffsets(code);
158
+ const identifiers = new Set();
159
+ let hit = false;
160
+ walk(ast, {
161
+ enter(node) {
162
+ if (node.type === 'Identifier')
163
+ identifiers.add(node.name);
164
+ },
165
+ });
166
+ // Vue's compiled render function shares the module's lexical scope, so
167
+ // reserve names not used anywhere in the parsed module before injecting
168
+ // either the import binding or the tracer helper.
169
+ const recordPositionIdentifier = createUniqueIdentifier('__kapiRecordPosition', identifiers);
170
+ const tracerIdentifier = createUniqueIdentifier('__kapiTracer', identifiers);
171
+ walk(ast, {
172
+ enter(node) {
173
+ if (node.type !== 'CallExpression' || node.callee.type !== 'Identifier')
174
+ return;
175
+ if (!VNODE_FUNCTIONS.includes(node.callee.name))
176
+ return;
177
+ const { start, end } = node;
178
+ const original = consumer.originalPositionFor(offsetToPos(lineOffsets, start));
179
+ if (original.source === null)
180
+ return;
181
+ hit = true;
182
+ s.appendLeft(start, `${tracerIdentifier}(${original.line},${original.column},`);
183
+ s.appendRight(end, ')');
184
+ },
185
+ });
186
+ if (!hit)
187
+ return;
188
+ const relativeFile = path.relative(process.cwd(), id.split('?')[0]);
189
+ s.prepend(`${INSTRUMENTED_MARKER}\nimport { recordPosition as ${recordPositionIdentifier} } from "/@kapi-ui/trace-record"\n`);
190
+ s.append(`\nfunction ${tracerIdentifier}(line, column, vnode) { return ${recordPositionIdentifier}(${JSON.stringify(relativeFile)}, line, column, vnode) }\n`);
191
+ return { code: s.toString(), map: s.generateMap({ hires: true }) };
192
+ },
193
+ transformIndexHtml() {
194
+ const tags = [
195
+ {
196
+ tag: 'script',
197
+ attrs: { type: 'module', src: '/@kapi-ui/overlay' },
198
+ injectTo: 'body',
199
+ },
200
+ ];
201
+ // Tell the overlay the agent session is off so it hides the AI button.
202
+ // Classic inline script runs before the deferred overlay module reads it.
203
+ if (agent === false) {
204
+ tags.unshift({
205
+ tag: 'script',
206
+ children: 'window.__KAPI_AGENT_ENABLED__=false',
207
+ injectTo: 'body',
208
+ });
209
+ }
210
+ return tags;
211
+ },
212
+ };
213
+ }
package/package.json CHANGED
@@ -1,20 +1,22 @@
1
1
  {
2
2
  "name": "kapi-ui",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Visually prompt your UI",
5
5
  "scripts": {
6
- "build": "tsc",
6
+ "build": "tsc && npm run copy:css",
7
+ "copy:css": "mkdir -p dist/browser/styles && cp src/browser/styles/*.css dist/browser/styles/",
8
+ "dev": "npm run copy:css && (node -e \"require('fs').watch('src/browser/styles',()=>require('child_process').execSync('npm run copy:css'))\" & tsc --watch --preserveWatchOutput)",
7
9
  "prepublishOnly": "npm run build"
8
10
  },
9
11
  "files": [
10
12
  "dist"
11
13
  ],
12
14
  "bin": {
13
- "kapi-ui": "dist/setup.js"
15
+ "kapi-ui": "dist/cli/setup.js"
14
16
  },
15
17
  "exports": {
16
- "./vite-plugin": "./dist/vite-plugin.js",
17
- "./nuxt": "./dist/nuxt-module.js"
18
+ "./vite-plugin": "./dist/node/vite-plugin.js",
19
+ "./nuxt": "./dist/node/nuxt-module.js"
18
20
  },
19
21
  "repository": {
20
22
  "type": "git",
@@ -23,7 +25,7 @@
23
25
  "keywords": [
24
26
  "ai",
25
27
  "design",
26
- "fronend"
28
+ "frontend"
27
29
  ],
28
30
  "author": "James Bermudo",
29
31
  "license": "ISC",
@@ -35,15 +37,14 @@
35
37
  "devDependencies": {
36
38
  "@nuxt/schema": "^3.14.0",
37
39
  "@types/node": "^26.1.0",
38
- "@types/prompts": "^2.4.9",
39
- "@types/ws": "^8.18.1",
40
40
  "typescript": "^6.0.3",
41
41
  "vite": "^8.1.3"
42
42
  },
43
43
  "dependencies": {
44
- "@nuxt/kit": "^3.14.0",
44
+ "@nuxt/kit": "^4.4.8",
45
+ "estree-walker": "^2.0.2",
46
+ "magic-string": "^0.30.21",
45
47
  "magicast": "^0.5.3",
46
- "prompts": "^2.4.2",
47
- "ws": "^8.21.0"
48
+ "source-map-js": "^1.2.1"
48
49
  }
49
50
  }