saeeol 1.2.1 → 1.2.3

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.
Files changed (151) hide show
  1. package/bin/saeeol.cjs +187 -0
  2. package/npm/bin/saeeol +0 -0
  3. package/package.json +12 -12
  4. package/src/cli/cmd/tui/component/dialog/dialog-agent.tsx +32 -0
  5. package/src/cli/cmd/tui/component/dialog/dialog-command.tsx +190 -0
  6. package/src/cli/cmd/tui/component/dialog/dialog-console-org.tsx +103 -0
  7. package/src/cli/cmd/tui/component/dialog/dialog-go-upsell.tsx +159 -0
  8. package/src/cli/cmd/tui/component/dialog/dialog-mcp.tsx +86 -0
  9. package/src/cli/cmd/tui/component/dialog/dialog-model.tsx +238 -0
  10. package/src/cli/cmd/tui/component/dialog/dialog-provider.tsx +343 -0
  11. package/src/cli/cmd/tui/component/dialog/dialog-session-delete-failed.tsx +103 -0
  12. package/src/cli/cmd/tui/component/dialog/dialog-session-list.tsx +301 -0
  13. package/src/cli/cmd/tui/component/dialog/dialog-session-rename.tsx +35 -0
  14. package/src/cli/cmd/tui/component/dialog/dialog-skill.tsx +37 -0
  15. package/src/cli/cmd/tui/component/dialog/dialog-stash.tsx +87 -0
  16. package/src/cli/cmd/tui/component/dialog/dialog-status.tsx +190 -0
  17. package/src/cli/cmd/tui/component/dialog/dialog-tag.tsx +44 -0
  18. package/src/cli/cmd/tui/component/dialog/dialog-theme-list.tsx +50 -0
  19. package/src/cli/cmd/tui/component/dialog/dialog-variant.tsx +39 -0
  20. package/src/cli/cmd/tui/component/dialog/dialog-workspace-create.tsx +200 -0
  21. package/src/cli/cmd/tui/component/dialog/dialog-workspace-unavailable.tsx +81 -0
  22. package/src/cli/cmd/tui/component/dialog-agent.tsx +1 -32
  23. package/src/cli/cmd/tui/component/dialog-command.tsx +1 -190
  24. package/src/cli/cmd/tui/component/dialog-console-org.tsx +1 -103
  25. package/src/cli/cmd/tui/component/dialog-go-upsell.tsx +1 -159
  26. package/src/cli/cmd/tui/component/dialog-mcp.tsx +1 -86
  27. package/src/cli/cmd/tui/component/dialog-model.tsx +1 -238
  28. package/src/cli/cmd/tui/component/dialog-provider.tsx +1 -343
  29. package/src/cli/cmd/tui/component/dialog-session-delete-failed.tsx +1 -103
  30. package/src/cli/cmd/tui/component/dialog-session-list.tsx +1 -301
  31. package/src/cli/cmd/tui/component/dialog-session-rename.tsx +1 -35
  32. package/src/cli/cmd/tui/component/dialog-skill.tsx +1 -37
  33. package/src/cli/cmd/tui/component/dialog-stash.tsx +1 -87
  34. package/src/cli/cmd/tui/component/dialog-status.tsx +1 -190
  35. package/src/cli/cmd/tui/component/dialog-tag.tsx +1 -44
  36. package/src/cli/cmd/tui/component/dialog-theme-list.tsx +1 -50
  37. package/src/cli/cmd/tui/component/dialog-variant.tsx +1 -39
  38. package/src/cli/cmd/tui/component/dialog-workspace-create.tsx +1 -200
  39. package/src/cli/cmd/tui/component/dialog-workspace-unavailable.tsx +1 -81
  40. package/src/cli/cmd/tui/context/app/args.tsx +15 -0
  41. package/src/cli/cmd/tui/context/app/directory.ts +15 -0
  42. package/src/cli/cmd/tui/context/app/editor-zed.ts +281 -0
  43. package/src/cli/cmd/tui/context/app/editor.ts +425 -0
  44. package/src/cli/cmd/tui/context/app/helper.tsx +25 -0
  45. package/src/cli/cmd/tui/context/app/project.tsx +109 -0
  46. package/src/cli/cmd/tui/context/app/route.tsx +67 -0
  47. package/src/cli/cmd/tui/context/app/sdk.tsx +142 -0
  48. package/src/cli/cmd/tui/context/app/sync.tsx +713 -0
  49. package/src/cli/cmd/tui/context/app/theme.tsx +307 -0
  50. package/src/cli/cmd/tui/context/app/tui-config.tsx +9 -0
  51. package/src/cli/cmd/tui/context/args.tsx +1 -15
  52. package/src/cli/cmd/tui/context/directory.ts +1 -15
  53. package/src/cli/cmd/tui/context/editor-zed.ts +1 -281
  54. package/src/cli/cmd/tui/context/editor.ts +1 -425
  55. package/src/cli/cmd/tui/context/event.ts +1 -45
  56. package/src/cli/cmd/tui/context/exit.tsx +1 -67
  57. package/src/cli/cmd/tui/context/helper.tsx +1 -25
  58. package/src/cli/cmd/tui/context/keybind.tsx +1 -105
  59. package/src/cli/cmd/tui/context/kv.tsx +1 -76
  60. package/src/cli/cmd/tui/context/local.tsx +1 -478
  61. package/src/cli/cmd/tui/context/plugin-keybinds.ts +1 -41
  62. package/src/cli/cmd/tui/context/project.tsx +1 -109
  63. package/src/cli/cmd/tui/context/prompt.tsx +1 -18
  64. package/src/cli/cmd/tui/context/route.tsx +1 -67
  65. package/src/cli/cmd/tui/context/runtime/event.ts +45 -0
  66. package/src/cli/cmd/tui/context/runtime/exit.tsx +67 -0
  67. package/src/cli/cmd/tui/context/runtime/keybind.tsx +105 -0
  68. package/src/cli/cmd/tui/context/runtime/kv.tsx +76 -0
  69. package/src/cli/cmd/tui/context/runtime/local.tsx +478 -0
  70. package/src/cli/cmd/tui/context/runtime/plugin-keybinds.ts +41 -0
  71. package/src/cli/cmd/tui/context/sdk.tsx +1 -142
  72. package/src/cli/cmd/tui/context/session/prompt.tsx +18 -0
  73. package/src/cli/cmd/tui/context/sync.tsx +1 -713
  74. package/src/cli/cmd/tui/context/theme.tsx +1 -307
  75. package/src/cli/cmd/tui/context/tui-config.tsx +1 -9
  76. package/src/tool/apply_patch.ts +1 -334
  77. package/src/tool/bash.ts +1 -656
  78. package/src/tool/core/external-directory.ts +55 -0
  79. package/src/tool/core/invalid.ts +21 -0
  80. package/src/tool/core/recall.ts +164 -0
  81. package/src/tool/core/recall.txt +12 -0
  82. package/src/tool/core/schema.ts +16 -0
  83. package/src/tool/core/tool.ts +162 -0
  84. package/src/tool/core/truncate.ts +160 -0
  85. package/src/tool/core/truncation-dir.ts +4 -0
  86. package/src/tool/diagnostics.ts +1 -20
  87. package/src/tool/edit-replacers.ts +1 -288
  88. package/src/tool/edit-utils.ts +1 -86
  89. package/src/tool/edit.ts +1 -262
  90. package/src/tool/external-directory.ts +1 -55
  91. package/src/tool/file/apply_patch.ts +334 -0
  92. package/src/tool/file/apply_patch.txt +33 -0
  93. package/src/tool/file/bash.ts +656 -0
  94. package/src/tool/file/bash.txt +119 -0
  95. package/src/tool/file/edit-replacers.ts +288 -0
  96. package/src/tool/file/edit-utils.ts +86 -0
  97. package/src/tool/file/edit.ts +262 -0
  98. package/src/tool/file/edit.txt +10 -0
  99. package/src/tool/file/read.ts +389 -0
  100. package/src/tool/file/read.txt +14 -0
  101. package/src/tool/file/write.ts +114 -0
  102. package/src/tool/file/write.txt +8 -0
  103. package/src/tool/glob.ts +1 -115
  104. package/src/tool/grep.ts +1 -151
  105. package/src/tool/integration/diagnostics.ts +20 -0
  106. package/src/tool/integration/lsp.ts +113 -0
  107. package/src/tool/integration/lsp.txt +24 -0
  108. package/src/tool/integration/mcp-exa.ts +73 -0
  109. package/src/tool/integration/package.ts +168 -0
  110. package/src/tool/integration/registry.ts +375 -0
  111. package/src/tool/invalid.ts +1 -21
  112. package/src/tool/lsp.ts +1 -113
  113. package/src/tool/mcp-exa.ts +1 -73
  114. package/src/tool/package.ts +1 -168
  115. package/src/tool/plan.ts +1 -30
  116. package/src/tool/question.ts +1 -52
  117. package/src/tool/read.ts +1 -389
  118. package/src/tool/recall.ts +1 -164
  119. package/src/tool/registry.ts +1 -375
  120. package/src/tool/schema.ts +1 -16
  121. package/src/tool/search/glob.ts +115 -0
  122. package/src/tool/search/glob.txt +6 -0
  123. package/src/tool/search/grep.ts +151 -0
  124. package/src/tool/search/grep.txt +8 -0
  125. package/src/tool/search/warpgrep.ts +107 -0
  126. package/src/tool/search/warpgrep.txt +10 -0
  127. package/src/tool/search/webfetch.ts +202 -0
  128. package/src/tool/search/webfetch.txt +13 -0
  129. package/src/tool/search/websearch.ts +71 -0
  130. package/src/tool/search/websearch.txt +14 -0
  131. package/src/tool/skill.ts +1 -91
  132. package/src/tool/task.ts +1 -197
  133. package/src/tool/todo.ts +1 -62
  134. package/src/tool/tool.ts +1 -162
  135. package/src/tool/truncate.ts +1 -160
  136. package/src/tool/truncation-dir.ts +1 -4
  137. package/src/tool/warpgrep.ts +1 -107
  138. package/src/tool/webfetch.ts +1 -202
  139. package/src/tool/websearch.ts +1 -71
  140. package/src/tool/workflow/plan-enter.txt +14 -0
  141. package/src/tool/workflow/plan-exit.txt +13 -0
  142. package/src/tool/workflow/plan.ts +30 -0
  143. package/src/tool/workflow/question.ts +52 -0
  144. package/src/tool/workflow/question.txt +11 -0
  145. package/src/tool/workflow/skill.ts +91 -0
  146. package/src/tool/workflow/skill.txt +5 -0
  147. package/src/tool/workflow/task.ts +197 -0
  148. package/src/tool/workflow/task.txt +57 -0
  149. package/src/tool/workflow/todo.ts +62 -0
  150. package/src/tool/workflow/todowrite.txt +167 -0
  151. package/src/tool/write.ts +1 -114
