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.
@@ -0,0 +1,282 @@
1
+ import { afterEach, describe, expect, test } from "bun:test"
2
+ import { mkdtemp, rm } from "node:fs/promises"
3
+ import { tmpdir } from "node:os"
4
+ import path from "node:path"
5
+ import { deleteProjectUpload, inferAttachmentContentType, persistProjectUpload } from "./uploads"
6
+ import { getProjectUploadDir } from "./paths"
7
+ import { persistUploadedFiles, startKannaServer } from "./server"
8
+
9
+ const PNG_BASE64 =
10
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+yF9sAAAAASUVORK5CYII="
11
+
12
+ const tempDirs: string[] = []
13
+
14
+ afterEach(async () => {
15
+ await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
16
+ })
17
+
18
+ describe("uploads", () => {
19
+ test("stores uploads in .kanna/uploads and keeps duplicate filenames", async () => {
20
+ const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-upload-test-"))
21
+ tempDirs.push(projectDir)
22
+
23
+ const first = await persistProjectUpload({
24
+ projectId: "project-1",
25
+ localPath: projectDir,
26
+ fileName: "notes.txt",
27
+ bytes: new TextEncoder().encode("hello"),
28
+ fallbackMimeType: "text/plain",
29
+ })
30
+ const second = await persistProjectUpload({
31
+ projectId: "project-1",
32
+ localPath: projectDir,
33
+ fileName: "notes.txt",
34
+ bytes: new TextEncoder().encode("world"),
35
+ fallbackMimeType: "text/plain",
36
+ })
37
+
38
+ expect(first.absolutePath).toBe(path.join(projectDir, ".kanna/uploads/notes.txt"))
39
+ expect(first.relativePath).toBe("./.kanna/uploads/notes.txt")
40
+ expect(first.contentUrl).toBe("/api/projects/project-1/uploads/notes.txt/content")
41
+ expect(second.absolutePath).toBe(path.join(projectDir, ".kanna/uploads/notes-1.txt"))
42
+ expect(second.relativePath).toBe("./.kanna/uploads/notes-1.txt")
43
+ expect(second.contentUrl).toBe("/api/projects/project-1/uploads/notes-1.txt/content")
44
+ expect(await Bun.file(path.join(projectDir, ".kanna/uploads/notes.txt")).text()).toBe("hello")
45
+ expect(await Bun.file(path.join(projectDir, ".kanna/uploads/notes-1.txt")).text()).toBe("world")
46
+ })
47
+
48
+ test("stores concurrent same-name uploads without overwriting existing content", async () => {
49
+ const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-upload-concurrent-"))
50
+ tempDirs.push(projectDir)
51
+
52
+ const attachments = await Promise.all([
53
+ persistProjectUpload({
54
+ projectId: "project-1",
55
+ localPath: projectDir,
56
+ fileName: "notes.txt",
57
+ bytes: new TextEncoder().encode("first"),
58
+ fallbackMimeType: "text/plain",
59
+ }),
60
+ persistProjectUpload({
61
+ projectId: "project-1",
62
+ localPath: projectDir,
63
+ fileName: "notes.txt",
64
+ bytes: new TextEncoder().encode("second"),
65
+ fallbackMimeType: "text/plain",
66
+ }),
67
+ persistProjectUpload({
68
+ projectId: "project-1",
69
+ localPath: projectDir,
70
+ fileName: "notes.txt",
71
+ bytes: new TextEncoder().encode("third"),
72
+ fallbackMimeType: "text/plain",
73
+ }),
74
+ ])
75
+
76
+ const storedNames = attachments.map((attachment) => path.basename(attachment.absolutePath)).sort()
77
+ expect(storedNames).toEqual(["notes-1.txt", "notes-2.txt", "notes.txt"])
78
+
79
+ const contents = await Promise.all(attachments.map((attachment) => Bun.file(attachment.absolutePath).text()))
80
+ expect(new Set(contents)).toEqual(new Set(["first", "second", "third"]))
81
+ })
82
+
83
+ test("detects image uploads and returns absolute plus project-relative paths", async () => {
84
+ const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-upload-image-"))
85
+ tempDirs.push(projectDir)
86
+
87
+ const attachment = await persistProjectUpload({
88
+ projectId: "project-2",
89
+ localPath: projectDir,
90
+ fileName: "pixel.png",
91
+ bytes: Buffer.from(PNG_BASE64, "base64"),
92
+ })
93
+
94
+ expect(attachment.kind).toBe("image")
95
+ expect(attachment.mimeType).toBe("image/png")
96
+ expect(getProjectUploadDir(projectDir)).toBe(path.join(projectDir, ".kanna", "uploads"))
97
+ expect(attachment.absolutePath).toBe(path.join(projectDir, ".kanna/uploads/pixel.png"))
98
+ expect(attachment.relativePath).toBe("./.kanna/uploads/pixel.png")
99
+ expect(attachment.contentUrl).toBe("/api/projects/project-2/uploads/pixel.png/content")
100
+ })
101
+
102
+ test("serves uploaded attachment content through the project content URL", async () => {
103
+ const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-project-"))
104
+ tempDirs.push(projectDir)
105
+
106
+ const server = await startKannaServer({ port: 4310, strictPort: true })
107
+
108
+ try {
109
+ const project = await server.store.openProject(projectDir, "Project")
110
+ const attachment = await persistProjectUpload({
111
+ projectId: project.id,
112
+ localPath: projectDir,
113
+ fileName: "hello.txt",
114
+ bytes: new TextEncoder().encode("hello from upload"),
115
+ fallbackMimeType: "text/plain",
116
+ })
117
+
118
+ const response = await fetch(`http://localhost:${server.port}${attachment.contentUrl}`)
119
+ expect(response.status).toBe(200)
120
+ expect(response.headers.get("content-type")).toBe("text/plain; charset=utf-8")
121
+ expect(await response.text()).toBe("hello from upload")
122
+ } finally {
123
+ await server.stop()
124
+ }
125
+ })
126
+
127
+ test("serves TypeScript uploads as text content", async () => {
128
+ const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-project-typescript-"))
129
+ tempDirs.push(projectDir)
130
+
131
+ const server = await startKannaServer({ port: 4314, strictPort: true })
132
+
133
+ try {
134
+ const project = await server.store.openProject(projectDir, "Project")
135
+ const attachment = await persistProjectUpload({
136
+ projectId: project.id,
137
+ localPath: projectDir,
138
+ fileName: "main.ts",
139
+ bytes: new TextEncoder().encode("export const value = 1\n"),
140
+ fallbackMimeType: "video/mp2t",
141
+ })
142
+
143
+ const response = await fetch(`http://localhost:${server.port}${attachment.contentUrl}`)
144
+ expect(response.status).toBe(200)
145
+ expect(response.headers.get("content-type")).toBe("text/plain; charset=utf-8")
146
+ expect(await response.text()).toContain("export const value = 1")
147
+ } finally {
148
+ await server.stop()
149
+ }
150
+ })
151
+
152
+ test("rejects non-GET requests for attachment content", async () => {
153
+ const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-project-content-method-"))
154
+ tempDirs.push(projectDir)
155
+
156
+ const server = await startKannaServer({ port: 4312, strictPort: true })
157
+
158
+ try {
159
+ const project = await server.store.openProject(projectDir, "Project")
160
+ const attachment = await persistProjectUpload({
161
+ projectId: project.id,
162
+ localPath: projectDir,
163
+ fileName: "hello.txt",
164
+ bytes: new TextEncoder().encode("hello from upload"),
165
+ fallbackMimeType: "text/plain",
166
+ })
167
+
168
+ const response = await fetch(`http://localhost:${server.port}${attachment.contentUrl}`, { method: "POST" })
169
+ expect(response.status).toBe(405)
170
+ expect(response.headers.get("allow")).toBe("GET")
171
+ } finally {
172
+ await server.stop()
173
+ }
174
+ })
175
+
176
+ test("rejects oversized uploads before reading them into memory", async () => {
177
+ const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-project-oversize-"))
178
+ tempDirs.push(projectDir)
179
+
180
+ const server = await startKannaServer({ port: 4313, strictPort: true })
181
+
182
+ try {
183
+ const project = await server.store.openProject(projectDir, "Project")
184
+ const formData = new FormData()
185
+ formData.append("files", new File([new Uint8Array(25 * 1024 * 1024 + 1)], "big.bin", { type: "application/octet-stream" }))
186
+
187
+ const response = await fetch(`http://localhost:${server.port}/api/projects/${project.id}/uploads`, {
188
+ method: "POST",
189
+ body: formData,
190
+ })
191
+
192
+ expect(response.status).toBe(413)
193
+ expect(await response.json()).toEqual({
194
+ error: "File \"big.bin\" exceeds the 25 MB limit.",
195
+ })
196
+ } finally {
197
+ await server.stop()
198
+ }
199
+ })
200
+
201
+ test("cleans up already-persisted files when a later file in the batch fails", async () => {
202
+ const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-project-cleanup-"))
203
+ tempDirs.push(projectDir)
204
+
205
+ const files = [
206
+ new File(["first"], "first.txt", { type: "text/plain" }),
207
+ new File(["second"], "second.txt", { type: "text/plain" }),
208
+ ]
209
+
210
+ await expect(
211
+ persistUploadedFiles({
212
+ projectId: "project-4",
213
+ localPath: projectDir,
214
+ files,
215
+ persistUpload: async (args) => {
216
+ if (args.fileName === "second.txt") {
217
+ throw new Error("disk full")
218
+ }
219
+
220
+ return persistProjectUpload(args)
221
+ },
222
+ })
223
+ ).rejects.toThrow("disk full")
224
+
225
+ expect(await Bun.file(path.join(projectDir, ".kanna/uploads/first.txt")).exists()).toBe(false)
226
+ expect(await Bun.file(path.join(projectDir, ".kanna/uploads/second.txt")).exists()).toBe(false)
227
+ })
228
+
229
+ test("deletes uploaded attachments from the project uploads directory", async () => {
230
+ const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-upload-delete-"))
231
+ tempDirs.push(projectDir)
232
+
233
+ const attachment = await persistProjectUpload({
234
+ projectId: "project-3",
235
+ localPath: projectDir,
236
+ fileName: "delete-me.txt",
237
+ bytes: new TextEncoder().encode("bye"),
238
+ fallbackMimeType: "text/plain",
239
+ })
240
+
241
+ const deleted = await deleteProjectUpload({
242
+ localPath: projectDir,
243
+ storedName: "delete-me.txt",
244
+ })
245
+
246
+ expect(deleted).toBe(true)
247
+ expect(await Bun.file(attachment.absolutePath).exists()).toBe(false)
248
+ })
249
+
250
+ test("deletes uploaded attachment content through the project delete URL", async () => {
251
+ const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-project-delete-"))
252
+ tempDirs.push(projectDir)
253
+
254
+ const server = await startKannaServer({ port: 4311, strictPort: true })
255
+
256
+ try {
257
+ const project = await server.store.openProject(projectDir, "Project")
258
+ const attachment = await persistProjectUpload({
259
+ projectId: project.id,
260
+ localPath: projectDir,
261
+ fileName: "bye.txt",
262
+ bytes: new TextEncoder().encode("delete over http"),
263
+ fallbackMimeType: "text/plain",
264
+ })
265
+
266
+ const deleteUrl = `http://localhost:${server.port}${attachment.contentUrl.replace(/\/content$/, "")}`
267
+ const response = await fetch(deleteUrl, { method: "DELETE" })
268
+ expect(response.status).toBe(200)
269
+ expect(await response.json()).toEqual({ ok: true })
270
+ expect(await Bun.file(attachment.absolutePath).exists()).toBe(false)
271
+ } finally {
272
+ await server.stop()
273
+ }
274
+ })
275
+
276
+ test("infers text-friendly content types for previewable source files", () => {
277
+ expect(inferAttachmentContentType("notes.txt")).toBe("text/plain; charset=utf-8")
278
+ expect(inferAttachmentContentType("README.md")).toBe("text/markdown; charset=utf-8")
279
+ expect(inferAttachmentContentType("main.ts", "video/mp2t")).toBe("text/plain; charset=utf-8")
280
+ expect(inferAttachmentContentType("archive.zip", "application/zip")).toBe("application/zip")
281
+ })
282
+ })
@@ -0,0 +1,127 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import { mkdir, open, rm } from "node:fs/promises"
3
+ import path from "node:path"
4
+ import { fileTypeFromBuffer } from "file-type"
5
+ import type { ChatAttachment } from "../shared/types"
6
+ import { getProjectUploadDir } from "./paths"
7
+
8
+ const DEFAULT_BINARY_MIME_TYPE = "application/octet-stream"
9
+ const IMAGE_MIME_PREFIX = "image/"
10
+ const TEXT_PLAIN_CONTENT_TYPE = "text/plain; charset=utf-8"
11
+
12
+ const TEXT_CONTENT_TYPE_BY_EXTENSION = new Map<string, string>([
13
+ [".csv", "text/csv; charset=utf-8"],
14
+ [".json", "application/json; charset=utf-8"],
15
+ [".jsonc", TEXT_PLAIN_CONTENT_TYPE],
16
+ [".md", "text/markdown; charset=utf-8"],
17
+ [".tsv", "text/tab-separated-values; charset=utf-8"],
18
+ ])
19
+
20
+ const TEXT_LIKE_EXTENSIONS = new Set([
21
+ ".c", ".cc", ".cfg", ".conf", ".cpp", ".cs", ".css", ".env", ".go", ".graphql", ".h", ".hpp", ".html",
22
+ ".ini", ".java", ".js", ".jsx", ".kt", ".lua", ".mjs", ".php", ".pl", ".properties", ".py", ".rb", ".rs",
23
+ ".scss", ".sh", ".sql", ".swift", ".toml", ".ts", ".tsx", ".txt", ".vue", ".xml", ".yaml", ".yml", ".zsh",
24
+ ])
25
+
26
+ function sanitizeFileName(fileName: string) {
27
+ const baseName = path.basename(fileName).trim()
28
+ const cleaned = baseName.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "")
29
+ return cleaned || "upload"
30
+ }
31
+
32
+ function getUploadCandidateNames(originalName: string) {
33
+ const sanitizedName = sanitizeFileName(originalName)
34
+ const parsed = path.parse(sanitizedName)
35
+ const extension = parsed.ext
36
+ const name = parsed.name || "upload"
37
+
38
+ return {
39
+ first: sanitizedName,
40
+ withCounter(counter: number) {
41
+ return `${name}-${counter}${extension}`
42
+ },
43
+ }
44
+ }
45
+
46
+ export async function persistProjectUpload(args: {
47
+ projectId: string
48
+ localPath: string
49
+ fileName: string
50
+ bytes: Uint8Array
51
+ fallbackMimeType?: string
52
+ }): Promise<ChatAttachment> {
53
+ const uploadDir = getProjectUploadDir(args.localPath)
54
+ await mkdir(uploadDir, { recursive: true })
55
+
56
+ const detectedType = await fileTypeFromBuffer(args.bytes)
57
+ const mimeType = detectedType?.mime ?? args.fallbackMimeType ?? DEFAULT_BINARY_MIME_TYPE
58
+ const candidates = getUploadCandidateNames(args.fileName)
59
+
60
+ let storedName = candidates.first
61
+ let absolutePath = path.join(uploadDir, storedName)
62
+ let counter = 1
63
+
64
+ while (true) {
65
+ try {
66
+ const handle = await open(absolutePath, "wx")
67
+ try {
68
+ await handle.writeFile(args.bytes)
69
+ } finally {
70
+ await handle.close()
71
+ }
72
+ break
73
+ } catch (error) {
74
+ const code = error instanceof Error && "code" in error ? (error as NodeJS.ErrnoException).code : undefined
75
+ if (code !== "EEXIST") {
76
+ throw error
77
+ }
78
+
79
+ storedName = candidates.withCounter(counter)
80
+ absolutePath = path.join(uploadDir, storedName)
81
+ counter += 1
82
+ }
83
+ }
84
+
85
+ return {
86
+ id: randomUUID(),
87
+ kind: mimeType.startsWith(IMAGE_MIME_PREFIX) ? "image" : "file",
88
+ displayName: args.fileName,
89
+ absolutePath,
90
+ relativePath: `./.kanna/uploads/${storedName}`,
91
+ contentUrl: `/api/projects/${args.projectId}/uploads/${encodeURIComponent(storedName)}/content`,
92
+ mimeType,
93
+ size: args.bytes.byteLength,
94
+ }
95
+ }
96
+
97
+ export function inferAttachmentContentType(fileName: string, fallbackType?: string): string {
98
+ const extension = path.extname(fileName).toLowerCase()
99
+ const mappedType = TEXT_CONTENT_TYPE_BY_EXTENSION.get(extension)
100
+ if (mappedType) {
101
+ return mappedType
102
+ }
103
+
104
+ if (TEXT_LIKE_EXTENSIONS.has(extension)) {
105
+ return TEXT_PLAIN_CONTENT_TYPE
106
+ }
107
+
108
+ return fallbackType || DEFAULT_BINARY_MIME_TYPE
109
+ }
110
+
111
+ export async function deleteProjectUpload(args: {
112
+ localPath: string
113
+ storedName: string
114
+ }): Promise<boolean> {
115
+ const storedName = args.storedName
116
+ if (!storedName || storedName.includes("/") || storedName.includes("\\") || storedName === "." || storedName === "..") {
117
+ return false
118
+ }
119
+
120
+ const absolutePath = path.join(getProjectUploadDir(args.localPath), storedName)
121
+ try {
122
+ await rm(absolutePath, { force: true })
123
+ return true
124
+ } catch {
125
+ return false
126
+ }
127
+ }
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  AgentProvider,
3
+ ChatAttachment,
3
4
  ChatSnapshot,
