pi-tldraw 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.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +222 -0
  3. package/bridge/app-bridge-entry.js +6 -0
  4. package/mcp-app/LICENSE.md +9 -0
  5. package/mcp-app/PI_TLDRAW_PROVENANCE.json +32 -0
  6. package/mcp-app/README.md +129 -0
  7. package/mcp-app/dev-tunnel.sh +51 -0
  8. package/mcp-app/dist/editor-api.json +8493 -0
  9. package/mcp-app/dist/mcp-app.html +643 -0
  10. package/mcp-app/dist/method-map.json +915 -0
  11. package/mcp-app/package.json +42 -0
  12. package/mcp-app/plugins/tldraw-mcp/.cursor-plugin/plugin.json +10 -0
  13. package/mcp-app/plugins/tldraw-mcp/assets/logo.svg +3 -0
  14. package/mcp-app/plugins/tldraw-mcp/mcp.json +8 -0
  15. package/mcp-app/scripts/extract-editor-api.ts +1374 -0
  16. package/mcp-app/server.json +21 -0
  17. package/mcp-app/src/logger.ts +45 -0
  18. package/mcp-app/src/register-tools.ts +368 -0
  19. package/mcp-app/src/shared/generated-data.ts +160 -0
  20. package/mcp-app/src/shared/pending-requests.ts +69 -0
  21. package/mcp-app/src/shared/types.ts +76 -0
  22. package/mcp-app/src/shared/utils.ts +132 -0
  23. package/mcp-app/src/tools/exec.ts +120 -0
  24. package/mcp-app/src/tools/loadCachedCanvasWidgetHtml.ts +16 -0
  25. package/mcp-app/src/tools/search.ts +150 -0
  26. package/mcp-app/src/widget/app-context.tsx +29 -0
  27. package/mcp-app/src/widget/dev-log.tsx +70 -0
  28. package/mcp-app/src/widget/exec-helpers.ts +232 -0
  29. package/mcp-app/src/widget/export-tldr.ts +35 -0
  30. package/mcp-app/src/widget/focused/defaults.ts +141 -0
  31. package/mcp-app/src/widget/focused/focused-editor-proxy.ts +434 -0
  32. package/mcp-app/src/widget/focused/format.ts +366 -0
  33. package/mcp-app/src/widget/focused/to-focused.ts +258 -0
  34. package/mcp-app/src/widget/focused/to-tldraw.ts +570 -0
  35. package/mcp-app/src/widget/image-guard.tsx +106 -0
  36. package/mcp-app/src/widget/index.html +33 -0
  37. package/mcp-app/src/widget/mcp-app.css +113 -0
  38. package/mcp-app/src/widget/mcp-app.tsx +857 -0
  39. package/mcp-app/src/widget/persistence.ts +337 -0
  40. package/mcp-app/src/widget/snapshot.ts +157 -0
  41. package/mcp-app/src/worker.ts +305 -0
  42. package/mcp-app/tsconfig.json +23 -0
  43. package/mcp-app/vite.config.ts +13 -0
  44. package/mcp-app/wrangler.toml +45 -0
  45. package/mcp-app-source.json +36 -0
  46. package/package.json +90 -0
  47. package/patches/tldraw-mcp-app/001-pi-runtime.patch +35 -0
  48. package/scripts/assemble-mcp-app.mjs +193 -0
  49. package/scripts/build-bridge.mjs +74 -0
  50. package/scripts/e2e-mcp.mjs +69 -0
  51. package/scripts/e2e-packaged-mcp-app.mjs +79 -0
  52. package/scripts/run-mcp-app-dev.mjs +44 -0
  53. package/scripts/verify-bundle.mjs +41 -0
  54. package/scripts/verify-mcp-app-source.mjs +51 -0
  55. package/scripts/verify-mcp-app.mjs +38 -0
  56. package/scripts/verify-package-files.mjs +50 -0
  57. package/src/canvas/export.ts +164 -0
  58. package/src/canvas/state.ts +117 -0
  59. package/src/canvas/workflow.ts +105 -0
  60. package/src/commands/tldraw-command.ts +48 -0
  61. package/src/diagram/guidance.ts +44 -0
  62. package/src/host/local-host.ts +289 -0
  63. package/src/index.ts +762 -0
  64. package/src/mcp/client.ts +126 -0
  65. package/src/mcp/response.ts +74 -0
  66. package/src/semantic/layer.ts +309 -0
  67. package/src/server/server-manager.ts +153 -0
  68. package/src/store/export-store.ts +33 -0
  69. package/src/store/project-store.ts +251 -0
  70. package/src/ui/tldraw-status.ts +88 -0
  71. package/static/app-bridge-bundle.js +18114 -0
  72. package/static/app-bridge-bundle.meta.json +164 -0
  73. package/static/host.html +390 -0
  74. package/tsconfig.json +13 -0
