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.
- package/LICENSE +21 -0
- package/README.md +222 -0
- package/bridge/app-bridge-entry.js +6 -0
- package/mcp-app/LICENSE.md +9 -0
- package/mcp-app/PI_TLDRAW_PROVENANCE.json +32 -0
- package/mcp-app/README.md +129 -0
- package/mcp-app/dev-tunnel.sh +51 -0
- package/mcp-app/dist/editor-api.json +8493 -0
- package/mcp-app/dist/mcp-app.html +643 -0
- package/mcp-app/dist/method-map.json +915 -0
- package/mcp-app/package.json +42 -0
- package/mcp-app/plugins/tldraw-mcp/.cursor-plugin/plugin.json +10 -0
- package/mcp-app/plugins/tldraw-mcp/assets/logo.svg +3 -0
- package/mcp-app/plugins/tldraw-mcp/mcp.json +8 -0
- package/mcp-app/scripts/extract-editor-api.ts +1374 -0
- package/mcp-app/server.json +21 -0
- package/mcp-app/src/logger.ts +45 -0
- package/mcp-app/src/register-tools.ts +368 -0
- package/mcp-app/src/shared/generated-data.ts +160 -0
- package/mcp-app/src/shared/pending-requests.ts +69 -0
- package/mcp-app/src/shared/types.ts +76 -0
- package/mcp-app/src/shared/utils.ts +132 -0
- package/mcp-app/src/tools/exec.ts +120 -0
- package/mcp-app/src/tools/loadCachedCanvasWidgetHtml.ts +16 -0
- package/mcp-app/src/tools/search.ts +150 -0
- package/mcp-app/src/widget/app-context.tsx +29 -0
- package/mcp-app/src/widget/dev-log.tsx +70 -0
- package/mcp-app/src/widget/exec-helpers.ts +232 -0
- package/mcp-app/src/widget/export-tldr.ts +35 -0
- package/mcp-app/src/widget/focused/defaults.ts +141 -0
- package/mcp-app/src/widget/focused/focused-editor-proxy.ts +434 -0
- package/mcp-app/src/widget/focused/format.ts +366 -0
- package/mcp-app/src/widget/focused/to-focused.ts +258 -0
- package/mcp-app/src/widget/focused/to-tldraw.ts +570 -0
- package/mcp-app/src/widget/image-guard.tsx +106 -0
- package/mcp-app/src/widget/index.html +33 -0
- package/mcp-app/src/widget/mcp-app.css +113 -0
- package/mcp-app/src/widget/mcp-app.tsx +857 -0
- package/mcp-app/src/widget/persistence.ts +337 -0
- package/mcp-app/src/widget/snapshot.ts +157 -0
- package/mcp-app/src/worker.ts +305 -0
- package/mcp-app/tsconfig.json +23 -0
- package/mcp-app/vite.config.ts +13 -0
- package/mcp-app/wrangler.toml +45 -0
- package/mcp-app-source.json +36 -0
- package/package.json +90 -0
- package/patches/tldraw-mcp-app/001-pi-runtime.patch +35 -0
- package/scripts/assemble-mcp-app.mjs +193 -0
- package/scripts/build-bridge.mjs +74 -0
- package/scripts/e2e-mcp.mjs +69 -0
- package/scripts/e2e-packaged-mcp-app.mjs +79 -0
- package/scripts/run-mcp-app-dev.mjs +44 -0
- package/scripts/verify-bundle.mjs +41 -0
- package/scripts/verify-mcp-app-source.mjs +51 -0
- package/scripts/verify-mcp-app.mjs +38 -0
- package/scripts/verify-package-files.mjs +50 -0
- package/src/canvas/export.ts +164 -0
- package/src/canvas/state.ts +117 -0
- package/src/canvas/workflow.ts +105 -0
- package/src/commands/tldraw-command.ts +48 -0
- package/src/diagram/guidance.ts +44 -0
- package/src/host/local-host.ts +289 -0
- package/src/index.ts +762 -0
- package/src/mcp/client.ts +126 -0
- package/src/mcp/response.ts +74 -0
- package/src/semantic/layer.ts +309 -0
- package/src/server/server-manager.ts +153 -0
- package/src/store/export-store.ts +33 -0
- package/src/store/project-store.ts +251 -0
- package/src/ui/tldraw-status.ts +88 -0
- package/static/app-bridge-bundle.js +18114 -0
- package/static/app-bridge-bundle.meta.json +164 -0
- package/static/host.html +390 -0
- 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
|
+
}
|