@jkwd/inbase 0.1.11 → 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.
@@ -14,6 +14,10 @@ import {
14
14
  } from './layout'
15
15
  import { World } from './scene/World'
16
16
  import { HUD } from './ui/HUD'
17
+ import {
18
+ MapContextMenu,
19
+ type MapContextMenuState,
20
+ } from './ui/MapContextMenu'
17
21
  import {
18
22
  fetchUserContext,
19
23
  persistFollowLook,
@@ -222,7 +226,9 @@ function Explorer({
222
226
  ],
223
227
  )
224
228
  const layout = useMemo(() => {
225
- const world = layoutWorld(previewGraph)
229
+ const world = layoutWorld(
230
+ withUserCreatedGraph(previewGraph, [], userIslands),
231
+ )
226
232
  if (previewing) markCreatedFolders(world, changeSet.createFolders ?? [])
227
233
  return withUserCreatedLayout(world, userBlocks, userIslands)
228
234
  }, [
@@ -294,6 +300,7 @@ function Explorer({
294
300
  const [selectedId, setSelectedId] = useState<string | null>(null)
295
301
  const [selectedTick, setSelectedTick] = useState(0)
296
302
  const [selectedFolder, setSelectedFolder] = useState<string | null>(null)
303
+ const [mapMenu, setMapMenu] = useState<MapContextMenuState | null>(null)
297
304
  const [aimedRelation, setAimedRelation] = useState<AimedRelation | null>(null)
298
305
  const [aimedFileId, setAimedFileId] = useState<string | null>(null)
299
306
  const [inspectTick, setInspectTick] = useState(0)
@@ -725,12 +732,14 @@ function Explorer({
725
732
  const placeIsland = useCallback(
726
733
  (parent: string) => {
727
734
  if (!canPlace) return
735
+ let placedId: string | null = null
728
736
  setUserIslands((current) => {
729
737
  if (current.some((island) => island.naming)) return current
738
+ placedId = `draft:${Date.now()}`
730
739
  return [
731
740
  ...current,
732
741
  {
733
- id: `draft:${Date.now()}`,
742
+ id: placedId,
734
743
  name: '',
735
744
  path: '',
736
745
  parent,
@@ -738,6 +747,10 @@ function Explorer({
738
747
  },
739
748
  ]
740
749
  })
750
+ if (placedId) {
751
+ setSelectedFolder(placedId)
752
+ setSelectedId(null)
753
+ }
741
754
  document.exitPointerLock()
742
755
  },
743
756
  [canPlace],
@@ -772,14 +785,23 @@ function Explorer({
772
785
  )
773
786
  setUserIslands(next)
774
787
  persistBlueprint(userBlocks, next)
788
+ setSelectedFolder(resolved.path)
789
+ setSelectedId(null)
775
790
  return true
776
791
  },
777
792
  [graph.folders, persistBlueprint, userBlocks, userIslands],
778
793
  )
779
794
 
780
- const cancelIslandName = useCallback((id: string) => {
781
- setUserIslands((current) => current.filter((island) => island.id !== id))
782
- }, [])
795
+ const cancelIslandName = useCallback(
796
+ (id: string) => {
797
+ const draft = userIslands.find((island) => island.id === id)
798
+ setUserIslands((current) => current.filter((island) => island.id !== id))
799
+ setSelectedFolder((selected) =>
800
+ selected === id ? draft?.parent ?? null : selected,
801
+ )
802
+ },
803
+ [userIslands],
804
+ )
783
805
 
784
806
  const deleteSelectedCreatedBlock = useCallback(() => {
785
807
  if (!canPlace || !selectedId) return false
@@ -1019,6 +1041,10 @@ function Explorer({
1019
1041
  return () => window.removeEventListener('keydown', onKey)
1020
1042
  }, [cancelBlockName, cancelIslandName, namingId, namingIslandId])
1021
1043
 
1044
+ useEffect(() => {
1045
+ if (mode !== 'map' || naming) setMapMenu(null)
1046
+ }, [mode, naming])
1047
+
1022
1048
  useEffect(() => {
1023
1049
  const onKey = (event: KeyboardEvent) => {
1024
1050
  if (event.repeat || event.code !== 'Backspace') return
@@ -1273,6 +1299,7 @@ function Explorer({
1273
1299
  namingIslandId={namingIslandId}
1274
1300
  onPlaceBlock={canPlace ? placeBlock : undefined}
1275
1301
  onPlaceIsland={canPlace ? placeIsland : undefined}
1302
+ onBlueprintMenu={canPlace ? setMapMenu : undefined}
1276
1303
  onCommitName={commitBlockName}
1277
1304
  onCancelName={cancelBlockName}
1278
1305
  userCreatedBlocks={userBlocks}
@@ -1346,6 +1373,12 @@ function Explorer({
1346
1373
  createdIds={plannedCreates}
1347
1374
  deletedIds={deletedIds}
1348
1375
  />
1376
+ <MapContextMenu
1377
+ menu={mapMenu}
1378
+ onAddFile={placeBlockOnFolder}
1379
+ onAddFolder={placeIslandOnFolder}
1380
+ onClose={() => setMapMenu(null)}
1381
+ />
1349
1382
  </>
1350
1383
  )
1351
1384
  }
@@ -1112,6 +1112,35 @@ button {
1112
1112
  margin-top: 14px;
1113
1113
  }
1114
1114
 
1115
+ .map-context-menu {
1116
+ position: fixed;
1117
+ z-index: 40;
1118
+ display: flex;
1119
+ min-width: 168px;
1120
+ flex-direction: column;
1121
+ gap: 2px;
1122
+ padding: 4px;
1123
+ color: #e7ebf2;
1124
+ background: rgba(25, 28, 34, 0.96);
1125
+ border: 1px solid var(--vc-border);
1126
+ pointer-events: auto;
1127
+ }
1128
+
1129
+ .map-context-menu button {
1130
+ cursor: pointer;
1131
+ padding: 8px 10px;
1132
+ color: inherit;
1133
+ text-align: left;
1134
+ background: transparent;
1135
+ border: 0;
1136
+ }
1137
+
1138
+ .map-context-menu button:hover,
1139
+ .map-context-menu button:focus-visible {
1140
+ background: var(--vc-surface);
1141
+ outline: none;
1142
+ }
1143
+
1115
1144
  .hud-decide {
1116
1145
  display: flex;
1117
1146
  flex-wrap: wrap;
@@ -6,6 +6,12 @@ import { folderAt, worldBounds } from '../layout'
6
6
  import type { ChangeKind } from '../theme'
7
7
  import type { WorldLayout } from '../types'
8
8
 
9
+ export type MapBlueprintMenu = {
10
+ x: number
11
+ y: number
12
+ folder: string
13
+ }
14
+
9
15
  type MapViewProps = {
10
16
  layout: WorldLayout
11
17
  enabled: boolean
@@ -16,6 +22,7 @@ type MapViewProps = {
16
22
  onSelect: (fileId: string | null) => void
17
23
  onSelectFolder: (folderPath: string | null) => void
18
24
  onTravelTo: (fromId: string, toId: string) => void
25
+ onBlueprintMenu?: (menu: MapBlueprintMenu) => void
19
26
  }
20
27
 
21
28
  export function MapView({
@@ -28,6 +35,7 @@ export function MapView({
28
35
  onSelect,
29
36
  onSelectFolder,
30
37
  onTravelTo,
38
+ onBlueprintMenu,
31
39
  }: MapViewProps) {
32
40
  const size = useThree((state) => state.size)
33
41
  const { camera, gl, invalidate, scene } = useThree()
@@ -172,7 +180,28 @@ export function MapView({
172
180
  }
173
181
 
174
182
  const onContextMenu = (event: MouseEvent) => {
175
- if (event.ctrlKey) event.preventDefault()
183
+ if (event.ctrlKey) {
184
+ event.preventDefault()
185
+ return
186
+ }
187
+ if (!onBlueprintMenu) return
188
+ event.preventDefault()
189
+ const pick = pickAt(event.clientX, event.clientY)
190
+ if (!pick) return
191
+ const hit = new THREE.Vector3()
192
+ const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0)
193
+ if (!pick.raycaster.ray.intersectPlane(plane, hit)) return
194
+ const folder =
195
+ folderAt(hit.x, hit.z, layout) ??
196
+ (selectedFolder ? layout.folders[selectedFolder] : undefined) ??
197
+ layout.folders['.']
198
+ if (!folder) return
199
+ onSelectFolder(folder.path)
200
+ onBlueprintMenu({
201
+ x: event.clientX,
202
+ y: event.clientY,
203
+ folder: folder.path,
204
+ })
176
205
  }
177
206
 
178
207
  const ground = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0)
@@ -250,10 +279,12 @@ export function MapView({
250
279
  invalidate,
251
280
  layout,
252
281
  onLand,
282
+ onBlueprintMenu,
253
283
  onSelect,
254
284
  onSelectFolder,
255
285
  onTravelTo,
256
286
  scene,
287
+ selectedFolder,
257
288
  ])
258
289
 
259
290
  return (
@@ -3,7 +3,7 @@ import { FileBlock } from './FileBlock'
3
3
  import { Bridge } from './Bridge'
4
4
  import { RelationLines } from './RelationLines'
5
5
  import { Player } from './Player'
6
- import { MapView } from './MapView'
6
+ import { MapView, type MapBlueprintMenu } from './MapView'
7
7
  import { SelectionController } from './SelectionController'
8
8
  import { UserContextTracker } from './UserContextTracker'
9
9
  import { BlockPlacer } from './BlockPlacer'
@@ -60,6 +60,7 @@ type WorldProps = {
60
60
  namingId?: string | null
61
61
  onPlaceBlock?: (spot: { x: number; z: number; folder: string }) => void
62
62
  onPlaceIsland?: (parent: string) => void
63
+ onBlueprintMenu?: (menu: MapBlueprintMenu) => void
63
64
  onCommitName?: (id: string, name: string) => boolean
64
65
  onCancelName?: (id: string) => void
65
66
  userCreatedBlocks?: UserCreatedBlock[]
@@ -100,6 +101,7 @@ export function World({
100
101
  namingIslandId = null,
101
102
  onPlaceBlock,
102
103
  onPlaceIsland,
104
+ onBlueprintMenu,
103
105
  onCommitName,
104
106
  onCancelName,
105
107
  userCreatedBlocks = [],
@@ -171,6 +173,9 @@ export function World({
171
173
  onSelect={onSelect}
172
174
  onSelectFolder={onSelectFolder}
173
175
  onTravelTo={onTravelTo}
176
+ onBlueprintMenu={
177
+ mapping && !placing && onBlueprintMenu ? onBlueprintMenu : undefined
178
+ }
174
179
  />
175
180
 
176
181
  {Object.values(viewLayout.folders).map((folder) => (
@@ -334,7 +334,7 @@ function HandshakeSetup({
334
334
  </h2>
335
335
  <p>
336
336
  Press <kbd>Space</kbd> for a file and <kbd>B</kbd> for an island.
337
- Optional.
337
+ On the map, right-click to add a file or folder. Optional.
338
338
  </p>
339
339
  </section>
340
340
  <section className="hud-setup-section">
@@ -373,7 +373,7 @@ function sessionLiveStatus(intent: AgentIntent) {
373
373
  return { text: 'LLM wait timed out', busy: false }
374
374
  }
375
375
  if (intent.awaitingAttach) {
376
- return { text: 'Waiting for /inbase in Cursor', busy: true }
376
+ return { text: 'Waiting for /inbase in Cursor', busy: false }
377
377
  }
378
378
  if (kind === 'execute') {
379
379
  return { text: `LLM received ${detail}`, busy: true }
@@ -606,11 +606,13 @@ function SessionPanel({
606
606
  />
607
607
  {!minimized && (
608
608
  <>
609
- <LiveStatus
610
- intent={intent}
611
- showStop={showConnectedProgress || working}
612
- onStop={() => act('stop')}
613
- />
609
+ {!intent.awaitingAttach && (
610
+ <LiveStatus
611
+ intent={intent}
612
+ showStop={showConnectedProgress || working}
613
+ onStop={() => act('stop')}
614
+ />
615
+ )}
614
616
  <label className="hud-mode-switch">
615
617
  <span>Step by step</span>
616
618
  <button
@@ -700,8 +702,9 @@ function SessionPanel({
700
702
  <>
701
703
  {canPlace && !intent.working && (
702
704
  <p>
703
- <kbd>Space</kbd> places a file, <kbd>B</kbd> an island for
704
- this session.
705
+ On the map, right-click to add a file or folder.{' '}
706
+ <kbd>Space</kbd> places a file, <kbd>B</kbd> an island while
707
+ walking.
705
708
  </p>
706
709
  )}
707
710
  {intent.steps?.length > 0 && (
@@ -1251,8 +1254,8 @@ function explorerInstructions({
1251
1254
  { id: 'select-island', keys: ['Click'], label: 'Select an island' },
1252
1255
  {
1253
1256
  id: 'add-file-folder',
1254
- keys: [],
1255
- label: 'Add file / Add folder in panel',
1257
+ keys: ['Right-click'],
1258
+ label: 'Add file or folder',
1256
1259
  },
1257
1260
  ]
1258
1261
  : []),
@@ -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
+ }
@@ -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
 
package/bin/session.mjs CHANGED
@@ -30,15 +30,31 @@ function printAck(kind, detail) {
30
30
  console.log(`VISUAL_CODER_ACK ${kind}: ${detail}`)
31
31
  }
32
32
 
33
- function createManifestGate(manifestPath) {
33
+ export function createManifestGate(manifestPath, watch = fs.watch) {
34
34
  const dir = path.dirname(manifestPath)
35
35
  let wake = () => {}
36
36
  let watcher = null
37
+
38
+ const dropWatcher = () => {
39
+ try {
40
+ watcher?.close()
41
+ } catch {
42
+ // Already closed after an error, or never opened.
43
+ }
44
+ watcher = null
45
+ }
46
+
37
47
  try {
38
- watcher = fs.watch(dir, () => wake())
48
+ watcher = watch(dir, () => wake())
49
+ watcher?.on?.('error', () => {
50
+ // EMFILE and sandbox limits must not crash wait-for-approval. Poll instead.
51
+ dropWatcher()
52
+ wake()
53
+ })
39
54
  } catch {
40
- watcher = null
55
+ dropWatcher()
41
56
  }
57
+
42
58
  return {
43
59
  wait(ms) {
44
60
  return new Promise((resolve) => {
@@ -50,7 +66,7 @@ function createManifestGate(manifestPath) {
50
66
  })
51
67
  },
52
68
  close() {
53
- watcher?.close()
69
+ dropWatcher()
54
70
  },
55
71
  }
56
72
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jkwd/inbase",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "A first-person 3D map of a codebase, with a visual coding workflow for Cursor.",
5
5
  "license": "MIT",
6
6
  "author": "Joris Kuijper",
@@ -144,11 +144,13 @@ npx inbase report-plan \
144
144
  npx inbase wait-for-approval --session "<session-id>"
145
145
  ```
146
146
 
147
- Do not edit project files until this prints `VISUAL_CODER_ACK execute` /
148
- `VISUAL_CODER_EXECUTE`. If Step by step is off, this returns immediately for
149
- each remaining step. First reply in chat acknowledging the ack, then re-read
150
- this session's `blueprint.json`; the user can place files and islands on any
151
- step.
147
+ Describe this tool call as `inbase wait-for-approval`, not "waiting for the
148
+ user to invoke". The command keeps running until they invoke; when it
149
+ returns, they already did. Do not edit project files until this prints
150
+ `VISUAL_CODER_ACK execute` / `VISUAL_CODER_EXECUTE`. If Step by step is off,
151
+ this returns immediately for each remaining step. First reply in chat
152
+ acknowledging the ack, then re-read this session's `blueprint.json`; the
153
+ user can place files and islands on any step.
152
154
  9. Implement only the invoked step by editing the live project files (Write,
153
155
  StrReplace, Delete). Paths are the same ids as `codebase.json`. Then record
154
156
  the step — Inbase diffs the working tree against the snapshot taken at