kanna-code 0.1.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.
Binary file
@@ -0,0 +1,14 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <link rel="icon" type="image/png" href="/favicon.png" />
7
+ <title>Kanna</title>
8
+ <script type="module" crossorigin src="/assets/index-DSpGrX6x.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-ClV0uXCn.css">
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ </body>
14
+ </html>
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "kanna-code",
3
+ "type": "module",
4
+ "version": "0.1.0",
5
+ "description": "Local-only project chat UI",
6
+ "bin": {
7
+ "kanna": "./bin/kanna"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/server/",
12
+ "src/shared/",
13
+ "dist/client/"
14
+ ],
15
+ "engines": {
16
+ "bun": ">=1.0.0"
17
+ },
18
+ "scripts": {
19
+ "build": "vite build",
20
+ "check": "tsc --noEmit && vite build",
21
+ "dev": "bun run ./scripts/dev.ts",
22
+ "dev:client": "vite --host 0.0.0.0 --port 5174",
23
+ "dev:server": "bun run ./src/server/cli.ts --no-open --port 3211",
24
+ "start": "bun run ./src/server/cli.ts",
25
+ "prepublishOnly": "vite build"
26
+ },
27
+ "dependencies": {
28
+ "@anthropic-ai/claude-agent-sdk": "^0.2.39"
29
+ },
30
+ "devDependencies": {
31
+ "@radix-ui/react-dialog": "^1.1.15",
32
+ "@radix-ui/react-popover": "^1.1.15",
33
+ "@radix-ui/react-tooltip": "^1.2.8",
34
+ "@tailwindcss/postcss": "^4.1.18",
35
+ "@tailwindcss/typography": "^0.5.19",
36
+ "@types/bun": "latest",
37
+ "@types/node": "^24.10.1",
38
+ "@types/react": "19.2.7",
39
+ "@types/react-dom": "19.2.3",
40
+ "@vitejs/plugin-react": "5.1.1",
41
+ "autoprefixer": "^10.4.23",
42
+ "class-variance-authority": "^0.7.1",
43
+ "clsx": "^2.1.1",
44
+ "lucide-react": "^0.562.0",
45
+ "react": "19.2.1",
46
+ "react-dom": "19.2.1",
47
+ "react-markdown": "^10.1.0",
48
+ "react-router-dom": "^7.12.0",
49
+ "remark-gfm": "^4.0.1",
50
+ "tailwind-merge": "^3.4.0",
51
+ "tailwindcss": "^4.1.18",
52
+ "typescript": "5.8.3",
53
+ "vite": "^6.0.0",
54
+ "zustand": "^5.0.10"
55
+ }
56
+ }
@@ -0,0 +1,379 @@
1
+ import { query, type CanUseTool, type PermissionResult, type Query } from "@anthropic-ai/claude-agent-sdk"
2
+ import type { ClientCommand } from "../shared/protocol"
3
+ import { SDK_CLIENT_APP } from "../shared/branding"
4
+ import type { KannaStatus, PendingToolSnapshot } from "../shared/types"
5
+ import { EventStore } from "./event-store"
6
+ import { generateTitleForChat } from "./generate-title"
7
+
8
+ const DEFAULT_MODEL = "opus"
9
+
10
+ const TOOLSET = [
11
+ "Skill",
12
+ "WebFetch",
13
+ "WebSearch",
14
+ "Task",
15
+ "TaskOutput",
16
+ "Bash",
17
+ "Glob",
18
+ "Grep",
19
+ "Read",
20
+ "Edit",
21
+ "Write",
22
+ "TodoWrite",
23
+ "KillShell",
24
+ "AskUserQuestion",
25
+ "EnterPlanMode",
26
+ "ExitPlanMode",
27
+ ] as const
28
+
29
+ interface PendingToolRequest {
30
+ toolUseId: string
31
+ toolName: "AskUserQuestion" | "ExitPlanMode"
32
+ input: Record<string, unknown>
33
+ resolve: (result: PermissionResult) => void
34
+ }
35
+
36
+ interface ActiveTurn {
37
+ chatId: string
38
+ query: Query
39
+ status: KannaStatus
40
+ pendingTool: PendingToolRequest | null
41
+ hasFinalResult: boolean
42
+ cancelRequested: boolean
43
+ cancelRecorded: boolean
44
+ }
45
+
46
+ interface AgentCoordinatorArgs {
47
+ store: EventStore
48
+ onStateChange: () => void
49
+ }
50
+
51
+ function buildUserPromptPayload(content: string) {
52
+ return JSON.stringify({ type: "user_prompt", content })
53
+ }
54
+
55
+ function buildToolResultPayload(toolUseId: string, body: unknown) {
56
+ return JSON.stringify({
57
+ type: "user",
58
+ message: {
59
+ role: "user",
60
+ content: [
61
+ {
62
+ type: "tool_result",
63
+ tool_use_id: toolUseId,
64
+ content: JSON.stringify(body),
65
+ },
66
+ ],
67
+ },
68
+ })
69
+ }
70
+
71
+ function buildCancelledPayload() {
72
+ return JSON.stringify({
73
+ type: "result",
74
+ subtype: "cancelled",
75
+ is_error: false,
76
+ duration_ms: 0,
77
+ result: "Interrupted by user",
78
+ })
79
+ }
80
+
81
+ function buildErrorPayload(message: string) {
82
+ return JSON.stringify({
83
+ type: "result",
84
+ subtype: "error",
85
+ is_error: true,
86
+ duration_ms: 0,
87
+ result: message,
88
+ })
89
+ }
90
+
91
+ function deriveChatTitle(content: string) {
92
+ const singleLine = content.replace(/\s+/g, " ").trim()
93
+ return singleLine.slice(0, 60) || "New Chat"
94
+ }
95
+
96
+ function toTranscriptEntry(message: string, messageId?: string) {
97
+ return {
98
+ _id: crypto.randomUUID(),
99
+ messageId,
100
+ message,
101
+ createdAt: Date.now(),
102
+ }
103
+ }
104
+
105
+ export class AgentCoordinator {
106
+ private readonly store: EventStore
107
+ private readonly onStateChange: () => void
108
+ readonly activeTurns = new Map<string, ActiveTurn>()
109
+
110
+ constructor(args: AgentCoordinatorArgs) {
111
+ this.store = args.store
112
+ this.onStateChange = args.onStateChange
113
+ }
114
+
115
+ getActiveStatuses() {
116
+ const statuses = new Map<string, KannaStatus>()
117
+ for (const [chatId, turn] of this.activeTurns.entries()) {
118
+ statuses.set(chatId, turn.status)
119
+ }
120
+ return statuses
121
+ }
122
+
123
+ getPendingTool(chatId: string): PendingToolSnapshot | null {
124
+ const pending = this.activeTurns.get(chatId)?.pendingTool
125
+ if (!pending) return null
126
+ return { toolUseId: pending.toolUseId, toolName: pending.toolName }
127
+ }
128
+
129
+ async send(command: Extract<ClientCommand, { type: "chat.send" }>) {
130
+ let chatId = command.chatId
131
+
132
+ if (!chatId) {
133
+ if (!command.projectId) {
134
+ throw new Error("Missing projectId for new chat")
135
+ }
136
+ const created = await this.store.createChat(command.projectId)
137
+ chatId = created.id
138
+ }
139
+
140
+ const chat = this.store.requireChat(chatId)
141
+ if (this.activeTurns.has(chatId)) {
142
+ throw new Error("Chat is already running")
143
+ }
144
+
145
+ const existingMessages = this.store.getMessages(chatId)
146
+ if (chat.title === "New Chat" && existingMessages.length === 0) {
147
+ // Immediate placeholder: truncated first message
148
+ await this.store.renameChat(chatId, deriveChatTitle(command.content))
149
+
150
+ // Fire-and-forget: generate a better title with Haiku in parallel
151
+ void generateTitleForChat(command.content)
152
+ .then(async (title) => {
153
+ if (title) {
154
+ await this.store.renameChat(chatId!, title)
155
+ this.onStateChange()
156
+ }
157
+ })
158
+ .catch(() => undefined)
159
+ }
160
+
161
+ await this.store.appendMessage(chatId, toTranscriptEntry(buildUserPromptPayload(command.content), crypto.randomUUID()))
162
+ await this.store.recordTurnStarted(chatId)
163
+
164
+ const canUseTool: CanUseTool = async (toolName, input, options) => {
165
+ if (toolName !== "AskUserQuestion" && toolName !== "ExitPlanMode") {
166
+ return {
167
+ behavior: "allow",
168
+ updatedInput: input,
169
+ }
170
+ }
171
+
172
+ const active = this.activeTurns.get(chatId!)
173
+ if (!active) {
174
+ return {
175
+ behavior: "deny",
176
+ message: "Chat turn ended unexpectedly",
177
+ }
178
+ }
179
+
180
+ active.status = "waiting_for_user"
181
+ this.onStateChange()
182
+
183
+ return await new Promise<PermissionResult>((resolve) => {
184
+ active.pendingTool = {
185
+ toolUseId: options.toolUseID,
186
+ toolName,
187
+ input,
188
+ resolve,
189
+ }
190
+ })
191
+ }
192
+
193
+ const project = this.store.getProject(chat.projectId)
194
+ if (!project) {
195
+ throw new Error("Project not found")
196
+ }
197
+
198
+ const q = query({
199
+ prompt: command.content,
200
+ options: {
201
+ cwd: project.localPath,
202
+ model: DEFAULT_MODEL,
203
+ resume: chat.resumeSessionId ?? undefined,
204
+ permissionMode: chat.planMode ? "plan" : "acceptEdits",
205
+ canUseTool,
206
+ tools: [...TOOLSET],
207
+ settingSources: ["user", "project", "local"],
208
+ env: {
209
+ ...process.env,
210
+ // CLAUDE_AGENT_SDK_CLIENT_APP: SDK_CLIENT_APP,
211
+ },
212
+ },
213
+ })
214
+
215
+ const active: ActiveTurn = {
216
+ chatId,
217
+ query: q,
218
+ status: "starting",
219
+ pendingTool: null,
220
+ hasFinalResult: false,
221
+ cancelRequested: false,
222
+ cancelRecorded: false,
223
+ }
224
+ this.activeTurns.set(chatId, active)
225
+ this.onStateChange()
226
+
227
+ void q.accountInfo()
228
+ .then(async (accountInfo) => {
229
+ await this.store.appendMessage(
230
+ chatId!,
231
+ toTranscriptEntry(JSON.stringify({ type: "system", subtype: "account_info", accountInfo }))
232
+ )
233
+ this.onStateChange()
234
+ })
235
+ .catch(() => undefined)
236
+
237
+ void this.runTurn(active)
238
+
239
+ return { chatId }
240
+ }
241
+
242
+ private async runTurn(active: ActiveTurn) {
243
+ try {
244
+ for await (const sdkMessage of active.query) {
245
+ const raw = JSON.stringify(sdkMessage)
246
+ const maybeMessageId = "uuid" in sdkMessage && sdkMessage.uuid ? String(sdkMessage.uuid) : crypto.randomUUID()
247
+
248
+ await this.store.appendMessage(active.chatId, toTranscriptEntry(raw, maybeMessageId))
249
+
250
+ const sessionId = "session_id" in sdkMessage && typeof sdkMessage.session_id === "string"
251
+ ? sdkMessage.session_id
252
+ : null
253
+ if (sessionId) {
254
+ await this.store.setResumeSession(active.chatId, sessionId)
255
+ }
256
+
257
+ if (sdkMessage.type === "system" && sdkMessage.subtype === "init") {
258
+ active.status = "running"
259
+ }
260
+
261
+ if (sdkMessage.type === "result") {
262
+ active.hasFinalResult = true
263
+ if (sdkMessage.is_error) {
264
+ const errorText = "errors" in sdkMessage && Array.isArray(sdkMessage.errors)
265
+ ? sdkMessage.errors.join("\n")
266
+ : "Turn failed"
267
+ await this.store.recordTurnFailed(active.chatId, errorText)
268
+ } else if (!active.cancelRequested) {
269
+ await this.store.recordTurnFinished(active.chatId)
270
+ }
271
+ }
272
+
273
+ this.onStateChange()
274
+ }
275
+ } catch (error) {
276
+ if (!active.cancelRequested) {
277
+ const message = error instanceof Error ? error.message : String(error)
278
+ await this.store.appendMessage(active.chatId, toTranscriptEntry(buildErrorPayload(message)))
279
+ await this.store.recordTurnFailed(active.chatId, message)
280
+ }
281
+ } finally {
282
+ if (active.cancelRequested && !active.cancelRecorded) {
283
+ await this.store.recordTurnCancelled(active.chatId)
284
+ }
285
+ active.query.close()
286
+ this.activeTurns.delete(active.chatId)
287
+ this.onStateChange()
288
+ }
289
+ }
290
+
291
+ async cancel(chatId: string) {
292
+ const active = this.activeTurns.get(chatId)
293
+ if (!active) return
294
+
295
+ active.cancelRequested = true
296
+ active.pendingTool = null
297
+
298
+ await this.store.appendMessage(chatId, toTranscriptEntry(buildCancelledPayload(), crypto.randomUUID()))
299
+ await this.store.recordTurnCancelled(chatId)
300
+ active.cancelRecorded = true
301
+ active.hasFinalResult = true
302
+
303
+ try {
304
+ await active.query.interrupt()
305
+ } catch {
306
+ active.query.close()
307
+ }
308
+
309
+ this.activeTurns.delete(chatId)
310
+ this.onStateChange()
311
+ }
312
+
313
+ async respondTool(command: Extract<ClientCommand, { type: "chat.respondTool" }>) {
314
+ const active = this.activeTurns.get(command.chatId)
315
+ if (!active || !active.pendingTool) {
316
+ throw new Error("No pending tool request")
317
+ }
318
+
319
+ const pending = active.pendingTool
320
+ if (pending.toolUseId !== command.toolUseId) {
321
+ throw new Error("Tool response does not match active request")
322
+ }
323
+
324
+ await this.store.appendMessage(
325
+ command.chatId,
326
+ toTranscriptEntry(buildToolResultPayload(command.toolUseId, command.result), crypto.randomUUID())
327
+ )
328
+
329
+ active.pendingTool = null
330
+ active.status = "running"
331
+
332
+ if (pending.toolName === "AskUserQuestion") {
333
+ const result = command.result as { questions?: unknown; answers?: unknown }
334
+ pending.resolve({
335
+ behavior: "allow",
336
+ updatedInput: {
337
+ ...(pending.input ?? {}),
338
+ questions: result.questions ?? (pending.input.questions as unknown),
339
+ answers: result.answers ?? result,
340
+ },
341
+ })
342
+ this.onStateChange()
343
+ return
344
+ }
345
+
346
+ const result = (command.result ?? {}) as {
347
+ confirmed?: boolean
348
+ clearContext?: boolean
349
+ message?: string
350
+ }
351
+
352
+ if (result.confirmed) {
353
+ await this.store.setPlanMode(command.chatId, false)
354
+ if (result.clearContext) {
355
+ await this.store.setResumeSession(command.chatId, null)
356
+ await this.store.appendMessage(
357
+ command.chatId,
358
+ toTranscriptEntry(JSON.stringify({ type: "system", subtype: "context_cleared" }), crypto.randomUUID())
359
+ )
360
+ }
361
+ pending.resolve({
362
+ behavior: "allow",
363
+ updatedInput: {
364
+ ...(pending.input ?? {}),
365
+ ...result,
366
+ },
367
+ })
368
+ } else {
369
+ pending.resolve({
370
+ behavior: "deny",
371
+ message: result.message
372
+ ? `User wants to suggest edits to the plan: ${result.message}`
373
+ : "User wants to suggest edits to the plan before approving.",
374
+ })
375
+ }
376
+
377
+ this.onStateChange()
378
+ }
379
+ }
@@ -0,0 +1,145 @@
1
+ import process from "node:process"
2
+ import { spawn, spawnSync } from "node:child_process"
3
+ import { APP_NAME, CLI_COMMAND, getDataDirDisplay, LOG_PREFIX, SDK_CLIENT_APP } from "../shared/branding"
4
+ import { PROD_SERVER_PORT } from "../shared/ports"
5
+ import { startKannaServer } from "./server"
6
+
7
+ const VERSION = SDK_CLIENT_APP.split("/")[1] ?? "0.0.0"
8
+
9
+ interface CliOptions {
10
+ port: number
11
+ openBrowser: boolean
12
+ }
13
+
14
+ function printHelp() {
15
+ console.log(`${APP_NAME} — local-only project chat UI
16
+
17
+ Usage:
18
+ ${CLI_COMMAND} [options]
19
+
20
+ Options:
21
+ --port <number> Port to listen on (default: ${PROD_SERVER_PORT})
22
+ --no-open Don't open browser automatically
23
+ --version Print version and exit
24
+ --help Show this help message`)
25
+ }
26
+
27
+ function parseArgs(argv: string[]): CliOptions {
28
+ let port = PROD_SERVER_PORT
29
+ let openBrowser = true
30
+
31
+ for (let index = 0; index < argv.length; index += 1) {
32
+ const arg = argv[index]
33
+ if (arg === "--version" || arg === "-v") {
34
+ console.log(VERSION)
35
+ process.exit(0)
36
+ }
37
+ if (arg === "--help" || arg === "-h") {
38
+ printHelp()
39
+ process.exit(0)
40
+ }
41
+ if (arg === "--port") {
42
+ const next = argv[index + 1]
43
+ if (!next) throw new Error("Missing value for --port")
44
+ port = Number(next)
45
+ index += 1
46
+ continue
47
+ }
48
+ if (arg === "--no-open") {
49
+ openBrowser = false
50
+ continue
51
+ }
52
+ if (!arg.startsWith("-")) throw new Error(`Unexpected positional argument: ${arg}`)
53
+ }
54
+
55
+ return {
56
+ port,
57
+ openBrowser,
58
+ }
59
+ }
60
+
61
+ function spawnDetached(command: string, args: string[]) {
62
+ spawn(command, args, { stdio: "ignore", detached: true }).unref()
63
+ }
64
+
65
+ function hasCommand(command: string) {
66
+ const result = spawnSync("sh", ["-lc", `command -v ${command}`], { stdio: "ignore" })
67
+ return result.status === 0
68
+ }
69
+
70
+ function canOpenMacApp(appName: string) {
71
+ const result = spawnSync("open", ["-Ra", appName], { stdio: "ignore" })
72
+ return result.status === 0
73
+ }
74
+
75
+ function openUrl(url: string) {
76
+ const platform = process.platform
77
+ if (platform === "darwin") {
78
+ const appCandidates = [
79
+ "Google Chrome",
80
+ "Chromium",
81
+ "Brave Browser",
82
+ "Microsoft Edge",
83
+ "Arc",
84
+ ]
85
+
86
+ for (const appName of appCandidates) {
87
+ if (!canOpenMacApp(appName)) continue
88
+ spawnDetached("open", ["-a", appName, "--args", `--app=${url}`])
89
+ console.log(`${LOG_PREFIX} opened in app window via ${appName}`)
90
+ return
91
+ }
92
+
93
+ spawnDetached("open", [url])
94
+ console.log(`${LOG_PREFIX} opened in default browser`)
95
+ return
96
+ }
97
+ if (platform === "win32") {
98
+ const browserCommands = ["chrome", "msedge", "brave", "chromium"]
99
+ for (const command of browserCommands) {
100
+ if (!hasCommand(command)) continue
101
+ spawnDetached(command, [`--app=${url}`])
102
+ console.log(`${LOG_PREFIX} opened in app window via ${command}`)
103
+ return
104
+ }
105
+
106
+ spawnDetached("cmd", ["/c", "start", "", url])
107
+ console.log(`${LOG_PREFIX} opened in default browser`)
108
+ return
109
+ }
110
+
111
+ const browserCommands = ["google-chrome", "chromium", "brave-browser", "microsoft-edge"]
112
+ for (const command of browserCommands) {
113
+ if (!hasCommand(command)) continue
114
+ spawnDetached(command, [`--app=${url}`])
115
+ console.log(`${LOG_PREFIX} opened in app window via ${command}`)
116
+ return
117
+ }
118
+
119
+ spawnDetached("xdg-open", [url])
120
+ console.log(`${LOG_PREFIX} opened in default browser`)
121
+ }
122
+
123
+ const options = parseArgs(process.argv.slice(2))
124
+ const { port, stop } = await startKannaServer(options)
125
+ const url = `http://localhost:${port}`
126
+ const launchUrl = `${url}/projects`
127
+
128
+ console.log(`${LOG_PREFIX} listening on ${url}`)
129
+ console.log(`${LOG_PREFIX} data dir: ${getDataDirDisplay()}`)
130
+
131
+ if (options.openBrowser) {
132
+ openUrl(launchUrl)
133
+ }
134
+
135
+ const shutdown = async () => {
136
+ await stop()
137
+ process.exit(0)
138
+ }
139
+
140
+ process.on("SIGINT", () => {
141
+ void shutdown()
142
+ })
143
+ process.on("SIGTERM", () => {
144
+ void shutdown()
145
+ })
@@ -0,0 +1,65 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs"
2
+ import { homedir } from "node:os"
3
+ import path from "node:path"
4
+
5
+ export interface DiscoveredProject {
6
+ localPath: string
7
+ title: string
8
+ modifiedAt: number
9
+ }
10
+
11
+ function resolveEncodedClaudePath(folderName: string) {
12
+ const segments = folderName.replace(/^-/, "").split("-").filter(Boolean)
13
+ let currentPath = ""
14
+ let remainingSegments = [...segments]
15
+
16
+ while (remainingSegments.length > 0) {
17
+ let found = false
18
+
19
+ for (let index = remainingSegments.length; index >= 1; index -= 1) {
20
+ const segment = remainingSegments.slice(0, index).join("-")
21
+ const candidate = `${currentPath}/${segment}`
22
+
23
+ if (existsSync(candidate)) {
24
+ currentPath = candidate
25
+ remainingSegments = remainingSegments.slice(index)
26
+ found = true
27
+ break
28
+ }
29
+ }
30
+
31
+ if (!found) {
32
+ const [head, ...tail] = remainingSegments
33
+ currentPath = `${currentPath}/${head}`
34
+ remainingSegments = tail
35
+ }
36
+ }
37
+
38
+ return currentPath || "/"
39
+ }
40
+
41
+ export function discoverClaudeProjects(homeDir: string = homedir()): DiscoveredProject[] {
42
+ const projectsDir = path.join(homeDir, ".claude", "projects")
43
+ if (!existsSync(projectsDir)) {
44
+ return []
45
+ }
46
+
47
+ const entries = readdirSync(projectsDir, { withFileTypes: true })
48
+ const projects: DiscoveredProject[] = []
49
+
50
+ for (const entry of entries) {
51
+ if (!entry.isDirectory()) continue
52
+
53
+ const resolvedPath = resolveEncodedClaudePath(entry.name)
54
+ if (!existsSync(resolvedPath)) continue
55
+
56
+ const stat = statSync(path.join(projectsDir, entry.name))
57
+ projects.push({
58
+ localPath: resolvedPath,
59
+ title: path.basename(resolvedPath) || resolvedPath,
60
+ modifiedAt: stat.mtimeMs,
61
+ })
62
+ }
63
+
64
+ return projects.sort((a, b) => b.modifiedAt - a.modifiedAt)
65
+ }