@@ -0,0 +1,251 @@
1
+ import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+
4
+ export interface StoredCanvasSnapshot {
5
+ version: 1
6
+ canvasId: string
7
+ cwd: string
8
+ updatedAt: string
9
+ shapeCount: number
10
+ assetCount: number
11
+ bindingCount: number
12
+ shapes: unknown[]
13
+ assets: unknown[]
14
+ bindings: unknown[]
15
+ }
16
+
17
+ export interface CanvasIndexEntry {
18
+ canvasId: string
19
+ file: string
20
+ updatedAt: string
21
+ shapeCount: number
22
+ assetCount: number
23
+ bindingCount: number
24
+ }
25
+
26
+ export interface CanvasIndex {
27
+ version: 1
28
+ currentCanvasId?: string
29
+ canvases: Record<string, CanvasIndexEntry>
30
+ }
31
+
32
+ const INDEX_FILE = 'index.json'
33
+
34
+ type SnapshotArrays = Pick<StoredCanvasSnapshot, 'shapes' | 'assets' | 'bindings'>
35
+
36
+ function normalizeSnapshotInput(input: { shapes?: unknown[]; assets?: unknown[]; bindings?: unknown[] }): SnapshotArrays {
37
+ return {
38
+ shapes: Array.isArray(input.shapes) ? input.shapes : [],
39
+ assets: Array.isArray(input.assets) ? input.assets : [],
40
+ bindings: Array.isArray(input.bindings) ? input.bindings : [],
41
+ }
42
+ }
43
+
44
+ function createStoredCanvasSnapshot(
45
+ cwd: string,
46
+ canvasId: string,
47
+ updatedAt: string,
48
+ arrays: SnapshotArrays
49
+ ): StoredCanvasSnapshot {
50
+ return {
51
+ version: 1,
52
+ canvasId,
53
+ cwd,
54
+ updatedAt,
55
+ shapeCount: arrays.shapes.length,
56
+ assetCount: arrays.assets.length,
57
+ bindingCount: arrays.bindings.length,
58
+ ...arrays,
59
+ }
60
+ }
61
+
62
+ function canvasIndexEntry(snapshot: StoredCanvasSnapshot, file: string): CanvasIndexEntry {
63
+ return {
64
+ canvasId: snapshot.canvasId,
65
+ file,
66
+ updatedAt: snapshot.updatedAt,
67
+ shapeCount: snapshot.shapeCount,
68
+ assetCount: snapshot.assetCount,
69
+ bindingCount: snapshot.bindingCount,
70
+ }
71
+ }
72
+
73
+ function shouldRefuseEmptyOverwrite(input: {
74
+ allowEmptyOverwrite?: boolean
75
+ newShapeCount: number
76
+ existingShapeCount: number
77
+ }): boolean {
78
+ return !input.allowEmptyOverwrite && input.newShapeCount === 0 && input.existingShapeCount > 0
79
+ }
80
+
81
+ async function archiveExistingSnapshot(cwd: string, canvasId: string, raw: string) {
82
+ const historyDir = join(getCanvasDir(cwd), 'history', sanitizeCanvasId(canvasId))
83
+ await mkdir(historyDir, { recursive: true })
84
+ await writeFile(
85
+ join(historyDir, `${new Date().toISOString().replace(/[:.]/g, '-')}.json`),
86
+ raw,
87
+ 'utf8'
88
+ )
89
+ }
90
+
91
+ export function getCanvasDir(cwd: string) {
92
+ return join(cwd, '.pi', 'tldraw-canvases')
93
+ }
94
+
95
+ export function getCanvasFileName(canvasId: string) {
96
+ return `${sanitizeCanvasId(canvasId)}.json`
97
+ }
98
+
99
+ export function getCanvasPath(cwd: string, canvasId: string) {
100
+ return join(getCanvasDir(cwd), getCanvasFileName(canvasId))
101
+ }
102
+
103
+ export function getIndexPath(cwd: string) {
104
+ return join(getCanvasDir(cwd), INDEX_FILE)
105
+ }
106
+
107
+ export async function readCanvasIndex(cwd: string): Promise<CanvasIndex> {
108
+ try {
109
+ const raw = await readFile(getIndexPath(cwd), 'utf8')
110
+ const parsed = JSON.parse(raw)
111
+ if (!parsed || typeof parsed !== 'object') throw new Error('invalid index')
112
+ return {
113
+ version: 1,
114
+ currentCanvasId:
115
+ typeof parsed.currentCanvasId === 'string' ? parsed.currentCanvasId : undefined,
116
+ canvases:
117
+ parsed.canvases && typeof parsed.canvases === 'object' && !Array.isArray(parsed.canvases)
118
+ ? parsed.canvases
119
+ : {},
120
+ }
121
+ } catch {
122
+ return { version: 1, canvases: {} }
123
+ }
124
+ }
125
+
126
+ export async function writeCanvasIndex(cwd: string, index: CanvasIndex) {
127
+ await mkdir(getCanvasDir(cwd), { recursive: true })
128
+ await writeFile(getIndexPath(cwd), `${JSON.stringify(index, null, 2)}\n`, 'utf8')
129
+ }
130
+
131
+ export async function getCurrentCanvasId(cwd: string) {
132
+ const index = await readCanvasIndex(cwd)
133
+ return index.currentCanvasId
134
+ }
135
+
136
+ export async function setCurrentCanvasId(cwd: string, canvasId: string) {
137
+ const index = await readCanvasIndex(cwd)
138
+ index.currentCanvasId = canvasId
139
+ await writeCanvasIndex(cwd, index)
140
+ }
141
+
142
+ export async function saveCanvasSnapshot(
143
+ cwd: string,
144
+ canvasId: string,
145
+ input: { shapes?: unknown[]; assets?: unknown[]; bindings?: unknown[] },
146
+ opts?: { allowEmptyOverwrite?: boolean }
147
+ ): Promise<StoredCanvasSnapshot> {
148
+ const arrays = normalizeSnapshotInput(input)
149
+ const snapshot = createStoredCanvasSnapshot(cwd, canvasId, new Date().toISOString(), arrays)
150
+
151
+ await mkdir(getCanvasDir(cwd), { recursive: true })
152
+ const file = getCanvasFileName(canvasId)
153
+ const canvasPath = join(getCanvasDir(cwd), file)
154
+ const existing = await readExistingSnapshot(canvasPath)
155
+ if (
156
+ shouldRefuseEmptyOverwrite({
157
+ allowEmptyOverwrite: opts?.allowEmptyOverwrite,
158
+ newShapeCount: snapshot.shapeCount,
159
+ existingShapeCount: existing?.shapeCount ?? 0,
160
+ })
161
+ ) {
162
+ throw new Error(
163
+ `Refusing to overwrite ${canvasId}: live canvas has 0 shapes but saved snapshot has ${existing!.shapeCount}. Use a force save only if this deletion is intentional.`
164
+ )
165
+ }
166
+ if (existing?.raw) await archiveExistingSnapshot(cwd, canvasId, existing.raw)
167
+ await writeFile(canvasPath, `${JSON.stringify(snapshot, null, 2)}\n`, 'utf8')
168
+
169
+ const index = await readCanvasIndex(cwd)
170
+ index.currentCanvasId = canvasId
171
+ index.canvases[canvasId] = canvasIndexEntry(snapshot, file)
172
+ await writeCanvasIndex(cwd, index)
173
+ return snapshot
174
+ }
175
+
176
+ export async function loadCanvasSnapshot(
177
+ cwd: string,
178
+ canvasId?: string
179
+ ): Promise<StoredCanvasSnapshot | null> {
180
+ const resolvedCanvasId = canvasId ?? (await getCurrentCanvasId(cwd))
181
+ if (!resolvedCanvasId) return null
182
+ try {
183
+ const raw = await readFile(getCanvasPath(cwd, resolvedCanvasId), 'utf8')
184
+ const parsed = JSON.parse(raw)
185
+ if (!parsed || typeof parsed !== 'object') return null
186
+ if (parsed.canvasId !== resolvedCanvasId) return null
187
+ return {
188
+ version: 1,
189
+ canvasId: resolvedCanvasId,
190
+ cwd: typeof parsed.cwd === 'string' ? parsed.cwd : cwd,
191
+ updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : '',
192
+ shapeCount: Array.isArray(parsed.shapes) ? parsed.shapes.length : 0,
193
+ assetCount: Array.isArray(parsed.assets) ? parsed.assets.length : 0,
194
+ bindingCount: Array.isArray(parsed.bindings) ? parsed.bindings.length : 0,
195
+ shapes: Array.isArray(parsed.shapes) ? parsed.shapes : [],
196
+ assets: Array.isArray(parsed.assets) ? parsed.assets : [],
197
+ bindings: Array.isArray(parsed.bindings) ? parsed.bindings : [],
198
+ }
199
+ } catch {
200
+ return null
201
+ }
202
+ }
203
+
204
+ export async function listCanvasSnapshots(cwd: string): Promise<CanvasIndexEntry[]> {
205
+ const index = await readCanvasIndex(cwd)
206
+ const fromIndex = Object.values(index.canvases)
207
+ if (fromIndex.length > 0) {
208
+ return fromIndex.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
209
+ }
210
+
211
+ try {
212
+ const dir = getCanvasDir(cwd)
213
+ const files = await readdir(dir)
214
+ const entries: CanvasIndexEntry[] = []
215
+ for (const file of files) {
216
+ if (!file.endsWith('.json') || file === INDEX_FILE) continue
217
+ try {
218
+ const raw = await readFile(join(dir, file), 'utf8')
219
+ const parsed = JSON.parse(raw)
220
+ if (typeof parsed.canvasId !== 'string') continue
221
+ entries.push({
222
+ canvasId: parsed.canvasId,
223
+ file,
224
+ updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : '',
225
+ shapeCount: Array.isArray(parsed.shapes) ? parsed.shapes.length : 0,
226
+ assetCount: Array.isArray(parsed.assets) ? parsed.assets.length : 0,
227
+ bindingCount: Array.isArray(parsed.bindings) ? parsed.bindings.length : 0,
228
+ })
229
+ } catch {
230
+ // Ignore malformed canvas files.
231
+ }
232
+ }
233
+ return entries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
234
+ } catch {
235
+ return []
236
+ }
237
+ }
238
+
239
+ async function readExistingSnapshot(path: string): Promise<{ raw: string; shapeCount: number } | null> {
240
+ try {
241
+ const raw = await readFile(path, 'utf8')
242
+ const parsed = JSON.parse(raw)
243
+ return { raw, shapeCount: Array.isArray(parsed.shapes) ? parsed.shapes.length : 0 }
244
+ } catch {
245
+ return null
246
+ }
247
+ }
248
+
249
+ function sanitizeCanvasId(canvasId: string) {
250
+ return canvasId.replace(/[^a-zA-Z0-9_-]/g, '_')
251
+ }
@@ -0,0 +1,88 @@
1
+ export type TldrawPhase = 'idle' | 'starting' | 'ready' | 'connected' | 'working' | 'error' | 'disconnected'
2
+
3
+ export type StatusUi = {
4
+ setStatus(name: string, value: string | undefined): void
5
+ theme: { fg(color: string, text: string): string }
6
+ }
7
+
8
+ export type StatusContext = { hasUI: boolean; ui: StatusUi }
9
+
10
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const
11
+
12
+ export function createTldrawStatusIndicator() {
13
+ let statusCtx: StatusContext | null = null
14
+ let spinnerTimer: ReturnType<typeof setInterval> | null = null
15
+ let spinnerPhase = 0
16
+
17
+ const divider = () => statusCtx!.ui.theme.fg('dim', '│ ')
18
+
19
+ const renderStatus = (phase: TldrawPhase) => {
20
+ if (!statusCtx?.hasUI) return
21
+ const iconColor = phaseIconColor(phase)
22
+ const icon = phaseIcon(phase)
23
+ statusCtx.ui.setStatus(
24
+ 'tldraw',
25
+ divider() + statusCtx.ui.theme.fg(iconColor, icon) + statusCtx.ui.theme.fg('muted', ' tldraw')
26
+ )
27
+ }
28
+
29
+ const renderSpinnerFrame = () => {
30
+ if (!statusCtx?.hasUI) return
31
+ const frame = SPINNER_FRAMES[spinnerPhase % SPINNER_FRAMES.length]
32
+ statusCtx.ui.setStatus(
33
+ 'tldraw',
34
+ divider() + statusCtx.ui.theme.fg('accent', frame) + statusCtx.ui.theme.fg('muted', ' tldraw')
35
+ )
36
+ spinnerPhase++
37
+ }
38
+
39
+ const startSpinner = () => {
40
+ if (spinnerTimer) return
41
+ spinnerPhase = 0
42
+ spinnerTimer = setInterval(renderSpinnerFrame, 120)
43
+ }
44
+
45
+ const stopSpinner = () => {
46
+ if (!spinnerTimer) return
47
+ clearInterval(spinnerTimer)
48
+ spinnerTimer = null
49
+ }
50
+
51
+ return {
52
+ update(ctx: StatusContext, phase: TldrawPhase) {
53
+ statusCtx = ctx
54
+ if (phase === 'working' || phase === 'starting') startSpinner()
55
+ else {
56
+ stopSpinner()
57
+ renderStatus(phase)
58
+ }
59
+ },
60
+ stop: stopSpinner,
61
+ }
62
+ }
63
+
64
+ const phaseIconColor = (phase: TldrawPhase): string => {
65
+ switch (phase) {
66
+ case 'error':
67
+ return 'error'
68
+ case 'connected':
69
+ return 'success'
70
+ case 'disconnected':
71
+ return 'warning'
72
+ default:
73
+ return 'accent'
74
+ }
75
+ }
76
+
77
+ const phaseIcon = (phase: TldrawPhase): string => {
78
+ switch (phase) {
79
+ case 'connected':
80
+ return '●'
81
+ case 'disconnected':
82
+ return '○'
83
+ case 'error':
84
+ return '✗'
85
+ default:
86
+ return '•'
87
+ }
88
+ }