kanna-code 0.17.0 → 0.18.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.
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="icon" type="image/png" href="/favicon.png" />
7
7
  <title>Kanna</title>
8
- <script type="module" crossorigin src="/assets/index-CtKZIZdD.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-Cwex1U8S.css">
8
+ <script type="module" crossorigin src="/assets/index-CLqK0AWC.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-BvhI1JYs.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kanna-code",
3
3
  "type": "module",
4
- "version": "0.17.0",
4
+ "version": "0.18.0",
5
5
  "description": "A beautiful web UI for Claude Code",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -54,6 +54,7 @@
54
54
  "@xterm/xterm": "^6.0.0",
55
55
  "cloudflared": "^0.7.1",
56
56
  "default-shell": "^2.2.0",
57
+ "file-type": "^22.0.0",
57
58
  "qrcode": "^1.5.4",
58
59
  "react-resizable-panels": "^4.7.3"
59
60
  },
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test"
2
- import { AgentCoordinator, normalizeClaudeStreamMessage } from "./agent"
2
+ import { AgentCoordinator, buildAttachmentHintText, buildPromptText, normalizeClaudeStreamMessage } from "./agent"
3
3
  import type { HarnessTurn } from "./harness-types"
4
- import type { TranscriptEntry } from "../shared/types"
4
+ import type { ChatAttachment, TranscriptEntry } from "../shared/types"
5
5
 
6
6
  function timestamped<T extends Omit<TranscriptEntry, "_id" | "createdAt">>(entry: T): TranscriptEntry {
7
7
  return {
@@ -63,6 +63,68 @@ describe("normalizeClaudeStreamMessage", () => {
63
63
  })
64
64
  })
65
65
 
