@jkwd/inbase 0.1.10 → 0.1.12

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,89 @@
1
+ import { useEffect } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+
4
+ export type MapContextMenuState = {
5
+ x: number
6
+ y: number
7
+ folder: string
8
+ }
9
+
10
+ type MapContextMenuProps = {
11
+ menu: MapContextMenuState | null
12
+ onAddFile: (folder: string) => void
13
+ onAddFolder: (folder: string) => void
14
+ onClose: () => void
15
+ }
16
+
17
+ export function MapContextMenu({
18
+ menu,
19
+ onAddFile,
20
+ onAddFolder,
21
+ onClose,
22
+ }: MapContextMenuProps) {
23
+ useEffect(() => {
24
+ if (!menu) return
25
+ const onKey = (event: KeyboardEvent) => {
26
+ if (event.code !== 'Escape') return
27
+ event.preventDefault()
28
+ onClose()
29
+ }
30
+ const onPointerDown = (event: PointerEvent) => {
31
+ const target = event.target
32
+ if (target instanceof Element && target.closest('.map-context-menu')) {
33
+ return
34
+ }
35
+ onClose()
36
+ }
37
+ window.addEventListener('keydown', onKey)
38
+ window.addEventListener('pointerdown', onPointerDown, true)
39
+ return () => {
40
+ window.removeEventListener('keydown', onKey)
41
+ window.removeEventListener('pointerdown', onPointerDown, true)
42
+ }
43
+ }, [menu, onClose])
44
+
45
+ if (!menu) return null
46
+
47
+ const pad = 8
48
+ const width = 176
49
+ const height = 84
50
+ const left = Math.min(
51
+ Math.max(pad, menu.x),
52
+ window.innerWidth - width - pad,
53
+ )
54
+ const top = Math.min(
55
+ Math.max(pad, menu.y),
56
+ window.innerHeight - height - pad,
57
+ )
58
+
59
+ return createPortal(
60
+ <div
61
+ className="map-context-menu"
62
+ role="menu"
63
+ style={{ left, top }}
64
+ onContextMenu={(event) => event.preventDefault()}
65
+ >
66
+ <button
67
+ type="button"
68
+ role="menuitem"
69
+ onClick={() => {
70
+ onAddFile(menu.folder)
71
+ onClose()
72
+ }}
73
+ >
74
+ Add file
75
+ </button>
76
+ <button
77
+ type="button"
78
+ role="menuitem"
79
+ onClick={() => {
80
+ onAddFolder(menu.folder)
81
+ onClose()
82
+ }}
83
+ >
84
+ Add folder
85
+ </button>
86
+ </div>,
87
+ document.body,
88
+ )
89
+ }
@@ -38,6 +38,16 @@ export function persistFollowLook(followLook: boolean) {
38
38
  })
39
39
  }
40
40
 