@@ -0,0 +1,281 @@
1
+ import { clamp } from "@saeeol/boxes/clamp"
2
+ import { Database } from "bun:sqlite"
3
+ import os from "node:os"
4
+ import path from "node:path"
5
+ import z from "zod"
6
+ import { Filesystem } from "@/util/filesystem"
7
+ import type { EditorSelection } from "./editor"
8
+
9
+ const ZedEditorRowSchema = z.object({
10
+ item_kind: z.string(),
11
+ editor_id: z.number().nullable(),
12
+ workspace_id: z.number(),
13
+ workspace_paths: z.string().nullable(),
14
+ timestamp: z.string(),
15
+ buffer_path: z.string().nullable(),
16
+ })
17
+
18
+ const ZedSelectionRowSchema = z.object({
19
+ selection_start: z.number().nullable(),
20
+ selection_end: z.number().nullable(),
21
+ })
22
+
23
+ const ZedEditorContentsSchema = z.object({
24
+ contents: z.string().nullable(),
25
+ })
26
+
27
+ const utf8 = new TextEncoder()
28
+
29
+ type ZedEditorRow = z.infer<typeof ZedEditorRowSchema>
30
+ type ZedActiveEditorRow = ZedEditorRow & { item_kind: "Editor"; editor_id: number }
31
+ type ZedSelectionRow = z.infer<typeof ZedSelectionRowSchema>
32
+
33
+ export type ZedSelectionResult =
34
+ | { type: "selection"; selection: EditorSelection }
35
+ | { type: "empty" }
36
+ | { type: "unavailable" }
37
+
38
+ export async function resolveZedSelection(dbPath: string, cwd = process.cwd()): Promise<ZedSelectionResult> {
39
+ const active = queryZedActiveEditor(dbPath, cwd)
40
+ if (active.type !== "row") return active
41
+
42
+ const row = active.row
43
+ if (!row.buffer_path) return { type: "empty" }
44
+
45
+ const selections = queryZedEditorSelections(dbPath, row)
46
+ if (selections.type !== "selections") return selections
47
+ const byteRanges = selections.selections
48
+ .flatMap((selection) => {
49
+ if (selection.selection_start == null || selection.selection_end == null) return []
50
+ return [
51
+ {
52
+ start: Math.min(selection.selection_start, selection.selection_end),
53
+ end: Math.max(selection.selection_start, selection.selection_end),
54
+ },
55
+ ]
56
+ })
57
+ .sort((left, right) => left.start - right.start || left.end - right.end)
58
+ if (byteRanges.length === 0) return { type: "unavailable" }
59
+
60
+ const contents = queryZedEditorContents(dbPath, row)
61
+ const text =
62
+ contents.type === "contents" && contents.contents != null
63
+ ? contents.contents
64
+ : await Bun.file(row.buffer_path)
65
+ .text()
66
+ .catch(() => undefined)
67
+ if (text == null) return { type: "unavailable" }
68
+
69
+ const ranges = byteRanges.map((range) => {
70
+ const startOffset = utf8ByteOffsetToStringIndex(text, range.start)
71
+ const endOffset = utf8ByteOffsetToStringIndex(text, range.end)
72
+ return {
73
+ text: text.slice(startOffset, endOffset),
74
+ selection: offsetsToSelection(text, startOffset, endOffset),
75
+ }
76
+ })
77
+
78
+ return {
79
+ type: "selection",
80
+ selection: {
81
+ filePath: row.buffer_path,
82
+ source: "zed",
83
+ ranges,
84
+ },
85
+ }
86
+ }
87
+
88
+ function queryZedActiveEditor(dbPath: string, cwd: string) {
89
+ let db: Database | undefined
90
+ try {
91
+ db = new Database(dbPath, { readonly: true })
92
+ const raw = db
93
+ .query(
94
+ `select
95
+ i.kind as item_kind,
96
+ e.item_id as editor_id,
97
+ i.workspace_id as workspace_id,
98
+ w.paths as workspace_paths,
99
+ w.timestamp as timestamp,
100
+ e.buffer_path as buffer_path
101
+ from items i
102
+ join panes p on p.pane_id = i.pane_id and p.workspace_id = i.workspace_id
103
+ join workspaces w on w.workspace_id = i.workspace_id
104
+ left join editors e on e.item_id = i.item_id and e.workspace_id = i.workspace_id
105
+ where i.active = 1 and p.active = 1
106
+ order by w.timestamp desc`,
107
+ )
108
+ .all()
109
+
110
+ const rows = raw.flatMap((row) => {
111
+ const parsed = ZedEditorRowSchema.safeParse(row)
112
+ return parsed.success ? [parsed.data] : []
113
+ })
114
+
115
+ if (raw.length > 0 && rows.length === 0) return { type: "unavailable" as const }
116
+
117
+ const row = rows
118
+ .map((row) => ({ row, score: scoreZedWorkspace(row.workspace_paths, cwd) }))
119
+ .filter((entry) => entry.score > 0)
120
+ .sort((left, right) => right.score - left.score || right.row.timestamp.localeCompare(left.row.timestamp))[0]?.row
121
+ if (!row) return { type: "empty" as const }
122
+ if (row.item_kind !== "Editor") return { type: "unavailable" as const }
123
+ if (!isZedActiveEditorRow(row)) return { type: "empty" as const }
124
+ return { type: "row" as const, row }
125
+ } catch {
126
+ return { type: "unavailable" as const }
127
+ } finally {
128
+ db?.close()
129
+ }
130
+ }
131
+
132
+ function queryZedEditorSelections(dbPath: string, row: ZedActiveEditorRow) {
133
+ let db: Database | undefined
134
+ try {
135
+ db = new Database(dbPath, { readonly: true })
136
+ const raw = db
137
+ .query(
138
+ `select
139
+ start as selection_start,
140
+ end as selection_end
141
+ from editor_selections
142
+ where editor_id = $editorID and workspace_id = $workspaceID`,
143
+ )
144
+ .all({ $editorID: row.editor_id, $workspaceID: row.workspace_id })
145
+
146
+ const selections = raw.flatMap((selection) => {
147
+ const parsed = ZedSelectionRowSchema.safeParse(selection)
148
+ return parsed.success ? [parsed.data] : []
149
+ })
150
+
151
+ if (raw.length > 0 && selections.length === 0) return { type: "unavailable" as const }
152
+ return { type: "selections" as const, selections }
153
+ } catch {
154
+ return { type: "unavailable" as const }
155
+ } finally {
156
+ db?.close()
157
+ }
158
+ }
159
+
160
+ function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) {
161
+ let db: Database | undefined
162
+ try {
163
+ db = new Database(dbPath, { readonly: true })
164
+ const parsed = ZedEditorContentsSchema.safeParse(
165
+ db
166
+ .query(
167
+ `select contents
168
+ from editors
169
+ where item_id = $editorID and workspace_id = $workspaceID`,
170
+ )
171
+ .get({ $editorID: row.editor_id, $workspaceID: row.workspace_id }),
172
+ )
173
+ if (!parsed.success) return { type: "unavailable" as const }
174
+ return { type: "contents" as const, contents: parsed.data.contents }
175
+ } catch {
176
+ return { type: "unavailable" as const }
177
+ } finally {
178
+ db?.close()
179
+ }
180
+ }
181
+
182
+ function isZedActiveEditorRow(row: ZedEditorRow): row is ZedActiveEditorRow {
183
+ return row.item_kind === "Editor" && row.editor_id != null
184
+ }
185
+
186
+ export function resolveZedDbPath() {
187
+ const candidates = [
188
+ process.env.SAEEOL_ZED_DB,
189
+ path.join(os.homedir(), "Library", "Application Support", "Zed", "db", "0-stable", "db.sqlite"),
190
+ path.join(os.homedir(), ".local", "share", "zed", "db", "0-stable", "db.sqlite"),
191
+ ].filter((item): item is string => Boolean(item))
192
+
193
+ return candidates.find((item) => isFile(item))
194
+ }
195
+
196
+ function isFile(item: string) {
197
+ try {
198
+ return Filesystem.stat(item)?.isFile() === true
199
+ } catch {
200
+ return false
201
+ }
202
+ }
203
+
204
+ function scoreZedWorkspace(workspacePaths: string | null, cwd: string) {
205
+ return zedWorkspacePaths(workspacePaths).reduce((score, item) => {
206
+ if (pathContains(item, cwd)) return Math.max(score, path.resolve(item).length)
207
+ return score
208
+ }, 0)
209
+ }
210
+
211
+ function zedWorkspacePaths(value: string | null) {
212
+ if (!value) return []
213
+ const parsed = parseJson(value)
214
+ if (Array.isArray(parsed)) return parsed.filter((item): item is string => typeof item === "string")
215
+ return value.split(/\r?\n/).filter(Boolean)
216
+ }
217
+
218
+ export function offsetToPosition(text: string, offset: number) {
219
+ const stringOffset = utf8ByteOffsetToStringIndex(text, offset)
220
+ return offsetsToSelection(text, stringOffset, stringOffset).start
221
+ }
222
+
223
+ function utf8ByteOffsetToStringIndex(text: string, byteOffset: number) {
224
+ if (byteOffset <= 0) return 0
225
+
226
+ let bytes = 0
227
+ for (let index = 0; index < text.length; ) {
228
+ const codePoint = text.codePointAt(index)
229
+ if (codePoint === undefined) return text.length
230
+
231
+ const nextIndex = index + (codePoint > 0xffff ? 2 : 1)
232
+ bytes += utf8.encode(text.slice(index, nextIndex)).length
233
+ if (bytes >= byteOffset) return nextIndex
234
+ index = nextIndex
235
+ }
236
+
237
+ return text.length
238
+ }
239
+
240
+ function offsetsToSelection(text: string, startOffset: number, endOffset: number) {
241
+ const start = clamp(startOffset, 0, text.length)
242
+ const end = clamp(endOffset, 0, text.length)
243
+ let line = 1
244
+ let lineStart = 0
245
+ let startPosition = position(line, lineStart, start)
246
+ let endPosition = position(line, lineStart, end)
247
+
248
+ for (let index = 0; index <= end; index++) {
249
+ if (index === start) startPosition = position(line, lineStart, index)
250
+ if (index === end) {
251
+ endPosition = position(line, lineStart, index)
252
+ break
253
+ }
254
+ if (text[index] === "\n") {
255
+ line += 1
256
+ lineStart = index + 1
257
+ }
258
+ }
259
+
260
+ return { start: startPosition, end: endPosition }
261
+ }
262
+
263
+ function position(line: number, lineStart: number, offset: number) {
264
+ return {
265
+ line,
266
+ character: offset - lineStart + 1,
267
+ }
268
+ }
269
+
270
+ function pathContains(parent: string, child: string) {
271
+ const relative = path.relative(path.resolve(parent), path.resolve(child))
272
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
273
+ }
274
+
275
+ function parseJson(value: string) {
276
+ try {
277
+ return JSON.parse(value) as unknown
278
+ } catch {
279
+ return
280
+ }
281
+ }
@@ -0,0 +1,425 @@
1
+ import { expDelay } from "@saeeol/boxes/schedule"
2
+ import { readdirSync, readFileSync, statSync } from "node:fs"
3
+ import os from "node:os"
4
+ import path from "node:path"
5
+ import { onCleanup, onMount } from "solid-js"
6
+ import { createStore } from "solid-js/store"
7
+ import z from "zod"
8
+ import { isRecord } from "@/util/record"
9
+ import { createSimpleContext } from "../app/helper"
10
+ import { resolveZedDbPath, resolveZedSelection } from "./editor-zed"
11
+
12
+ const MCP_PROTOCOL_VERSION = "2025-11-25"
13
+
14
+ const JsonRpcMessageSchema = z.object({
15
+ id: z.union([z.number(), z.string(), z.null()]).optional(),
16
+ method: z.string().optional(),
17
+ params: z.unknown().optional(),
18
+ result: z.unknown().optional(),
19
+ error: z
20
+ .object({
21
+ code: z.number().optional(),
22
+ message: z.string().optional(),
23
+ })
24
+ .optional(),
25
+ })
26
+
27
+ const PositionSchema = z.object({
28
+ line: z.number(),
29
+ character: z.number(),
30
+ })
31
+
32
+ const EditorSelectionRangeSchema = z.object({
33
+ text: z.string(),
34
+ selection: z.object({
35
+ start: PositionSchema,
36
+ end: PositionSchema,
37
+ }),
38
+ })
39
+
40
+ const EditorSelectionSchema = z
41
+ .union([
42
+ z.object({
43
+ filePath: z.string(),
44
+ source: z.enum(["websocket", "zed"]).optional(),
45
+ ranges: z.array(EditorSelectionRangeSchema).min(1),
46
+ }),
47
+ z.object({
48
+ text: z.string(),
49
+ filePath: z.string(),
50
+ source: z.enum(["websocket", "zed"]).optional(),
51
+ selection: z.object({
52
+ start: PositionSchema,
53
+ end: PositionSchema,
54
+ }),
55
+ }),
56
+ ])
57
+ .transform((value) =>
58
+ "ranges" in value
59
+ ? value
60
+ : {
61
+ filePath: value.filePath,
62
+ source: value.source,
63
+ ranges: [
64
+ {
65
+ text: value.text,
66
+ selection: value.selection,
67
+ },
68
+ ],
69
+ },
70
+ )
71
+
72
+ const EditorMentionSchema = z.object({
73
+ filePath: z.string(),
74
+ lineStart: z.number(),
75
+ lineEnd: z.number(),
76
+ })
77
+
78
+ const EditorServerInfoSchema = z.object({
79
+ protocolVersion: z.string().optional(),
80
+ serverInfo: z
81
+ .object({
82
+ name: z.string().optional(),
83
+ version: z.string().optional(),
84
+ })
85
+ .optional(),
86
+ })
87
+
88
+ type JsonRpcMessage = z.infer<typeof JsonRpcMessageSchema>
89
+ export type EditorSelection = z.infer<typeof EditorSelectionSchema>
90
+ export type EditorMention = z.infer<typeof EditorMentionSchema>
91
+ type EditorServerInfo = z.infer<typeof EditorServerInfoSchema>
92
+
93
+ type EditorConnection = {
94
+ url: string
95
+ authToken?: string
96
+ source: string
97
+ }
98
+
99
+ type EditorLockFile = {
100
+ port: number
101
+ authToken?: string
102
+ transport?: string
103
+ workspaceFolders: string[]
104
+ mtimeMs: number
105
+ }
106
+
107
+ export const { use: useEditorContext, provider: EditorContextProvider } = createSimpleContext({
108
+ name: "EditorContext",
109
+ init: (props: { WebSocketImpl?: typeof WebSocket }) => {
110
+ const mentionListeners = new Set<(mention: EditorMention) => void>()
111
+ const WebSocketImpl = props.WebSocketImpl ?? WebSocket
112
+ const [store, setStore] = createStore<{
113
+ status: "disabled" | "connecting" | "connected"
114
+ selection: EditorSelection | undefined
115
+ server: EditorServerInfo | undefined
116
+ }>({
117
+ status: "disabled",
118
+ selection: undefined,
119
+ server: undefined,
120
+ })
121
+
122
+ let socket: WebSocket | undefined
123
+ let closed = false
124
+ let reconnect: ReturnType<typeof setTimeout> | undefined
125
+ let attempt = 0
126
+ let requestID = 0
127
+ let zedSelection: Promise<void> | undefined
128
+ let lastZedSelectionKey: string | undefined
129
+ let directory = process.cwd()
130
+ const pending = new Map<number, string>()
131
+
132
+ const send = (payload: JsonRpcMessage) => {
133
+ if (!socket || socket.readyState !== 1) return
134
+ socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload }))
135
+ }
136
+
137
+ const request = (method: string, params?: unknown) => {
138
+ requestID += 1
139
+ pending.set(requestID, method)
140
+ send({ id: requestID, method, params })
141
+ }
142
+
143
+ const connect = () => {
144
+ if (closed) return
145
+
146
+ const connection = resolveEditorConnection(directory)
147
+ if (!connection) {
148
+ const dbPath = resolveZedDbPath()
149
+ if (!dbPath) {
150
+ setStore("status", "disabled")
151
+ scheduleReconnect()
152
+ return
153
+ }
154
+ zedSelection ??= resolveZedSelection(dbPath, directory)
155
+ .then((result) => {
156
+ if (closed || socket) return
157
+ if (result.type === "unavailable") return
158
+ const selection = result.type === "selection" ? result.selection : undefined
159
+ const key = editorSelectionKey(selection)
160
+ if (key !== lastZedSelectionKey) {
161
+ lastZedSelectionKey = key
162
+ setStore("selection", selection)
163
+ setStore("status", selection ? "connected" : "disabled")
164
+ }
165
+ })
166
+ .catch(() => {
167
+ // Keep the last known Zed selection for transient polling failures.
168
+ })
169
+ .finally(() => {
170
+ zedSelection = undefined
171
+ })
172
+ scheduleZedPoll()
173
+ return
174
+ }
175
+
176
+ setStore("status", "connecting")
177
+ const current = openEditorSocket(connection, WebSocketImpl)
178
+ socket = current
179
+
180
+ current.addEventListener("open", () => {
181
+ if (socket !== current) {
182
+ current.close()
183
+ return
184
+ }
185
+
186
+ attempt = 0
187
+ setStore("status", "connected")
188
+ request("initialize", {
189
+ protocolVersion: MCP_PROTOCOL_VERSION,
190
+ capabilities: {},
191
+ clientInfo: { name: "saeeol", version: "0.0.0" },
192
+ })
193
+ })
194
+
195
+ current.addEventListener("message", (event) => {
196
+ const message = parseMessage(event.data)
197
+ if (!message) return
198
+
199
+ const selection =
200
+ message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
201
+ if (selection?.success) {
202
+ setStore("selection", { ...selection.data, source: "websocket" })
203
+ return
204
+ }
205
+
206
+ const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined
207
+ if (mention?.success) {
208
+ mentionListeners.forEach((listener) => listener(mention.data))
209
+ return
210
+ }
211
+
212
+ if (typeof message.id !== "number") return
213
+
214
+ const method = pending.get(message.id)
215
+ if (!method) return
216
+
217
+ pending.delete(message.id)
218
+ if (message.error) return
219
+
220
+ const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined
221
+ if (initialize?.success) {
222
+ setStore("server", initialize.data)
223
+ send({ method: "notifications/initialized" })
224
+ return
225
+ }
226
+ })
227
+
228
+ current.addEventListener("close", () => {
229
+ if (socket !== current) return
230
+
231
+ socket = undefined
232
+ pending.clear()
233
+ if (closed) return
234
+
235
+ setStore("status", "connecting")
236
+ scheduleReconnect()
237
+ })
238
+ }
239
+
240
+ const scheduleReconnect = () => {
241
+ if (closed) return
242
+ if (reconnect) clearTimeout(reconnect)
243
+ attempt += 1
244
+ const delay = Math.min(expDelay(1000, attempt - 1), 10_000)
245
+ reconnect = setTimeout(connect, delay)
246
+ }
247
+
248
+ const scheduleZedPoll = () => {
249
+ if (closed) return
250
+ if (reconnect) clearTimeout(reconnect)
251
+ reconnect = setTimeout(connect, 1000)
252
+ }
253
+
254
+ const reconnectWithDirectory = (nextDirectory?: string) => {
255
+ const resolved = nextDirectory || process.cwd()
256
+ if (directory === resolved) return
257
+
258
+ directory = resolved
259
+ attempt = 0
260
+ pending.clear()
261
+ lastZedSelectionKey = undefined
262
+ if (reconnect) clearTimeout(reconnect)
263
+ reconnect = undefined
264
+ if (socket) {
265
+ const current = socket
266
+ socket = undefined
267
+ current.close()
268
+ }
269
+ setStore("status", "disabled")
270
+ setStore("selection", undefined)
271
+ setStore("server", undefined)
272
+ connect()
273
+ }
274
+
275
+ onMount(() => {
276
+ connect()
277
+
278
+ onCleanup(() => {
279
+ closed = true
280
+ if (reconnect) clearTimeout(reconnect)
281
+ socket?.close()
282
+ })
283
+ })
284
+
285
+ return {
286
+ enabled() {
287
+ return Boolean(resolveEditorConnection(directory) || resolveZedDbPath())
288
+ },
289
+ connected() {
290
+ return store.status === "connected"
291
+ },
292
+ selection() {
293
+ return store.selection
294
+ },
295
+ clearSelection() {
296
+ lastZedSelectionKey = undefined
297
+ setStore("selection", undefined)
298
+ },
299
+ onMention(listener: (mention: EditorMention) => void) {
300
+ mentionListeners.add(listener)
301
+ return () => mentionListeners.delete(listener)
302
+ },
303
+ server() {
304
+ return store.server
305
+ },
306
+ reconnect(directory?: string) {
307
+ setStore("selection", undefined)
308
+ reconnectWithDirectory(directory)
309
+ },
310
+ }
311
+ },
312
+ })
313
+
314
+ function parsePort(value: string | undefined) {
315
+ if (!value) return
316
+
317
+ const parsed = Number.parseInt(value, 10)
318
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) return
319
+ return parsed
320
+ }
321
+
322
+ function resolveEditorConnection(directory: string): EditorConnection | undefined {
323
+ const port = parsePort(process.env.CLAUDE_CODE_SSE_PORT || process.env.SAEEOL_EDITOR_SSE_PORT)
324
+ if (port) {
325
+ return {
326
+ url: `ws://127.0.0.1:${port}`,
327
+ source: `env:${port}`,
328
+ }
329
+ }
330
+
331
+ const lock = resolveEditorLockFile(directory)
332
+ if (lock) {
333
+ return {
334
+ url: `ws://127.0.0.1:${lock.port}`,
335
+ authToken: lock.authToken,
336
+ source: `lock:${lock.port}`,
337
+ }
338
+ }
339
+ }
340
+
341
+ function resolveEditorLockFile(activeDirectory: string) {
342
+ const directory = path.join(os.homedir(), ".claude", "ide")
343
+ let entries: string[]
344
+
345
+ try {
346
+ entries = readdirSync(directory)
347
+ } catch {
348
+ return
349
+ }
350
+
351
+ // longest workspace folder that contains the active session directory; 0 if none match
352
+ const bestMatchLength = (lock: EditorLockFile) =>
353
+ Math.max(0, ...lock.workspaceFolders.map((folder) => pathContainsLength(folder, activeDirectory)))
354
+ const locks = entries
355
+ .filter((entry) => entry.endsWith(".lock"))
356
+ .map((entry) => readEditorLockFile(path.join(directory, entry)))
357
+ .filter((entry): entry is EditorLockFile => Boolean(entry))
358
+ .filter((entry) => bestMatchLength(entry) > 0)
359
+ // prefer locks with longer matching workspace folders, then more recent ones
360
+ .sort((left, right) => bestMatchLength(right) - bestMatchLength(left) || right.mtimeMs - left.mtimeMs)
361
+ return locks[0]
362
+ }
363
+
364
+ function readEditorLockFile(filePath: string): EditorLockFile | undefined {
365
+ const port = parsePort(path.basename(filePath, ".lock"))
366
+ if (!port) return
367
+
368
+ try {
369
+ const parsed = JSON.parse(readFileSync(filePath, "utf-8")) as unknown
370
+ if (!isRecord(parsed)) return
371
+ if (parsed.transport !== undefined && parsed.transport !== "ws") return
372
+
373
+ return {
374
+ port,
375
+ authToken: typeof parsed.authToken === "string" ? parsed.authToken : undefined,
376
+ transport: typeof parsed.transport === "string" ? parsed.transport : undefined,
377
+ workspaceFolders: Array.isArray(parsed.workspaceFolders)
378
+ ? parsed.workspaceFolders.filter((value): value is string => typeof value === "string")
379
+ : [],
380
+ mtimeMs: statSync(filePath).mtimeMs,
381
+ }
382
+ } catch {
383
+ return
384
+ }
385
+ }
386
+
387
+ export function editorSelectionKey(selection: EditorSelection | undefined) {
388
+ if (!selection) return ""
389
+ return [
390
+ selection.filePath,
391
+ ...selection.ranges.flatMap((range) => [
392
+ range.selection.start.line,
393
+ range.selection.start.character,
394
+ range.selection.end.line,
395
+ range.selection.end.character,
396
+ range.text,
397
+ ]),
398
+ ].join("\0")
399
+ }
400
+
401
+ function pathContainsLength(parent: string, child: string) {
402
+ const resolved = path.resolve(parent)
403
+ const relative = path.relative(resolved, path.resolve(child))
404
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ? resolved.length : 0
405
+ }
406
+
407
+ function openEditorSocket(connection: EditorConnection, WebSocketImpl: typeof WebSocket) {
408
+ if (!connection.authToken) return new WebSocketImpl(connection.url)
409
+
410
+ return new WebSocketImpl(connection.url, {
411
+ headers: {
412
+ "x-claude-code-ide-authorization": connection.authToken,
413
+ },
414
+ } as any)
415
+ }
416
+
417
+ function parseMessage(value: unknown) {
418
+ if (typeof value !== "string") return
419
+
420
+ try {
421
+ return JsonRpcMessageSchema.parse(JSON.parse(value))
422
+ } catch {
423
+ return
424
+ }
425
+ }