4
5
  KeybindingsSnapshot,
5
6
  LocalProjectsSnapshot,
@@ -67,6 +68,7 @@ export type ClientCommand =
67
68
  projectId?: string
68
69
  provider?: AgentProvider
69
70
  content: string
71
+ attachments?: ChatAttachment[]
70
72
  model?: string
71
73
  modelOptions?: ModelOptions
72
74
  effort?: string
@@ -96,4 +96,69 @@ describe("hydrateToolResult", () => {
96
96
 
97
97
  expect(hydrateToolResult(tool, "line 1\nline 2")).toBe("line 1\nline 2")
98
98
  })
99
+
100
+ test("hydrates read image results with canonical image blocks intact", () => {
101
+ const tool = normalizeToolCall({
102
+ toolName: "Read",
103
+ toolId: "tool-image",
104
+ input: { file_path: "/tmp/example.png" },
105
+ })
106
+
107
+ expect(hydrateToolResult(tool, {
108
+ content: [
109
+ {
110
+ type: "text",
111
+ text: "Read image file [image/png]\n[Image: original 10x10, displayed at 10x10.]",
112
+ },
113
+ {
114
+ type: "image",
115
+ data: "ZmFrZS1pbWFnZS1kYXRh",
116
+ mimeType: "image/png",
117
+ },
118
+ ],
119
+ })).toEqual({
120
+ content: "Read image file [image/png]\n[Image: original 10x10, displayed at 10x10.]",
121
+ blocks: [
122
+ {
123
+ type: "text",
124
+ text: "Read image file [image/png]\n[Image: original 10x10, displayed at 10x10.]",
125
+ },
126
+ {
127
+ type: "image",
128
+ data: "ZmFrZS1pbWFnZS1kYXRh",
129
+ mimeType: "image/png",
130
+ },
131
+ ],
132
+ })
133
+ })
134
+
135
+ test("hydrates Claude read image results with source.base64 into canonical image blocks", () => {
136
+ const tool = normalizeToolCall({
137
+ toolName: "Read",
138
+ toolId: "tool-image-claude",
139
+ input: { file_path: "/tmp/example.png" },
140
+ })
141
+
142
+ expect(hydrateToolResult(tool, {
143
+ content: [
144
+ {
145
+ type: "image",
146
+ source: {
147
+ type: "base64",
148
+ data: "ZmFrZS1pbWFnZS1kYXRh",
149
+ media_type: "image/png",
150
+ },
151
+ },
152
+ ],
153
+ })).toEqual({
154
+ content: "",
155
+ blocks: [
156
+ {
157
+ type: "image",
158
+ data: "ZmFrZS1pbWFnZS1kYXRh",
159
+ mimeType: "image/png",
160
+ },
161
+ ],
162
+ })
163
+ })
99
164
  })
