@jkwd/inbase 0.1.3 → 0.1.4

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,6 @@
1
+ export function editorFileUri(filePath: string): string
2
+ export function defaultCursorUserDataDir(): string
3
+ export function discoverCursorUserDataDirs(): string[]
4
+ export function openFoldersFromStorage(storage: unknown): string[]
5
+ export function cursorUserDataDirForFile(filePath: string): string | null
6
+ export function openInEditor(filePath: string): boolean
@@ -0,0 +1,219 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ export function editorFileUri(filePath) {
8
+ return `vscode://file${encodeURI(filePath)}`
9
+ }
10
+
11
+ function which(command) {
12
+ const result = spawnSync('which', [command], { encoding: 'utf8' })
13
+ if (result.error || result.status !== 0) return null
14
+ return result.stdout.trim().split('\n')[0] || null
15
+ }
16
+
17
+ export function defaultCursorUserDataDir() {
18
+ if (process.platform === 'darwin') {
19
+ return path.join(os.homedir(), 'Library/Application Support/Cursor')
20
+ }
21
+ if (process.platform === 'win32') {
22
+ return path.join(os.homedir(), 'AppData/Roaming/Cursor')
23
+ }
24
+ return path.join(os.homedir(), '.config/Cursor')
25
+ }
26
+
27
+ function expandUser(value) {
28
+ if (!value) return null
29
+ const trimmed = String(value).trim().replace(/^['"]|['"]$/g, '')
30
+ if (!trimmed) return null
31
+ if (trimmed.startsWith('~')) {
32
+ return path.resolve(os.homedir() + trimmed.slice(1))
33
+ }
34
+ return path.resolve(trimmed)
35
+ }
36
+
37
+ function isCursorUserDataDir(dir) {
38
+ try {
39
+ if (fs.existsSync(path.join(dir, 'User/globalStorage/storage.json'))) return true
40
+ return fs.readdirSync(dir).some((name) => name.endsWith('-main.sock'))
41
+ } catch {
42
+ return false
43
+ }
44
+ }
45
+
46
+ function userDataDirFromHook(hook = process.env.VSCODE_IPC_HOOK) {
47
+ if (!hook || !hook.endsWith('.sock')) return null
48
+ const dir = path.dirname(hook)
49
+ return isCursorUserDataDir(dir) ? dir : null
50
+ }
51
+
52
+ function userDataDirsFromProcessList() {
53
+ const result = spawnSync('ps', ['-ax', '-o', 'command='], { encoding: 'utf8' })
54
+ if (result.error || result.status !== 0) return []
55
+ const dirs = []
56
+ for (const line of result.stdout.split('\n')) {
57
+ const match = line.match(/--user-data-dir(?:=|\s+)(\S+)/)
58
+ if (!match) continue
59
+ const dir = expandUser(match[1])
60
+ if (dir && isCursorUserDataDir(dir)) dirs.push(dir)
61
+ }
62
+ return dirs
63
+ }
64
+
65
+ function profileDirsInHome() {
66
+ let names = []
67
+ try {
68
+ names = fs.readdirSync(os.homedir())
69
+ } catch {
70
+ return []
71
+ }
72
+ return names
73
+ .filter((name) => name.startsWith('cursor-profile'))
74
+ .map((name) => path.join(os.homedir(), name))
75
+ .filter(isCursorUserDataDir)
76
+ }
77
+
78
+ export function discoverCursorUserDataDirs() {
79
+ const dirs = new Set()
80
+ const explicit = expandUser(
81
+ process.env.INBASE_CURSOR_USER_DATA_DIR || process.env.CURSOR_USER_DATA_DIR,
82
+ )
83
+ if (explicit && isCursorUserDataDir(explicit)) dirs.add(explicit)
84
+ const hookDir = userDataDirFromHook()
85
+ if (hookDir) dirs.add(hookDir)
86
+ const def = defaultCursorUserDataDir()
87
+ if (isCursorUserDataDir(def)) dirs.add(def)
88
+ for (const dir of profileDirsInHome()) dirs.add(dir)
89
+ for (const dir of userDataDirsFromProcessList()) dirs.add(dir)
90
+ return [...dirs]
91
+ }
92
+
93
+ function folderPathFromUri(value) {
94
+ if (typeof value !== 'string' || !value) return null
95
+ try {
96
+ return value.startsWith('file:') ? fileURLToPath(value) : path.resolve(value)
97
+ } catch {
98
+ return null
99
+ }
100
+ }
101
+
102
+ function fileIsInside(folder, filePath) {
103
+ const root = path.resolve(folder)
104
+ const file = path.resolve(filePath)
105
+ return file === root || file.startsWith(`${root}${path.sep}`)
106
+ }
107
+
108
+ export function openFoldersFromStorage(storage) {
109
+ const state = storage?.windowsState
110
+ if (!state || typeof state !== 'object') return []
111
+ const windows = [
112
+ state.lastActiveWindow,
113
+ ...(Array.isArray(state.openedWindows) ? state.openedWindows : []),
114
+ ]
115
+ const folders = []
116
+ for (const window of windows) {
117
+ const folder = folderPathFromUri(window?.folder)
118
+ if (folder) folders.push(folder)
119
+ }
120
+ return folders
121
+ }
122
+
123
+ function readOpenFolders(userDataDir) {
124
+ const file = path.join(userDataDir, 'User/globalStorage/storage.json')
125
+ try {
126
+ return openFoldersFromStorage(JSON.parse(fs.readFileSync(file, 'utf8')))
127
+ } catch {
128
+ return []
129
+ }
130
+ }
131
+
132
+ function lastActiveFolder(userDataDir) {
133
+ const file = path.join(userDataDir, 'User/globalStorage/storage.json')
134
+ try {
135
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
136
+ return folderPathFromUri(parsed?.windowsState?.lastActiveWindow?.folder)
137
+ } catch {
138
+ return null
139
+ }
140
+ }
141
+
142
+ export function cursorUserDataDirForFile(filePath) {
143
+ const explicit = expandUser(
144
+ process.env.INBASE_CURSOR_USER_DATA_DIR || process.env.CURSOR_USER_DATA_DIR,
145
+ )
146
+ if (explicit && isCursorUserDataDir(explicit)) return explicit
147
+
148
+ const hookDir = userDataDirFromHook()
149
+ let best = null
150
+ let bestScore = 0
151
+ for (const dir of discoverCursorUserDataDirs()) {
152
+ const folders = readOpenFolders(dir)
153
+ if (!folders.some((folder) => fileIsInside(folder, filePath))) continue
154
+ let score = 1
155
+ if (hookDir && path.resolve(hookDir) === path.resolve(dir)) score += 2
156
+ const last = lastActiveFolder(dir)
157
+ if (last && fileIsInside(last, filePath)) score += 2
158
+ if (score > bestScore) {
159
+ best = dir
160
+ bestScore = score
161
+ }
162
+ }
163
+ return best ?? hookDir
164
+ }
165
+
166
+ function cursorCliPaths() {
167
+ const home = os.homedir()
168
+ const fromEnv =
169
+ process.env.CURSOR_CLI && fs.existsSync(process.env.CURSOR_CLI)
170
+ ? process.env.CURSOR_CLI
171
+ : null
172
+ return [
173
+ fromEnv,
174
+ which('cursor'),
175
+ '/Applications/Cursor.app/Contents/Resources/app/bin/cursor',
176
+ path.join(home, '.local/bin/cursor'),
177
+ '/usr/local/bin/cursor',
178
+ '/opt/homebrew/bin/cursor',
179
+ ].filter((candidate, index, list) => {
180
+ if (!candidate || typeof candidate !== 'string') return false
181
+ return list.indexOf(candidate) === index
182
+ })
183
+ }
184
+
185
+ function editorEnv() {
186
+ const env = { ...process.env }
187
+ delete env.ELECTRON_RUN_AS_NODE
188
+ delete env.ELECTRON_NO_ASAR
189
+ delete env.CURSOR_AGENT
190
+ // A second Cursor instance uses its own sockets. Inherited hooks from
191
+ // another profile would send --goto to the wrong window.
192
+ delete env.VSCODE_IPC_HOOK
193
+ delete env.VSCODE_IPC_HOOK_CLI
194
+ return env
195
+ }
196
+
197
+ function runEditor(command, args) {
198
+ if (command.includes(path.sep) && !fs.existsSync(command)) return false
199
+ const result = spawnSync(command, args, {
200
+ stdio: 'ignore',
201
+ env: editorEnv(),
202
+ })
203
+ return !result.error && result.status === 0
204
+ }
205
+
206
+ export function openInEditor(filePath) {
207
+ const userDataDir = cursorUserDataDirForFile(filePath)
208
+ const goto = ['--goto', `${filePath}:1`]
209
+ if (userDataDir) {
210
+ goto.unshift('--user-data-dir', userDataDir)
211
+ }
212
+ for (const command of cursorCliPaths()) {
213
+ if (runEditor(command, goto)) return true
214
+ }
215
+
216
+ const code = which('code')
217
+ if (code && runEditor(code, goto)) return true
218
+ return false
219
+ }
@@ -119,6 +119,36 @@ export function sessionIntent(
119
119
  knownFileIds?: string[],
120
120
  selectedDiffId?: string,
121
121
  ): Record<string, unknown> | null
122
+ export function resolveTargetFile(
123
+ targetRoot: string,
124
+ fileId: string,
125
+ ): { id: string; absolute: string }
126
+ export function captureBaseline(
127
+ dataDir: string,
128
+ sessionId: string,
129
+ targetRoot: string,
130
+ fileIds?: string[],
131
+ ): { files: Record<string, { existed: boolean }> }
132
+ export function restoreBaseline(
133
+ dataDir: string,
134
+ sessionId: string,
135
+ targetRoot: string,
136
+ ): void
137
+ export function materializeDiff(
138
+ dataDir: string,
139
+ targetRoot: string,
140
+ sessionId: string,
141
+ diffId?: string | null,
142
+ ): DiffManifest
143
+ export function inspectTargetFile(
144
+ dataDir: string,
145
+ targetRoot: string,
146
+ input?: {
147
+ sessionId?: string | null
148
+ diffId?: string | null
149
+ fileId?: string | null
150
+ },
151
+ ): string | null
122
152
  export function appendDiff(
123
153
  dataDir: string,
124
154
  targetRoot: string,
@@ -142,7 +172,7 @@ export function requestReplan(
142
172
  export function stopSession(
143
173
  dataDir: string,
144
174
  sessionId: string,
145
- diffId?: string,
175
+ targetRoot?: string | null,
146
176
  ): null
147
177
  export function decideDiff(
148
178
  dataDir: string,
@@ -44,9 +44,28 @@ export function sessionPaths(dataDir, sessionId) {
44
44
  diffs: path.join(root, 'diffs'),
45
45
  manifest: path.join(root, 'manifest.json'),
46
46
  blueprint: path.join(root, 'blueprint.json'),
47
+ baseline: path.join(root, 'baseline.json'),
48
+ baselineFiles: path.join(root, 'baseline'),
47
49
  }
48
50
  }
49
51
 
52
+ export function resolveTargetFile(targetRoot, fileId) {
53
+ if (typeof fileId !== 'string' || fileId.trim() === '') {
54
+ throw new Error('fileId is required')
55
+ }
56
+ const normalized = fileId.trim().replaceAll('\\', '/').replace(/^\/+/, '')
57
+ if (!normalized || normalized === '.' || normalized.includes('..')) {
58
+ throw new Error(`Invalid file id ${fileId}`)
59
+ }
60
+ const root = path.resolve(targetRoot)
61
+ const absolute = path.resolve(root, normalized)
62
+ const prefix = root.endsWith(path.sep) ? root : `${root}${path.sep}`
63
+ if (absolute !== root && !absolute.startsWith(prefix)) {
64
+ throw new Error(`Invalid file id ${fileId}`)
65
+ }
66
+ return { id: normalized, absolute }
67
+ }
68
+
50
69
  export function readActiveSession(dataDir) {
51
70
  const value = readJson(path.join(dataDir, 'active-session.json'), null)
52
71
  return value?.sessionId ? assertSessionId(value.sessionId) : null
@@ -305,10 +324,105 @@ export function sessionIntent(dataDir, sessionId, knownFileIds = [], selectedDif
305
324
  }
306
325
  }
307
326
 
308
- function withVirtualTarget(targetRoot, patches, action) {
327
+ function emptyBaseline() {
328
+ return { files: {} }
329
+ }
330
+
331
+ function readBaseline(dataDir, sessionId) {
332
+ const { baseline } = sessionPaths(dataDir, sessionId)
333
+ const value = readJson(baseline, emptyBaseline())
334
+ return {
335
+ files:
336
+ value?.files && typeof value.files === 'object' && !Array.isArray(value.files)
337
+ ? value.files
338
+ : {},
339
+ }
340
+ }
341
+
342
+ function writeBaseline(dataDir, sessionId, baseline) {
343
+ const { baseline: file } = sessionPaths(dataDir, sessionId)
344
+ atomicWrite(file, `${JSON.stringify({ files: baseline.files ?? {} }, null, 2)}\n`)
345
+ }
346
+
347
+ function pruneEmptyDirs(targetRoot, filePath) {
348
+ const root = path.resolve(targetRoot)
349
+ let current = path.dirname(filePath)
350
+ while (current.startsWith(`${root}${path.sep}`)) {
351
+ if (!fs.existsSync(current)) {
352
+ current = path.dirname(current)
353
+ continue
354
+ }
355
+ if (fs.readdirSync(current).length > 0) break
356
+ fs.rmdirSync(current)
357
+ current = path.dirname(current)
358
+ }
359
+ }
360
+
361
+ export function captureBaseline(dataDir, sessionId, targetRoot, fileIds = []) {
362
+ const paths = sessionPaths(dataDir, sessionId)
363
+ const baseline = readBaseline(dataDir, sessionId)
364
+ let changed = false
365
+ for (const fileId of fileIds) {
366
+ const { id, absolute } = resolveTargetFile(targetRoot, fileId)
367
+ if (baseline.files[id]) continue
368
+ const existed = fs.existsSync(absolute) && fs.statSync(absolute).isFile()
369
+ baseline.files[id] = { existed }
370
+ if (existed) {
371
+ const stored = resolveTargetFile(paths.baselineFiles, id).absolute
372
+ fs.mkdirSync(path.dirname(stored), { recursive: true })
373
+ fs.copyFileSync(absolute, stored)
374
+ }
375
+ changed = true
376
+ }
377
+ if (changed) writeBaseline(dataDir, sessionId, baseline)
378
+ return baseline
379
+ }
380
+
381
+ export function restoreBaseline(dataDir, sessionId, targetRoot) {
382
+ const paths = sessionPaths(dataDir, sessionId)
383
+ const baseline = readBaseline(dataDir, sessionId)
384
+ for (const [fileId, info] of Object.entries(baseline.files)) {
385
+ const { absolute } = resolveTargetFile(targetRoot, fileId)
386
+ if (!info?.existed) {
387
+ fs.rmSync(absolute, { force: true })
388
+ pruneEmptyDirs(targetRoot, absolute)
389
+ continue
390
+ }
391
+ const stored = resolveTargetFile(paths.baselineFiles, fileId).absolute
392
+ fs.mkdirSync(path.dirname(absolute), { recursive: true })
393
+ fs.copyFileSync(stored, absolute)
394
+ }
395
+ }
396
+
397
+ function replayPatches(dataDir, sessionId, targetRoot, entries) {
398
+ for (const entry of entries) {
399
+ applyUnifiedPatch(readDiff(dataDir, sessionId, entry), targetRoot)
400
+ }
401
+ }
402
+
403
+ function acceptedEntries(manifest) {
404
+ return manifest.diffs.filter((entry) => entry.status === 'applied')
405
+ }
406
+
407
+ function liveEntries(manifest, diffId) {
408
+ return chainThrough(manifest, diffId).filter((entry) => entry.status !== 'rejected')
409
+ }
410
+
411
+ export function materializeDiff(dataDir, targetRoot, sessionId, diffId) {
412
+ const manifest = readManifest(dataDir, assertSessionId(sessionId))
413
+ if (!manifest) throw new Error(`Unknown session ${sessionId}`)
414
+ const through = diffId || manifest.activeDiffId
415
+ if (!through) return manifest
416
+ restoreBaseline(dataDir, sessionId, targetRoot)
417
+ replayPatches(dataDir, sessionId, targetRoot, liveEntries(manifest, through))
418
+ return manifest
419
+ }
420
+
421
+ function withSessionReplay(dataDir, sessionId, targetRoot, patches, action) {
309
422
  const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'visual-coder-chain-'))
310
423
  try {
311
424
  fs.cpSync(targetRoot, temporary, { recursive: true })
425
+ restoreBaseline(dataDir, sessionId, temporary)
312
426
  for (const patchText of patches) applyUnifiedPatch(patchText, temporary)
313
427
  return action(temporary)
314
428
  } finally {
@@ -317,10 +431,28 @@ function withVirtualTarget(targetRoot, patches, action) {
317
431
  }
318
432
 
319
433
  export function validateContinuation(dataDir, manifest, targetRoot, patchText) {
320
- const prior = unresolvedEntries(manifest).map((entry) =>
321
- readDiff(dataDir, manifest.sessionId, entry),
434
+ const prior = manifest.diffs
435
+ .filter((entry) => entry.status !== 'rejected')
436
+ .map((entry) => readDiff(dataDir, manifest.sessionId, entry))
437
+ withSessionReplay(dataDir, manifest.sessionId, targetRoot, [...prior, patchText], () =>
438
+ undefined,
322
439
  )
323
- withVirtualTarget(targetRoot, [...prior, patchText], () => undefined)
440
+ }
441
+
442
+ export function inspectTargetFile(
443
+ dataDir,
444
+ targetRoot,
445
+ { sessionId, diffId, fileId } = {},
446
+ ) {
447
+ if (sessionId && readManifest(dataDir, sessionId)) {
448
+ materializeDiff(dataDir, targetRoot, sessionId, diffId)
449
+ }
450
+ if (!fileId) return null
451
+ const { absolute } = resolveTargetFile(targetRoot, fileId)
452
+ if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) {
453
+ throw new Error(`File ${fileId} is not on disk`)
454
+ }
455
+ return absolute
324
456
  }
325
457
 
326
458
  function planSteps(titles, startAt = 1) {
@@ -555,6 +687,12 @@ export function appendDiff(dataDir, targetRoot, input) {
555
687
  }
556
688
 
557
689
  validateContinuation(dataDir, manifest, targetRoot, input.patchText)
690
+ captureBaseline(
691
+ dataDir,
692
+ sessionId,
693
+ targetRoot,
694
+ parseUnifiedPatch(input.patchText).entries.map((entry) => entry.id),
695
+ )
558
696
  if (parent?.status === 'extend') parent.status = 'extended'
559
697
 
560
698
  const id = String(manifest.diffs.length + 1).padStart(4, '0')
@@ -581,6 +719,7 @@ export function appendDiff(dataDir, targetRoot, input) {
581
719
  manifest.workStartedAt = null
582
720
  manifest.diffs.push(entry)
583
721
  writeManifest(dataDir, manifest)
722
+ materializeDiff(dataDir, targetRoot, sessionId, id)
584
723
  focusSession(dataDir, sessionId)
585
724
  return { manifest, entry }
586
725
  }
@@ -601,25 +740,7 @@ function pendingActive(manifest, diffId) {
601
740
 
602
741
  function applyUnresolved(dataDir, targetRoot, manifest, diffId) {
603
742
  const unresolved = unresolvedEntries(manifest, diffId)
604
- const patches = unresolved.map((entry) =>
605
- readDiff(dataDir, manifest.sessionId, entry),
606
- )
607
- const touched = new Set()
608
- for (const patch of patches) {
609
- for (const entry of parseUnifiedPatch(patch).entries) touched.add(entry.id)
610
- }
611
- withVirtualTarget(targetRoot, patches, (virtualRoot) => {
612
- for (const id of touched) {
613
- const source = path.join(virtualRoot, id)
614
- const destination = path.join(targetRoot, id)
615
- if (!fs.existsSync(source)) {
616
- fs.rmSync(destination, { recursive: true, force: true })
617
- continue
618
- }
619
- fs.mkdirSync(path.dirname(destination), { recursive: true })
620
- fs.copyFileSync(source, destination)
621
- }
622
- })
743
+ materializeDiff(dataDir, targetRoot, manifest.sessionId, diffId)
623
744
  for (const entry of unresolved) {
624
745
  entry.status = 'applied'
625
746
  entry.decidedAt = new Date().toISOString()
@@ -663,8 +784,14 @@ export function requestReplan(dataDir, sessionId, diffId, instruction) {
663
784
  return manifest
664
785
  }
665
786
 
666
- export function stopSession(dataDir, sessionId, _diffId) {
667
- finalizeFinishedSession(dataDir, sessionId)
787
+ export function stopSession(dataDir, sessionId, targetRoot = null) {
788
+ const safeId = assertSessionId(sessionId)
789
+ const manifest = readManifest(dataDir, safeId)
790
+ if (manifest && targetRoot) {
791
+ restoreBaseline(dataDir, safeId, targetRoot)
792
+ replayPatches(dataDir, safeId, targetRoot, acceptedEntries(manifest))
793
+ }
794
+ finalizeFinishedSession(dataDir, safeId)
668
795
  return null
669
796
  }
670
797
 
@@ -682,7 +809,7 @@ export function decideDiff(
682
809
  if (decision === 'extend') {
683
810
  return requestReplan(dataDir, sessionId, diffId, instruction)
684
811
  }
685
- return stopSession(dataDir, sessionId, diffId)
812
+ return stopSession(dataDir, sessionId, targetRoot)
686
813
  }
687
814
 
688
815
  export function closeSession(dataDir, sessionId) {
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
2
  import { Canvas } from '@react-three/fiber'
3
- import { emptyIntent, fetchAgentIntent, performAgentAction, persistSessionBlueprint } from './agentIntent'
3
+ import { emptyIntent, fetchAgentIntent, inspectTargetFile, performAgentAction, persistSessionBlueprint } from './agentIntent'
4
4
  import { fetchCodebase } from './codebase'
5
5
  import {
6
6
  layoutWorld,
@@ -163,6 +163,7 @@ function Explorer({
163
163
  ])
164
164
  const walkPos = useRef<[number, number]>([layout.spawn[0], layout.spawn[2]])
165
165
  const [selectedId, setSelectedId] = useState<string | null>(null)
166
+ const [selectedTick, setSelectedTick] = useState(0)
166
167
  const [selectedFolder, setSelectedFolder] = useState<string | null>(null)
167
168
  const [aimedRelation, setAimedRelation] = useState<AimedRelation | null>(null)
168
169
  const [flyTo, setFlyTo] = useState<FlyTo | null>(null)
@@ -274,7 +275,7 @@ function Explorer({
274
275
  browsingHistory.current = false
275
276
  lastIntentSig.current = intentSignature(next)
276
277
  applyIntent(next)
277
- if (action === 'invoke' || action === 'continue') {
278
+ if (action === 'invoke' || action === 'continue' || action === 'stop') {
278
279
  await onRefreshGraph()
279
280
  }
280
281
  } catch {
@@ -300,6 +301,16 @@ function Explorer({
300
301
  try {
301
302
  const latest = intent.chain.at(-1)?.id
302
303
  browsingHistory.current = diffId !== latest
304
+ if (intent.sessionId) {
305
+ try {
306
+ await inspectTargetFile({
307
+ sessionId: intent.sessionId,
308
+ diffId,
309
+ })
310
+ } catch {
311
+ // Still show the historical preview if disk replay failed.
312
+ }
313
+ }
303
314
  const next = await fetchAgentIntent(diffId)
304
315
  lastIntentSig.current = intentSignature(next)
305
316
  applyIntent(next)
@@ -307,7 +318,22 @@ function Explorer({
307
318
  // Keep the current chain position if navigation failed.
308
319
  }
309
320
  },
310
- [applyIntent, intent.chain],
321
+ [applyIntent, intent.chain, intent.sessionId],
322
+ )
323
+
324
+ const inspectFile = useCallback(
325
+ async (fileId: string) => {
326
+ try {
327
+ await inspectTargetFile({
328
+ sessionId: intent.sessionId,
329
+ diffId: intent.diffId,
330
+ fileId,
331
+ })
332
+ } catch {
333
+ // Keep the current view if the editor could not open the file.
334
+ }
335
+ },
336
+ [intent.diffId, intent.sessionId],
311
337
  )
312
338
 
313
339
  useEffect(() => {
@@ -566,7 +592,10 @@ function Explorer({
566
592
  const selectFile = useCallback(
567
593
  (fileId: string | null) => {
568
594
  setSelectedId(fileId)
569
- if (fileId) setSelectedFolder(null)
595
+ if (fileId) {
596
+ setSelectedFolder(null)
597
+ setSelectedTick((tick) => tick + 1)
598
+ }
570
599
  if (fileId && intent.creationMode) document.exitPointerLock()
571
600
  },
572
601
  [intent.creationMode],
@@ -904,6 +933,7 @@ function Explorer({
904
933
  mode={mode}
905
934
  locked={locked}
906
935
  selectedId={selectedId}
936
+ selectedTick={selectedTick}
907
937
  selectedFolder={selectedFolder}
908
938
  aimedRelation={aimedRelation}
909
939
  currentFolder={currentFolder}
@@ -935,6 +965,7 @@ function Explorer({
935
965
  onRemoveBlueprintImport={removeBlueprintImport}
936
966
  onMapAddFile={placeBlockOnFolder}
937
967
  onMapAddFolder={placeIslandOnFolder}
968
+ onInspectFile={inspectFile}
938
969
  />
939
970
  </>
940
971
  )
@@ -180,3 +180,24 @@ export function persistSessionBlueprint(
180
180
  // Keep local drafts if the session handshake is no longer open.
181
181
  })
182
182
  }
183
+
184
+ export async function inspectTargetFile(payload: {
185
+ sessionId?: string | null
186
+ diffId?: string | null
187
+ fileId?: string
188
+ }) {
189
+ const response = await fetch('/api/inspect-file', {
190
+ method: 'POST',
191
+ headers: { 'Content-Type': 'application/json' },
192
+ body: JSON.stringify(payload),
193
+ })
194
+ if (!response.ok) {
195
+ const detail = await response.text()
196
+ throw new Error(detail || 'Could not inspect file')
197
+ }
198
+ return (await response.json()) as {
199
+ path: string | null
200
+ uri: string | null
201
+ opened: boolean
202
+ }
203
+ }
@@ -22,6 +22,7 @@ body,
22
22
  position: absolute;
23
23
  inset: 0;
24
24
  z-index: 0;
25
+ background: #000;
25
26
  }
26
27
 
27
28
  * {
@@ -566,6 +567,11 @@ button {
566
567
  word-break: break-all;
567
568
  }
568
569
 
570
+ .hud-inspect {
571
+ margin: 10px 0 0;
572
+ width: 100%;
573
+ }
574
+
569
575
  .hud-panel ul {
570
576
  margin: 8px 0 0;
571
577
  padding-left: 18px;
@@ -599,6 +605,21 @@ button {
599
605
  border-color: #8a4a4a;
600
606
  }
601
607
 
608
+ .hud-item-inspect {
609
+ flex: none;
610
+ padding: 2px 6px;
611
+ color: #d7eef8;
612
+ background: transparent;
613
+ border: 1px solid #3a4250;
614
+ cursor: pointer;
615
+ font-size: 11px;
616
+ }
617
+
618
+ .hud-item-inspect:hover,
619
+ .hud-item-inspect:focus-visible {
620
+ border-color: #9ad8ff;
621
+ }
622
+
602
623
  .hud-add-row {
603
624
  display: flex;
604
625
  gap: 6px;
@@ -66,8 +66,14 @@ export function MapView({
66
66
  const element = gl.domElement
67
67
  element.style.cursor = 'grab'
68
68
 
69
+ const isWalkClick = (event: PointerEvent | MouseEvent) =>
70
+ event.shiftKey || event.ctrlKey || event.metaKey
71
+
72
+ const isWalkButton = (event: PointerEvent) =>
73
+ event.button === 0 || (event.ctrlKey && event.button === 2)
74
+
69
75
  const onDown = (event: PointerEvent) => {
70
- if (event.button !== 0) return
76
+ if (!isWalkButton(event)) return
71
77
  drag.current = { x: event.clientX, y: event.clientY, moved: false, active: true }
72
78
  element.style.cursor = 'grabbing'
73
79
  }
@@ -93,20 +99,31 @@ export function MapView({
93
99
  return { raycaster, relationHit, fileHit }
94
100
  }
95
101
 
96
- const landAtPointer = (clientX: number, clientY: number) => {
102
+ const landAt = (x: number, z: number) => {
103
+ const lock = element.requestPointerLock()
104
+ if (lock && typeof lock.catch === 'function') {
105
+ void lock.catch(() => {})
106
+ }
107
+ onLand(x, z)
108
+ }
109
+
110
+ const landAtPointer = (clientX: number, clientY: number, allowIsland: boolean) => {
97
111
  const pick = pickAt(clientX, clientY)
98
112
  if (!pick) return false
99
113
  const { raycaster, relationHit, fileHit } = pick
100
- if (relationHit || fileHit) return false
101
114
 
102
115
  const hit = new THREE.Vector3()
103
116
  const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0)
104
117
  if (!raycaster.ray.intersectPlane(plane, hit)) return false
105
- const lock = element.requestPointerLock()
106
- if (lock && typeof lock.catch === 'function') {
107
- void lock.catch(() => {})
118
+
119
+ const folder = folderAt(hit.x, hit.z, layout)
120
+ if (allowIsland && folder) {
121
+ landAt(hit.x, hit.z)
122
+ return true
108
123
  }
109
- onLand(hit.x, hit.z)
124
+ if (relationHit || fileHit) return false
125
+
126
+ landAt(hit.x, hit.z)
110
127
  return true
111
128
  }
112
129
 
@@ -114,9 +131,11 @@ export function MapView({
114
131
  element.style.cursor = 'grab'
115
132
  const startedOnCanvas = drag.current.active
116
133
  drag.current.active = false
117
- if (!startedOnCanvas || event.button !== 0 || drag.current.moved) return
134
+ if (!startedOnCanvas || !isWalkButton(event) || drag.current.moved) return
118
135
 
119
- if (event.shiftKey && landAtPointer(event.clientX, event.clientY)) return
136
+ if (isWalkClick(event) && landAtPointer(event.clientX, event.clientY, true)) {
137
+ return
138
+ }
120
139
 
121
140
  const pick = pickAt(event.clientX, event.clientY)
122
141
  if (!pick) return
@@ -149,14 +168,20 @@ export function MapView({
149
168
  onSelectFolder(null)
150
169
  }
151
170
 
171
+ const onContextMenu = (event: MouseEvent) => {
172
+ if (event.ctrlKey) event.preventDefault()
173
+ }
174
+
152
175
  element.addEventListener('pointerdown', onDown)
153
176
  window.addEventListener('pointermove', onMove)
154
177
  window.addEventListener('pointerup', onUp)
178
+ element.addEventListener('contextmenu', onContextMenu)
155
179
  return () => {
156
180
  element.style.cursor = ''
157
181
  element.removeEventListener('pointerdown', onDown)
158
182
  window.removeEventListener('pointermove', onMove)
159
183
  window.removeEventListener('pointerup', onUp)
184
+ element.removeEventListener('contextmenu', onContextMenu)
160
185
  }
161
186
  }, [
162
187
  camera,
@@ -8,8 +8,8 @@ import { SelectionController } from './SelectionController'
8
8
  import { UserContextTracker } from './UserContextTracker'
9
9
  import { BlockPlacer } from './BlockPlacer'
10
10
  import { IslandPlacer } from './IslandPlacer'
11
- import { folderOfFile, filesImporting, worldBounds } from '../layout'
12
- import { EDITOR_GREY } from '../theme'
11
+ import { folderOfFile, filesImporting } from '../layout'
12
+ import { WORLD_VOID } from '../theme'
13
13
  import type { ChangeKind } from '../theme'
14
14
  import type {
15
15
  CodebaseGraph,
@@ -159,28 +159,16 @@ export function World({
159
159
  return null
160
160
  }
161
161
 
162
- const bounds = worldBounds(layout)
163
- const groundPad = 120
164
- const groundWidth = Math.max(bounds.width + groundPad * 2, 400)
165
- const groundDepth = Math.max(bounds.depth + groundPad * 2, 400)
166
-
167
162
  return (
168
163
  <>
169
- <color attach="background" args={[EDITOR_GREY.chrome]} />
170
- {!mapping && <fog attach="fog" args={[EDITOR_GREY.chrome, 38, 160]} />}
164
+ <color attach="background" args={[WORLD_VOID]} />
165
+ {!mapping && <fog attach="fog" args={[WORLD_VOID, 38, 160]} />}
171
166
  <hemisphereLight args={['#d7e2ee', '#2a3038', mapping ? 1.1 : 0.85]} />
172
167
  <directionalLight
173
168
  position={mapping ? [8, 60, 8] : [12, 22, 8]}
174
169
  intensity={mapping ? 1.35 : 0.55}
175
170
  />
176
171
  <ambientLight intensity={mapping ? 0.7 : 0.42} />
177
- <mesh
178
- rotation={[-Math.PI / 2, 0, 0]}
179
- position={[bounds.cx, -0.06, bounds.cz]}
180
- >
181
- <planeGeometry args={[groundWidth, groundDepth]} />
182
- <meshBasicMaterial color={EDITOR_GREY.chrome} />
183
- </mesh>
184
172
  <MapView
185
173
  layout={layout}
186
174
  enabled={mapping}
@@ -25,6 +25,9 @@ export const EDITOR_GREY = {
25
25
  surface: '#272c36',
26
26
  }
27
27
 
28
+ /** Infinite map/walk backdrop. Keep distinct from chrome HUD greys. */
29
+ export const WORLD_VOID = '#000000'
30
+
28
31
  export type ChangeKind = 'add' | 'edit' | 'remove'
29
32
 
30
33
  export const CHANGE_HIGHLIGHT: Record<
@@ -128,6 +128,7 @@ type HUDProps = {
128
128
  mode: ViewMode
129
129
  locked: boolean
130
130
  selectedId: string | null
131
+ selectedTick?: number
131
132
  selectedFolder?: string | null
132
133
  aimedRelation: AimedRelation | null
133
134
  currentFolder: string
@@ -162,6 +163,7 @@ type HUDProps = {
162
163
  ) => void
163
164
  onMapAddFile?: (folderPath: string) => void
164
165
  onMapAddFolder?: (folderPath: string) => void
166
+ onInspectFile?: (fileId: string) => void
165
167
  }
166
168
 
167
169
  export function HUD({
@@ -169,6 +171,7 @@ export function HUD({
169
171
  mode,
170
172
  locked,
171
173
  selectedId,
174
+ selectedTick = 0,
172
175
  selectedFolder = null,
173
176
  aimedRelation,
174
177
  currentFolder,
@@ -196,6 +199,7 @@ export function HUD({
196
199
  onRemoveBlueprintImport,
197
200
  onMapAddFile,
198
201
  onMapAddFolder,
202
+ onInspectFile,
199
203
  }: HUDProps) {
200
204
  const selected = graph.files.find((file) => file.id === selectedId)
201
205
  const selectedFolderNode = graph.folders.find(
@@ -299,6 +303,11 @@ export function HUD({
299
303
  const canRunNext =
300
304
  Boolean(nextStep) && (planReady || pending) && !working
301
305
  const canComplete = pending && lastStep
306
+ const canInspectFile = (fileId: string, userCreated = false) =>
307
+ Boolean(onInspectFile) &&
308
+ !fileId.startsWith('draft:') &&
309
+ !(previewing && (intent.deletes ?? []).includes(fileId)) &&
310
+ (!userCreated || (intent.creates ?? []).includes(fileId))
302
311
  const panelDone =
303
312
  intent.status === 'finished' ||
304
313
  intent.status === 'approved' ||
@@ -332,8 +341,8 @@ export function HUD({
332
341
  }, [selectedId, selectedFolder])
333
342
 
334
343
  useEffect(() => {
335
- if (sessionMode && selectedId) setInfoVisible(true)
336
- }, [selectedId, sessionMode])
344
+ if (selectedId) setInfoVisible(true)
345
+ }, [selectedId, selectedTick])
337
346
 
338
347
  useEffect(() => {
339
348
  if (selectedFolder) setInfoVisible(true)
@@ -402,7 +411,7 @@ export function HUD({
402
411
  , <kbd>Space</kbd> place a file, <kbd>B</kbd> place an island
403
412
  </>
404
413
  ) : null}
405
- , click a block for imports.
414
+ , click a block for info.
406
415
  </p>
407
416
  </div>
408
417
  </div>
@@ -757,6 +766,15 @@ export function HUD({
757
766
  <p>
758
767
  {selected.lines} lines · {selected.language}
759
768
  </p>
769
+ {canInspectFile(selected.id, selected.userCreated) && (
770
+ <button
771
+ className="hud-button hud-inspect"
772
+ type="button"
773
+ onClick={() => onInspectFile?.(selected.id)}
774
+ >
775
+ Inspect file
776
+ </button>
777
+ )}
760
778
  {selectedClasses.length > 0 && (
761
779
  <>
762
780
  <div className="hud-section-title">Classes</div>
@@ -955,7 +973,18 @@ export function HUD({
955
973
  ) : (
956
974
  <ul>
957
975
  {folderFiles.map((file) => (
958
- <li key={file.id}>{file.path || file.id}</li>
976
+ <li key={file.id}>
977
+ <span>{file.path || file.id}</span>
978
+ {canInspectFile(file.id, file.userCreated) && (
979
+ <button
980
+ className="hud-item-inspect"
981
+ type="button"
982
+ onClick={() => onInspectFile?.(file.id)}
983
+ >
984
+ Inspect
985
+ </button>
986
+ )}
987
+ </li>
959
988
  ))}
960
989
  </ul>
961
990
  )}
@@ -988,11 +1017,7 @@ export function HUD({
988
1017
  <>
989
1018
  <span>Scroll zoom</span>
990
1019
  <span>Drag pan</span>
991
- <span>
992
- {sessionMode
993
- ? 'Click a block for info'
994
- : 'Click a block for relations'}
995
- </span>
1020
+ <span>Click a block for info</span>
996
1021
  <span>Click an island for its files</span>
997
1022
  {creatingBlueprint && (
998
1023
  <>
@@ -1001,19 +1026,11 @@ export function HUD({
1001
1026
  </>
1002
1027
  )}
1003
1028
  <span>Click a line to fly there</span>
1004
- <span>Shift-click ground to walk</span>
1029
+ <span>Ctrl-click an island to walk</span>
1005
1030
  {selected?.userCreated && creatingBlueprint && (
1006
1031
  <span>Backspace delete</span>
1007
1032
  )}
1008
- <span>
1009
- {sessionMode
1010
- ? infoVisible
1011
- ? 'I hide info'
1012
- : 'Click a block for info'
1013
- : infoVisible
1014
- ? 'I hide info'
1015
- : 'I show info'}
1016
- </span>
1033
+ <span>{infoVisible ? 'I hide info' : 'I show info'}</span>
1017
1034
  {infoVisible && <span>↑↓ scroll info</span>}
1018
1035
  <span>
1019
1036
  {importedBy ? 'K show imports' : 'K show imported by'}
@@ -1034,21 +1051,9 @@ export function HUD({
1034
1051
  {selected?.userCreated && creatingBlueprint && (
1035
1052
  <span>Backspace delete</span>
1036
1053
  )}
1037
- <span>
1038
- {sessionMode
1039
- ? 'Click a block for info'
1040
- : 'Click block for relations'}
1041
- </span>
1054
+ <span>Click a block for info</span>
1042
1055
  <span>Aim a line to fly</span>
1043
- <span>
1044
- {sessionMode
1045
- ? infoVisible
1046
- ? 'I hide info'
1047
- : 'Click a block for info'
1048
- : infoVisible
1049
- ? 'I hide info'
1050
- : 'I show info'}
1051
- </span>
1056
+ <span>{infoVisible ? 'I hide info' : 'I show info'}</span>
1052
1057
  {infoVisible && <span>↑↓ scroll info</span>}
1053
1058
  <span>
1054
1059
  {importedBy ? 'K show imports' : 'K show imported by'}
@@ -7,9 +7,11 @@ import { fileURLToPath } from 'node:url'
7
7
  import type { IncomingMessage, ServerResponse } from 'node:http'
8
8
  import { emptyIntent } from './scripts/patch-lib.mjs'
9
9
  import { dataDir, targetRoot } from './scripts/target-config.mjs'
10
+ import { editorFileUri, openInEditor } from './scripts/open-editor.mjs'
10
11
  import {
11
12
  answerBlueprint,
12
13
  continueDiff,
14
+ inspectTargetFile,
13
15
  invokeStep,
14
16
  readActiveSession,
15
17
  requestReplan,
@@ -105,10 +107,46 @@ function jsonFilePlugin(): Plugin {
105
107
 
106
108
  next()
107
109
  })
110
+
111
+ server.middlewares.use('/api/inspect-file', (req, res, next) => {
112
+ if (req.method === 'POST') {
113
+ void inspectFile(req, res)
114
+ return
115
+ }
116
+ next()
117
+ })
108
118
  },
109
119
  }
110
120
  }
111
121
 
122
+ async function inspectFile(req: IncomingMessage, res: ServerResponse) {
123
+ try {
124
+ const body = JSON.parse(await readBody(req)) as {
125
+ sessionId?: string
126
+ diffId?: string
127
+ fileId?: string
128
+ }
129
+ const filePath = inspectTargetFile(dataDir, targetRoot, {
130
+ sessionId: body.sessionId,
131
+ diffId: body.diffId,
132
+ fileId: body.fileId,
133
+ })
134
+ if (!filePath) {
135
+ sendJson(res, 200, { path: null, uri: null, opened: false })
136
+ return
137
+ }
138
+ const opened = openInEditor(filePath)
139
+ sendJson(res, 200, {
140
+ path: filePath,
141
+ uri: editorFileUri(filePath),
142
+ opened,
143
+ })
144
+ } catch (error) {
145
+ const message = error instanceof Error ? error.message : 'invalid request'
146
+ sendJson(res, 400, { error: message })
147
+ }
148
+ }
149
+
112
150
  async function decideIntent(req: IncomingMessage, res: ServerResponse) {
113
151
  try {
114
152
  const body = JSON.parse(await readBody(req)) as {
@@ -209,7 +247,7 @@ async function decideIntent(req: IncomingMessage, res: ServerResponse) {
209
247
  addedImports: body.addedImports,
210
248
  })
211
249
  } else {
212
- stopSession(dataDir, body.sessionId, body.diffId)
250
+ stopSession(dataDir, body.sessionId, targetRoot)
213
251
  }
214
252
  const next = sessionIntent(dataDir, body.sessionId, knownFileIds())
215
253
  sendJson(res, 200, next ?? { ...emptyIntent })
package/bin/session.mjs CHANGED
@@ -201,7 +201,7 @@ export async function proposePatch(args) {
201
201
 
202
202
  if (clear) {
203
203
  if (!sessionId) usage('propose-patch', '--session <cursor-chat-id> --clear')
204
- store.stopSession(config.dataDir, sessionId)
204
+ store.stopSession(config.dataDir, sessionId, config.targetRoot)
205
205
  console.log(
206
206
  `Cleared session ${sessionId}; stored diffs and blueprint drafts were removed.`,
207
207
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jkwd/inbase",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "A first-person 3D map of a codebase, with a visual coding workflow for Cursor.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -29,6 +29,8 @@
29
29
  "apps/explorer/src/scene",
30
30
  "apps/explorer/src/ui",
31
31
  "apps/explorer/scripts/js-source.mjs",
32
+ "apps/explorer/scripts/open-editor.d.ts",
33
+ "apps/explorer/scripts/open-editor.mjs",
32
34
  "apps/explorer/scripts/patch-lib.d.ts",
33
35
  "apps/explorer/scripts/patch-lib.mjs",
34
36
  "apps/explorer/scripts/scan-target.mjs",