41
+ export function persistShowBranchChanges(showBranchChanges: boolean) {
42
+ fetch('/api/user-context', {
43
+ method: 'POST',
44
+ headers: { 'Content-Type': 'application/json' },
45
+ body: `${JSON.stringify({ showBranchChanges })}\n`,
46
+ }).catch(() => {
47
+ lastWritten = ''
48
+ })
49
+ }
50
+
41
51
  export function persistUserContext(context: UserContext) {
42
52
  pending = context
43
53
  if (timer !== null) return
@@ -51,6 +61,7 @@ function flushUserContext() {
51
61
  if (!context) return
52
62
  const {
53
63
  followLook: _followLook,
64
+ showBranchChanges: _showBranchChanges,
54
65
  userCreatedBlocks: _userCreatedBlocks,
55
66
  userCreatedIslands: _userCreatedIslands,
56
67
  ...gaze
@@ -5,6 +5,7 @@ import type {
5
5
  FileNode,
6
6
  PatchImportAddition,
7
7
  PatchSymbolAddition,
8
+ PlacedFolder,
8
9
  UserCreatedBlock,
9
10
  UserCreatedIsland,
10
11
  WorldLayout,
@@ -193,6 +194,54 @@ export function withUserCreatedGraph(
193
194
  }
194
195
  }
195
196
 
197
+ function placedFolderParent(
198
+ folder: PlacedFolder,
199
+ islands: UserCreatedIsland[],
200
+ ) {
201
+ const created = islands.find((item) => islandKey(item) === folder.path)
202
+ if (created) return created.parent
203
+ return folderParent(folder.path)
204
+ }
205
+
206
+ function expandParentsToChildren(
207
+ folders: Record<string, PlacedFolder>,
208
+ islands: UserCreatedIsland[],
209
+ ) {
210
+ const parentPaths = new Set<string>()
211
+ for (const island of islands) {
212
+ let current: string | null = island.parent
213
+ while (current) {
214
+ parentPaths.add(current)
215
+ current = folderParent(current)
216
+ }
217
+ }
218
+ const ordered = [...parentPaths].sort(
219
+ (left, right) =>
220
+ right.split('/').filter(Boolean).length -
221
+ left.split('/').filter(Boolean).length,
222
+ )
223
+ for (const parentPath of ordered) {
224
+ const parent = folders[parentPath]
225
+ if (!parent) continue
226
+ const children = Object.values(folders).filter(
227
+ (folder) =>
228
+ folder.path !== parentPath &&
229
+ placedFolderParent(folder, islands) === parentPath,
230
+ )
231
+ if (children.length === 0) continue
232
+ let extent = parent.width / 2
233
+ for (const child of children) {
234
+ extent = Math.max(
235
+ extent,
236
+ Math.abs(child.x - child.width / 2 - parent.x),
237
+ Math.abs(child.x + child.width / 2 - parent.x),
238
+ )
239
+ }
240
+ const width = extent * 2
241
+ if (width > parent.width) folders[parentPath] = { ...parent, width }
242
+ }
243
+ }
244
+
196
245
  function overlayIslands(
197
246
  layout: WorldLayout,
198
247
  islands: UserCreatedIsland[],
@@ -245,6 +294,7 @@ function overlayIslands(
245
294
  })
246
295
  }
247
296
 
297
+ expandParentsToChildren(folders, islands)
248
298
  return { ...layout, folders, bridges }
249
299
  }
250
300
 
@@ -6,11 +6,13 @@ import path from 'node:path'
6
6
  import { fileURLToPath } from 'node:url'
7
7
  import type { IncomingMessage, ServerResponse } from 'node:http'
8
8
  import { emptyIntent } from './scripts/patch-lib.mjs'
9
+ import { readBranchChanges } from './scripts/branch-changes.mjs'
10
+ import { writeRunningInstance } from '../../bin/project.mjs'
9
11
  import { dataDir, targetRoot } from './scripts/target-config.mjs'
10
12
  import { editorFileUri, openInEditor } from './scripts/open-editor.mjs'
11
13
  import {
12
14
  answerBlueprint,
13
- recoverOpenDiffSessions,
15
+ clearDiffSessions,
14
16
  continueDiff,
15
17
  inspectTargetFile,
16
18
  invokeStep,
@@ -19,7 +21,9 @@ import {
19
21
  requestReplan,
20
22
  sendBlueprint,
21
23
  sessionIntent,
24
+ setInitialInstruction,
22
25
  setStepByStep,
26
+ setupSession,
23
27
  focusSession,
24
28
  stopSession,
25
29
  updateBlueprint,
@@ -85,8 +89,14 @@ function jsonFilePlugin(): Plugin {
85
89
  return {
86
90
  name: 'visual-coder-json-files',
87
91
  configureServer(server) {
88
- recoverOpenDiffSessions(dataDir, targetRoot)
89
- rescanTarget('after recovering diff sessions')
92
+ writeRunningInstance({
93
+ dataDir,
94
+ targetRoot,
95
+ port: server.config.server.port ?? 5173,
96
+ })
97
+ // Always boot with no LLM session. Leftover diffs and pointers are not restored.
98
+ clearDiffSessions(dataDir, targetRoot)
99
+ rescanTarget('after discarding leftover LLM sessions')
90
100
  server.middlewares.use('/api/user-context', (req, res, next) => {
91
101
  if (req.method === 'GET') {
92
102
  sendJson(res, 200, readUserContext())
@@ -145,6 +155,14 @@ function jsonFilePlugin(): Plugin {
145
155
  next()
146
156
  })
147
157
 
158
+ server.middlewares.use('/api/branch-changes', (req, res, next) => {
159
+ if (req.method === 'GET') {
160
+ sendJson(res, 200, readBranchChanges(targetRoot, knownFileIds()))
161
+ return
162
+ }
163
+ next()
164
+ })
165
+
148
166
  server.middlewares.use('/api/inspect-file', (req, res, next) => {
149
167
  if (req.method === 'POST') {
150
168
  void inspectFile(req, res)
@@ -191,6 +209,7 @@ async function decideIntent(req: IncomingMessage, res: ServerResponse) {
191
209
  sessionId?: string
192
210
  diffId?: string
193
211
  instruction?: string
212
+ name?: string
194
213
  step?: number
195
214
  stepByStep?: boolean
196
215
  userCreatedBlocks?: unknown[]
@@ -210,12 +229,14 @@ async function decideIntent(req: IncomingMessage, res: ServerResponse) {
210
229
  action !== 'blueprint_send' &&
211
230
  action !== 'blueprint_update' &&
212
231
  action !== 'focus' &&
213
- action !== 'set_step_by_step'
232
+ action !== 'set_step_by_step' &&
233
+ action !== 'set_initial_instruction' &&
234
+ action !== 'setup_session'
214
235
  ) {
215
236
  sendJson(res, 400, { error: 'invalid workflow action' })
216
237
  return
217
238
  }
218
- if (!body.sessionId) {
239
+ if (action !== 'setup_session' && !body.sessionId) {
219
240
  sendJson(res, 400, { error: 'sessionId is required' })
220
241
  return
221
242
  }
@@ -227,13 +248,20 @@ async function decideIntent(req: IncomingMessage, res: ServerResponse) {
227
248
  return
228
249
  }
229
250
 
251
+ if (
252
+ body.name !== undefined &&
253
+ (typeof body.name !== 'string' || body.name.length > 200)
254
+ ) {
255
+ sendJson(res, 400, { error: 'name must be a string up to 200 characters' })
256
+ return
257
+ }
258
+
230
259
  if (action === 'invoke') {
231
260
  if (!Number.isInteger(body.step)) {
232
261
  sendJson(res, 400, { error: 'step is required for invoke' })
233
262
  return
234
263
  }
235
264
  invokeStep(dataDir, body.sessionId, body.step as number, targetRoot)
236
- rescanTarget('after invoking step')
237
265
  } else if (action === 'continue') {
238
266
  if (!body.diffId) {
239
267
  sendJson(res, 400, { error: 'diffId is required for continue' })
@@ -274,6 +302,16 @@ async function decideIntent(req: IncomingMessage, res: ServerResponse) {
274
302
  addedVariables: body.addedVariables,
275
303
  addedImports: body.addedImports,
276
304
  })
305
+ } else if (action === 'setup_session') {
306
+ const manifest = setupSession(dataDir, {
307
+ sessionId: body.sessionId,
308
+ name: body.name,
309
+ })
310
+ const next = sessionIntent(dataDir, manifest.sessionId, knownFileIds())
311
+ sendJson(res, 200, next ?? { ...emptyIntent })
312
+ return
313
+ } else if (action === 'set_initial_instruction') {
314
+ setInitialInstruction(dataDir, body.sessionId, body.instruction ?? '')
277
315
  } else if (action === 'focus') {
278
316
  focusSession(dataDir, body.sessionId)
279
317
  } else if (action === 'set_step_by_step') {
@@ -330,9 +368,10 @@ function readUserContext() {
330
368
  return {
331
369
  ...parsed,
332
370
  followLook: Boolean(parsed.followLook),
371
+ showBranchChanges: Boolean(parsed.showBranchChanges),
333
372
  }
334
373
  } catch {
335
- return { followLook: false }
374
+ return { followLook: false, showBranchChanges: false }
336
375
  }
337
376
  }
338
377
 
@@ -347,6 +386,10 @@ async function writeUserContext(req: IncomingMessage, res: ServerResponse) {
347
386
  typeof incoming.followLook === 'boolean'
348
387
  ? incoming.followLook
349
388
  : Boolean(existing.followLook),
389
+ showBranchChanges:
390
+ typeof incoming.showBranchChanges === 'boolean'
391
+ ? incoming.showBranchChanges
392
+ : Boolean(existing.showBranchChanges),
350
393
  }
351
394
  delete next.userCreatedBlocks
352
395
  delete next.userCreatedIslands
@@ -360,10 +403,21 @@ async function writeUserContext(req: IncomingMessage, res: ServerResponse) {
360
403
  }
361
404
  }
362
405
 
406
+ function isDataDirPath(filePath: string) {
407
+ const file = path.resolve(filePath)
408
+ const root = path.resolve(dataDir)
409
+ return file === root || file.startsWith(root + path.sep)
410
+ }
411
+
363
412
  export default defineConfig({
364
413
  plugins: [react(), jsonFilePlugin()],
365
414
  server: {
366
415
  port: 5173,
416
+ watch: {
417
+ // Session snapshots copy target source into the data dir. If Vite
418
+ // watches those writes, Create proposal full-reloads the visualizer.
419
+ ignored: ['**/src/data/**', isDataDirPath],
420
+ },
367
421
  fs: {
368
422
  allow: [here, dataDir],
369
423
  },
package/bin/inbase.mjs CHANGED
@@ -10,12 +10,14 @@ import {
10
10
  ensureGitignoreEntry,
11
11
  explorerRoot,
12
12
  skillTemplateDir,
13
+ commandTemplateDir,
13
14
  takeFlagValue,
14
15
  } from './project.mjs'
15
16
  import {
16
17
  proposePatch,
17
18
  reportPlan,
18
19
  startSession,
20
+ attachSession,
19
21
  waitForApproval,
20
22
  waitForBlueprint,
21
23
  } from './session.mjs'
@@ -28,11 +30,12 @@ Usage:
28
30
  inbase help Show this help
29
31
 
30
32
  Agent commands (used by the Cursor skill):
31
- inbase start-session --session <id> [--feature "name"]
33
+ inbase start-session --session <id> --name "short name"
34
+ inbase attach [--session <id>]
32
35
  inbase wait-for-blueprint --session <id>
33
36
  inbase report-plan --session <id> --feature "name" --steps "one" [--steps "two"]
34
37
  inbase wait-for-approval --session <id>
35
- inbase propose-patch --session <id> <file.patch|->
38
+ inbase propose-patch --session <id> [file.patch|-]
36
39
  inbase propose-patch --session <id> --clear
37
40
 
38
41
  Options for run:
@@ -50,6 +53,10 @@ export function initProject(projectRoot = process.cwd()) {
50
53
  }
51
54
  const skillDir = path.join(projectRoot, '.cursor/skills/inbase')
52
55
  copyDir(skillTemplateDir, skillDir)
56
+ const commandDir = path.join(projectRoot, '.cursor/commands')
57
+ if (fs.existsSync(commandTemplateDir)) {
58
+ copyDir(commandTemplateDir, commandDir)
59
+ }
53
60
  const { dataDir } = applyHostEnv({
54
61
  cwd: projectRoot,
55
62
  target: projectRoot,
@@ -57,7 +64,7 @@ export function initProject(projectRoot = process.cwd()) {
57
64
  })
58
65
  ensureDataDir(dataDir)
59
66
  const gitignoreAdded = ensureGitignoreEntry(projectRoot)
60
- return { skillDir, dataDir, gitignoreAdded }
67
+ return { skillDir, commandDir, dataDir, gitignoreAdded }
61
68
  }
62
69
 
63
70
  function explorerHref(relative) {
@@ -122,6 +129,9 @@ export async function main(argv = process.argv.slice(2)) {
122
129
  if (command === 'init') {
123
130
  const result = initProject()
124
131
  console.log(`Installed Cursor skill at ${result.skillDir}`)
132
+ if (fs.existsSync(path.join(result.commandDir, 'inbase.md'))) {
133
+ console.log(`Installed /inbase command at ${result.commandDir}`)
134
+ }
125
135
  if (result.gitignoreAdded) console.log('Added .inbase/ to .gitignore')
126
136
  console.log('Next: run `inbase run`, then ask Cursor to change source files.')
127
137
  return
@@ -132,13 +142,22 @@ export async function main(argv = process.argv.slice(2)) {
132
142
  return
133
143
  }
134
144
 
135
- applyHostEnv()
145
+ const host = applyHostEnv()
136
146
  ensureDataDir(process.env.INBASE_DATA_DIR)
147
+ if (host.instance) {
148
+ console.log(
149
+ `INBASE_ATTACHED Using the running visualizer (${host.instance.dataDir}). Run wait-for-blueprint to read the optional blueprint; it does not wait.`,
150
+ )
151
+ }
137
152
 
138
153
  if (command === 'start-session') {
139
154
  await startSession(args)
140
155
  return
141
156
  }
157
+ if (command === 'attach') {
158
+ await attachSession(args)
159
+ return
160
+ }
142
161
  if (command === 'wait-for-blueprint') {
143
162
  await waitForBlueprint(args)
144
163
  return
package/bin/project.mjs CHANGED
@@ -6,6 +6,7 @@ const here = path.dirname(fileURLToPath(import.meta.url))
6
6
  export const packageRoot = path.resolve(here, '..')
7
7
  export const explorerRoot = path.join(packageRoot, 'apps/explorer')
8
8
  export const skillTemplateDir = path.join(packageRoot, 'skill/inbase')
9
+ export const commandTemplateDir = path.join(packageRoot, 'skill/commands')
9
10
 
10
11
  export function resolveOptionalPath(value, fallback) {
11
12
  const raw = value?.trim()
@@ -13,19 +14,76 @@ export function resolveOptionalPath(value, fallback) {
13
14
  return path.isAbsolute(raw) ? path.normalize(raw) : path.resolve(process.cwd(), raw)
14
15
  }
15
16
 
17
+ export const INSTANCE_FILE = 'instance.json'
18
+
19
+ function isPidAlive(pid) {
20
+ if (!Number.isInteger(pid)) return true
21
+ try {
22
+ process.kill(pid, 0)
23
+ return true
24
+ } catch {
25
+ return false
26
+ }
27
+ }
28
+
29
+ export function instanceFile(dataDir) {
30
+ return path.join(dataDir, INSTANCE_FILE)
31
+ }
32
+
33
+ export function writeRunningInstance({ dataDir, targetRoot, port = null }) {
34
+ fs.mkdirSync(dataDir, { recursive: true })
35
+ const instance = {
36
+ dataDir: path.resolve(dataDir),
37
+ targetRoot: path.resolve(targetRoot),
38
+ port: port ?? null,
39
+ pid: process.pid,
40
+ updatedAt: new Date().toISOString(),
41
+ }
42
+ fs.writeFileSync(instanceFile(dataDir), `${JSON.stringify(instance, null, 2)}\n`)
43
+ return instance
44
+ }
45
+
46
+ export function readInstanceFile(file) {
47
+ try {
48
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
49
+ if (!parsed?.dataDir || !parsed?.targetRoot) return null
50
+ if (!isPidAlive(parsed.pid)) return null
51
+ return parsed
52
+ } catch {
53
+ return null
54
+ }
55
+ }
56
+
57
+ export function readRunningInstance(cwd = process.cwd()) {
58
+ const files = [
59
+ path.join(cwd, '.inbase', INSTANCE_FILE),
60
+ path.join(explorerRoot, 'src/data', INSTANCE_FILE),
61
+ ]
62
+ const seen = new Set()
63
+ for (const file of files) {
64
+ const resolved = path.resolve(file)
65
+ if (seen.has(resolved) || !fs.existsSync(resolved)) continue
66
+ seen.add(resolved)
67
+ const instance = readInstanceFile(resolved)
68
+ if (instance) return instance
69
+ }
70
+ return null
71
+ }
72
+
16
73
  export function applyHostEnv({
17
74
  cwd = process.cwd(),
18
75
  target = process.env.VISUAL_CODER_TARGET,
19
76
  dataDir = process.env.INBASE_DATA_DIR,
20
77
  } = {}) {
21
- const targetRoot = resolveOptionalPath(target, cwd)
78
+ const running = !target && !dataDir ? readRunningInstance(cwd) : null
79
+ const targetRoot = resolveOptionalPath(target, running?.targetRoot ?? cwd)
22
80
  const resolvedDataDir = resolveOptionalPath(
23
81
  dataDir,
24
- path.join(targetRoot, '.inbase'),
82
+ running?.dataDir ?? path.join(targetRoot, '.inbase'),
25
83
  )
26
84
  process.env.VISUAL_CODER_TARGET = targetRoot
27
85
  process.env.INBASE_DATA_DIR = resolvedDataDir
28
- return { cwd, targetRoot, dataDir: resolvedDataDir }
86
+ return { cwd, targetRoot, dataDir: resolvedDataDir, instance: running }
29
87
  }
30
88
 
31
89
  export function ensureDataDir(dataDir) {
@@ -34,7 +92,7 @@ export function ensureDataDir(dataDir) {
34
92
  if (!fs.existsSync(userContextFile)) {
35
93
  fs.writeFileSync(
36
94
  userContextFile,
37
- `${JSON.stringify({ followLook: false }, null, 2)}\n`,
95
+ `${JSON.stringify({ followLook: false, showBranchChanges: false }, null, 2)}\n`,
38
96
  )
39
97
  }
40
98
  return dataDir