@@ -203,6 +203,72 @@ function parseJsonValue(value: unknown): unknown {
203
203
  }
204
204
  }
205
205
 
206
+ type ReadStructuredTextBlock = {
207
+ type: "text"
208
+ text: string
209
+ }
210
+
211
+ type ReadStructuredImageBlock = {
212
+ type: "image"
213
+ data: string
214
+ mimeType?: string
215
+ }
216
+
217
+ function normalizeReadBlocks(value: unknown): Array<ReadStructuredTextBlock | ReadStructuredImageBlock> {
218
+ const blocks = (
219
+ value
220
+ && typeof value === "object"
221
+ && "content" in value
222
+ && Array.isArray((value as { content?: unknown }).content)
223
+ )
224
+ ? (value as { content: unknown[] }).content
225
+ : Array.isArray(value)
226
+ ? value
227
+ : []
228
+
229
+ const normalized: Array<ReadStructuredTextBlock | ReadStructuredImageBlock> = []
230
+
231
+ for (const block of blocks) {
232
+ if (!block || typeof block !== "object" || !("type" in block)) {
233
+ continue
234
+ }
235
+
236
+ if (block.type === "text" && typeof block.text === "string") {
237
+ normalized.push({ type: "text", text: block.text })
238
+ continue
239
+ }
240
+
241
+ if (block.type === "image") {
242
+ if ("data" in block && typeof block.data === "string") {
243
+ normalized.push({
244
+ type: "image",
245
+ data: block.data,
246
+ mimeType: typeof block.mimeType === "string" ? block.mimeType : undefined,
247
+ })
248
+ continue
249
+ }
250
+
251
+ if (
252
+ "source" in block
253
+ && block.source
254
+ && typeof block.source === "object"
255
+ && "type" in block.source
256
+ && block.source.type === "base64"
257
+ && "data" in block.source
258
+ && typeof block.source.data === "string"
259
+ ) {
260
+ normalized.push({
261
+ type: "image",
262
+ data: block.source.data,
263
+ mimeType: typeof block.source.media_type === "string" ? block.source.media_type : undefined,
264
+ })
265
+ }
266
+ }
267
+ }
268
+
269
+ return normalized
270
+ }
271
+
206
272
  export function hydrateToolResult(tool: NormalizedToolCall, raw: unknown): HydratedToolCall["result"] {
207
273
  const parsed = parseJsonValue(raw)
208
274
 
@@ -241,6 +307,16 @@ export function hydrateToolResult(tool: NormalizedToolCall, raw: unknown): Hydra
241
307
  if (typeof parsed === "string") {
242
308
  return parsed
243
309
  }
310
+ const blocks = normalizeReadBlocks(parsed)
311
+ if (blocks.length > 0) {
312
+ return {
313
+ content: blocks
314
+ .flatMap((block) => block.type === "text" ? [block.text] : [])
315
+ .join(""),
316
+ blocks,
317
+ } satisfies ReadFileToolResult
318
+ }
319
+
244
320
  const record = asRecord(parsed)
245
321
  return {
246
322
  content: typeof record?.content === "string" ? record.content : JSON.stringify(parsed, null, 2),
@@ -3,6 +3,25 @@ export const PROTOCOL_VERSION = 1 as const
3
3
 
4
4
  export type AgentProvider = "claude" | "codex"
5
5
 
6
+ export type AttachmentKind = "image" | "file"
7
+
8
+ export interface ChatAttachment {
9
+ id: string
10
+ kind: AttachmentKind
11
+ displayName: string
12
+ absolutePath: string
13
+ relativePath: string
14
+ contentUrl: string
15
+ mimeType: string
16
+ size: number
17
+ }
18
+
19
+ export interface InternalUserAttachmentsData {
20
+ userText: string
21
+ attachments: ChatAttachment[]
22
+ llmHintText: string
23
+ }
24
+
6
25
  export interface ProviderModelOption {
7
26
  id: string
8
27
  label: string
@@ -378,6 +397,7 @@ export interface ToolResultEntry extends TranscriptEntryBase {
378
397
  export interface UserPromptEntry extends TranscriptEntryBase {
379
398
  kind: "user_prompt"
380
399
  content: string
400
+ attachments?: ChatAttachment[]
381
401
  }
382
402
 
383
403
  export interface SystemInitEntry extends TranscriptEntryBase {
@@ -501,8 +521,20 @@ export type HydratedBashToolCall =
501
521
  export type HydratedWebSearchToolCall =
502
522
  HydratedToolCallBase<"web_search", WebSearchToolCall["input"], unknown>
503
523
 
524
+ export interface ReadFileTextBlock {
525
+ type: "text"
526
+ text: string
527
+ }
528
+
529
+ export interface ReadFileImageBlock {
530
+ type: "image"
531
+ data: string
532
+ mimeType?: string
533
+ }
534
+
504
535
  export interface ReadFileToolResult {
505
536
  content: string
537
+ blocks?: Array<ReadFileTextBlock | ReadFileImageBlock>
506
538
  }
507
539
 
508
540
  export type HydratedReadFileToolCall =
@@ -540,7 +572,7 @@ export type HydratedToolCall =
540
572
  | HydratedUnknownToolCall
541
573
 
542
574
  export type HydratedTranscriptMessage =
543
- | ({ kind: "user_prompt"; content: string; id: string; messageId?: string; timestamp: string; hidden?: boolean })
575
+ | ({ kind: "user_prompt"; content: string; attachments?: ChatAttachment[]; id: string; messageId?: string; timestamp: string; hidden?: boolean })
544
576
  | ({ kind: "system_init"; model: string; tools: string[]; agents: string[]; slashCommands: string[]; mcpServers: McpServerInfo[]; provider: AgentProvider; id: string; messageId?: string; timestamp: string; hidden?: boolean; debugRaw?: string })
545
577
  | ({ kind: "account_info"; accountInfo: AccountInfo; id: string; messageId?: string; timestamp: string; hidden?: boolean })
546
578
  | ({ kind: "assistant_text"; text: string; id: string; messageId?: string; timestamp: string; hidden?: boolean })