66
+ describe("attachment prompt helpers", () => {
67
+ test("appends a structured attachment hint block for all attachment kinds", () => {
68
+ const attachments: ChatAttachment[] = [
69
+ {
70
+ id: "image-1",
71
+ kind: "image",
72
+ displayName: "shot.png",
73
+ absolutePath: "/tmp/project/.kanna/uploads/shot.png",
74
+ relativePath: "./.kanna/uploads/shot.png",
75
+ contentUrl: "/api/projects/project-1/uploads/shot.png/content",
76
+ mimeType: "image/png",
77
+ size: 512,
78
+ },
79
+ {
80
+ id: "file-1",
81
+ kind: "file",
82
+ displayName: "spec.pdf",
83
+ absolutePath: "/tmp/project/.kanna/uploads/spec.pdf",
84
+ relativePath: "./.kanna/uploads/spec.pdf",
85
+ contentUrl: "/api/projects/project-1/uploads/spec.pdf/content",
86
+ mimeType: "application/pdf",
87
+ size: 1234,
88
+ },
89
+ ]
90
+
91
+ const prompt = buildPromptText("Review these", attachments)
92
+ expect(prompt).toContain("<kanna-attachments>")
93
+ expect(prompt).toContain('path="/tmp/project/.kanna/uploads/shot.png"')
94
+ expect(prompt).toContain('project_path="./.kanna/uploads/spec.pdf"')
95
+ })
96
+
97
+ test("supports attachment-only prompts", () => {
98
+ const attachments: ChatAttachment[] = [{
99
+ id: "file-1",
100
+ kind: "file",
101
+ displayName: "todo.txt",
102
+ absolutePath: "/tmp/project/.kanna/uploads/todo.txt",
103
+ relativePath: "./.kanna/uploads/todo.txt",
104
+ contentUrl: "/api/projects/project-1/uploads/todo.txt/content",
105
+ mimeType: "text/plain",
106
+ size: 32,
107
+ }]
108
+
109
+ expect(buildPromptText("", attachments)).toContain("Please inspect the attached files.")
110
+ })
111
+
112
+ test("escapes xml attribute values for attachment hint markup", () => {
113
+ const hint = buildAttachmentHintText([{
114
+ id: "file-1",
115
+ kind: "file",
116
+ displayName: "\"report\" <draft>.txt",
117
+ absolutePath: "/tmp/project/.kanna/uploads/report.txt",
118
+ relativePath: "./.kanna/uploads/report.txt",
119
+ contentUrl: "/api/projects/project-1/uploads/report.txt/content",
120
+ mimeType: "text/plain",
121
+ size: 64,
122
+ }])
123
+
124
+ expect(hint).toContain("&quot;report&quot; &lt;draft&gt;.txt")
125
+ })
126
+ })
127
+
66
128
  describe("AgentCoordinator codex integration", () => {
67
129
  test("generates a chat title in the background on the first user message", async () => {
68
130
  const fakeCodexManager = {
@@ -1,6 +1,7 @@
1
1
  import { query, type CanUseTool, type PermissionResult, type Query } from "@anthropic-ai/claude-agent-sdk"
2
2
  import type {
3
3
  AgentProvider,
4
+ ChatAttachment,
4
5
  NormalizedToolCall,
5
6
  PendingToolSnapshot,
6
7
  KannaStatus,
@@ -89,6 +90,41 @@ function stringFromUnknown(value: unknown) {
89
90
  }
90
91
  }
91
92
 
93
+ function escapeXmlAttribute(value: string) {
94
+ return value
95
+ .replaceAll("&", "&amp;")
96
+ .replaceAll("\"", "&quot;")
97
+ .replaceAll("<", "&lt;")
98
+ .replaceAll(">", "&gt;")
99
+ }
100
+
101
+ export function buildAttachmentHintText(attachments: ChatAttachment[]) {
102
+ if (attachments.length === 0) return ""
103
+
104
+ const lines = attachments.map((attachment) => (
105
+ `<attachment kind="${escapeXmlAttribute(attachment.kind)}" mime_type="${escapeXmlAttribute(attachment.mimeType)}" path="${escapeXmlAttribute(attachment.absolutePath)}" project_path="${escapeXmlAttribute(attachment.relativePath)}" size_bytes="${attachment.size}" display_name="${escapeXmlAttribute(attachment.displayName)}" />`
106
+ ))
107
+
108
+ return [
109
+ "<kanna-attachments>",
110
+ ...lines,
111
+ "</kanna-attachments>",
112
+ ].join("\n")
113
+ }
114
+
115
+ export function buildPromptText(content: string, attachments: ChatAttachment[]) {
116
+ const attachmentHint = buildAttachmentHintText(attachments)
117
+ if (!attachmentHint) {
118
+ return content.trim()
119
+ }
120
+
121
+ const trimmed = content.trim()
122
+ return [
123
+ trimmed || "Please inspect the attached files.",
124
+ attachmentHint,
125
+ ].join("\n\n").trim()
126
+ }
127
+
92
128
  function discardedToolResult(
93
129
  tool: NormalizedToolCall & { toolKind: "ask_user_question" | "exit_plan_mode" }
94
130
  ) {
@@ -389,6 +425,7 @@ export class AgentCoordinator {
389
425
  chatId: string
390
426
  provider: AgentProvider
391
427
  content: string
428
+ attachments: ChatAttachment[]
392
429
  model: string
393
430
  effort?: string
394
431
  serviceTier?: "fast"
@@ -409,7 +446,10 @@ export class AgentCoordinator {
409
446
  const shouldGenerateTitle = args.appendUserPrompt && chat.title === "New Chat" && existingMessages.length === 0
410
447
 
411
448
  if (args.appendUserPrompt) {
412
- await this.store.appendMessage(args.chatId, timestamped({ kind: "user_prompt", content: args.content }, Date.now()))
449
+ await this.store.appendMessage(
450
+ args.chatId,
451
+ timestamped({ kind: "user_prompt", content: args.content, attachments: args.attachments }, Date.now())
452
+ )
413
453
  }
414
454
  await this.store.recordTurnStarted(args.chatId)
415
455
 
@@ -443,7 +483,7 @@ export class AgentCoordinator {
443
483
  let turn: HarnessTurn
444
484
  if (args.provider === "claude") {
445
485
  turn = await startClaudeTurn({
446
- content: args.content,
486
+ content: buildPromptText(args.content, args.attachments),
447
487
  localPath: project.localPath,
448
488
  model: args.model,
449
489
  effort: args.effort,
@@ -461,7 +501,7 @@ export class AgentCoordinator {
461
501
  })
462
502
  turn = await this.codexManager.startTurn({
463
503
  chatId: args.chatId,
464
- content: args.content,
504
+ content: buildPromptText(args.content, args.attachments),
465
505
  model: args.model,
466
506
  effort: args.effort as any,
467
507
  serviceTier: args.serviceTier,
@@ -519,6 +559,7 @@ export class AgentCoordinator {
519
559
  chatId,
520
560
  provider,
521
561
  content: command.content,
562
+ attachments: command.attachments ?? [],
522
563
  model: settings.model,
523
564
  effort: settings.effort,
524
565
  serviceTier: settings.serviceTier,
@@ -600,6 +641,7 @@ export class AgentCoordinator {
600
641
  chatId: active.chatId,
601
642
  provider: active.provider,
602
643
  content: active.postToolFollowUp.content,
644
+ attachments: [],
603
645
  model: active.model,
604
646
  effort: active.effort,
605
647
  serviceTier: active.serviceTier,
@@ -25,3 +25,7 @@ export async function ensureProjectDirectory(localPath: string) {
25
25
  throw new Error("Project path must be a directory")
26
26
  }
27
27
  }
28
+
29
+ export function getProjectUploadDir(localPath: string) {
30
+ return path.join(resolveLocalPath(localPath), ".kanna", "uploads")
31
+ }
@@ -1,5 +1,7 @@
1
1
  import path from "node:path"
2
+ import { stat } from "node:fs/promises"
2
3
  import { APP_NAME, getRuntimeProfile } from "../shared/branding"
4
+ import type { ChatAttachment } from "../shared/types"
3
5
  import { EventStore } from "./event-store"
4
6
  import { AgentCoordinator } from "./agent"
5
7
  import { discoverProjects, type DiscoveredProject } from "./discovery"
@@ -9,6 +11,45 @@ import { TerminalManager } from "./terminal-manager"
9
11
  import { UpdateManager } from "./update-manager"
10
12
  import type { UpdateInstallAttemptResult } from "./cli-runtime"
11
13
  import { createWsRouter, type ClientState } from "./ws-router"
14
+ import { deleteProjectUpload, inferAttachmentContentType, persistProjectUpload } from "./uploads"
15
+ import { getProjectUploadDir } from "./paths"
16
+
17
+ const MAX_UPLOAD_FILES = 10
18
+ const MAX_UPLOAD_SIZE_BYTES = 25 * 1024 * 1024
19
+
20
+ export async function persistUploadedFiles(args: {
21
+ projectId: string
22
+ localPath: string
23
+ files: File[]
24
+ persistUpload?: typeof persistProjectUpload
25
+ }): Promise<ChatAttachment[]> {
26
+ const persistUpload = args.persistUpload ?? persistProjectUpload
27
+ const attachments: ChatAttachment[] = []
28
+
29
+ try {
30
+ for (const file of args.files) {
31
+ const bytes = new Uint8Array(await file.arrayBuffer())
32
+ const attachment = await persistUpload({
33
+ projectId: args.projectId,
34
+ localPath: args.localPath,
35
+ fileName: file.name,
36
+ bytes,
37
+ fallbackMimeType: file.type || undefined,
38
+ })
39
+ attachments.push(attachment)
40
+ }
41
+ } catch (error) {
42
+ await Promise.allSettled(
43
+ attachments.map((attachment) => deleteProjectUpload({
44
+ localPath: args.localPath,
45
+ storedName: path.basename(attachment.absolutePath),
46
+ }))
47
+ )
48
+ throw error
49
+ }
50
+
51
+ return attachments
52
+ }
12
53
 
13
54
  export interface StartKannaServerOptions {
14
55
  port?: number
@@ -79,7 +120,7 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
79
120
  server = Bun.serve<ClientState>({
80
121
  port: actualPort,
81
122
  hostname,
82
- fetch(req, serverInstance) {
123
+ async fetch(req, serverInstance) {
83
124
  const url = new URL(req.url)
84
125
 
85
126
  if (url.pathname === "/ws") {
@@ -95,6 +136,21 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
95
136
  return Response.json({ ok: true, port: actualPort })
96
137
  }
97
138
 
139
+ const uploadResponse = await handleProjectUpload(req, url, store)
140
+ if (uploadResponse) {
141
+ return uploadResponse
142
+ }
143
+
144
+ const deleteUploadResponse = await handleProjectUploadDelete(req, url, store)
145
+ if (deleteUploadResponse) {
146
+ return deleteUploadResponse
147
+ }
148
+
149
+ const attachmentContentResponse = await handleAttachmentContent(req, url, store)
150
+ if (attachmentContentResponse) {
151
+ return attachmentContentResponse
152
+ }
153
+
98
154
  return serveStatic(distDir, url.pathname)
99
155
  },
100
156
  websocket: {
@@ -140,6 +196,127 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
140
196
  }
141
197
  }
142
198
 
199
+ async function handleProjectUpload(req: Request, url: URL, store: EventStore) {
200
+ if (req.method !== "POST") {
201
+ return null
202
+ }
203
+
204
+ const match = url.pathname.match(/^\/api\/projects\/([^/]+)\/uploads$/)
205
+ if (!match) {
206
+ return null
207
+ }
208
+
209
+ const project = store.getProject(match[1])
210
+ if (!project) {
211
+ return Response.json({ error: "Project not found" }, { status: 404 })
212
+ }
213
+
214
+ const formData = await req.formData()
215
+ const files = formData
216
+ .getAll("files")
217
+ .filter((value): value is File => value instanceof File)
218
+
219
+ if (files.length === 0) {
220
+ return Response.json({ error: "No files uploaded" }, { status: 400 })
221
+ }
222
+
223
+ if (files.length > MAX_UPLOAD_FILES) {
224
+ return Response.json({ error: `You can upload up to ${MAX_UPLOAD_FILES} files at a time.` }, { status: 400 })
225
+ }
226
+
227
+ for (const file of files) {
228
+ if (file.size > MAX_UPLOAD_SIZE_BYTES) {
229
+ return Response.json(
230
+ { error: `File "${file.name}" exceeds the ${Math.floor(MAX_UPLOAD_SIZE_BYTES / (1024 * 1024))} MB limit.` },
231
+ { status: 413 }
232
+ )
233
+ }
234
+ }
235
+
236
+ try {
237
+ const attachments = await persistUploadedFiles({
238
+ projectId: project.id,
239
+ localPath: project.localPath,
240
+ files,
241
+ })
242
+ return Response.json({ attachments })
243
+ } catch (error) {
244
+ console.error("[uploads] Upload failed:", error)
245
+ return Response.json({ error: "Upload failed" }, { status: 500 })
246
+ }
247
+ }
248
+
249
+ async function handleAttachmentContent(req: Request, url: URL, store: EventStore) {
250
+ const match = url.pathname.match(/^\/api\/projects\/([^/]+)\/uploads\/([^/]+)\/content$/)
251
+ if (!match) {
252
+ return null
253
+ }
254
+
255
+ if (req.method !== "GET") {
256
+ return new Response(null, {
257
+ status: 405,
258
+ headers: {
259
+ Allow: "GET",
260
+ },
261
+ })
262
+ }
263
+
264
+ const project = store.getProject(match[1])
265
+ if (!project) {
266
+ return Response.json({ error: "Project not found" }, { status: 404 })
267
+ }
268
+
269
+ const storedName = decodeURIComponent(match[2])
270
+ if (!storedName || storedName.includes("/") || storedName.includes("\\") || storedName === "." || storedName === "..") {
271
+ return Response.json({ error: "Invalid attachment path" }, { status: 400 })
272
+ }
273
+
274
+ const filePath = path.join(getProjectUploadDir(project.localPath), storedName)
275
+ const file = Bun.file(filePath)
276
+ try {
277
+ const info = await stat(filePath)
278
+ if (!info.isFile()) {
279
+ return Response.json({ error: "Attachment not found" }, { status: 404 })
280
+ }
281
+ } catch {
282
+ return Response.json({ error: "Attachment not found" }, { status: 404 })
283
+ }
284
+
285
+ return new Response(file, {
286
+ headers: {
287
+ "Content-Type": inferAttachmentContentType(storedName, file.type),
288
+ },
289
+ })
290
+ }
291
+
292
+ async function handleProjectUploadDelete(req: Request, url: URL, store: EventStore) {
293
+ if (req.method !== "DELETE") {
294
+ return null
295
+ }
296
+
297
+ const match = url.pathname.match(/^\/api\/projects\/([^/]+)\/uploads\/([^/]+)$/)
298
+ if (!match) {
299
+ return null
300
+ }
301
+
302
+ const project = store.getProject(match[1])
303
+ if (!project) {
304
+ return Response.json({ error: "Project not found" }, { status: 404 })
305
+ }
306
+
307
+ const storedName = decodeURIComponent(match[2])
308
+ if (!storedName || storedName.includes("/") || storedName.includes("\\") || storedName === "." || storedName === "..") {
309
+ return Response.json({ error: "Invalid attachment path" }, { status: 400 })
310
+ }
311
+
312
+ const deleted = await deleteProjectUpload({
313
+ localPath: project.localPath,
314
+ storedName,
315
+ })
316
+
317
+ return Response.json({ ok: deleted })
318
+ }
319
+
143
320
  async function serveStatic(distDir: string, pathname: string) {
144
321
  const requestedPath = pathname === "/" ? "/index.html" : pathname
145
322
  const filePath = path.join(distDir, requestedPath)