Bring the Ark0N/codeman-gesture-control repo in-tree as the codeman-gesture-control workspace package so the hand-tracking overlay can be developed in the Codeman repo. New npm run build:gesture bundles src/codeman/entry.ts into the served gesture-codeman.js; scripts/build.mjs reruns it on every production build. Source formatted to Codeman's prettier style. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
13 KiB
gesture-proto
A Jarvis-style hand-tracking input layer for the Codeman dashboard. Your webcam sees your hands; you pinch-drag session tabs between columns. Runs entirely in-browser — the camera feed never leaves the machine.
This build is pinch-drag only. The discrete gesture commands (halt-all / approve / new-session) were built in Phase 4 but are intentionally not wired up — an open palm while reaching to pinch kept charging the hold-to-halt. The
GestureControllercore still emitscommand/haltProgressfor any future consumer; the demo just no longer listens.
This is a standalone prototype (own folder, fake tabs) so the input feel can
be validated on real hardware before integrating with Codeman. The canonical
spec is docs/BUILD_PLAN.md — read it before
continuing the build.
Status: Phase 0–4 complete
| Phase | What | State |
|---|---|---|
| 0 | Vite+TS scaffold, mirrored webcam preview, start button | ✅ done |
| 1 | MediaPipe GestureRecognizer (VIDEO mode, CDN model+wasm), rAF loop, debug skeleton overlay, HUD |
✅ done |
| — | Checkpoint: ≥25fps on real camera/lighting | ✅ 60fps (iPhone 17 Pro / Continuity Camera) |
| 2 | One-Euro–filtered cursor + pinch detection (hysteresis) | ✅ done |
| 3 | Drag fake tabs across 3 columns (state machine) — go/no-go | ✅ done (two-handed) |
| 4 | Discrete gesture→command bus (👍 approve · ✌️ new-session · ✋-hold halt-all) + toast | ✅ built, ⛔ unwired in the demo (pinch-only) |
| 5 | Integrate gesture/ into real Codeman (src/codeman/entry.ts) |
✅ working at the desk — see Codeman integration |
Also done beyond the original plan: two-hand tracking (drag two tabs at once) and a live camera picker (front-facing iPhone 17 Pro by default; the superwide / Desk View camera now works too — see the camera note below).
Also: fullscreen mode (toggle button — the board fills the display, so grab targets get big).
Phase 5 is live in the real Codeman dashboard. The Codeman-side detach +
instance isolation + base gesture overlay are committed on Ark0N/Codeman
branch beta/session-detach (open as PR #103) — including this session's
gesture improvements (direct-detach, Run/Run Shell taps, self-hosted MediaPipe,
cache-bust), ported onto the PR in commit eea84db (CI green). See
Codeman integration below and the hand-off brief
../docs/CODEMAN_DETACH_BRIEF.md.
Multi-monitor mode — built & validated at the desk (2026-06-08). Approach
A+C (see ../docs/MULTIMONITOR_DESIGN.md):
(A) gesture "detach" now pops a session into a re-grabbable in-page floating
panel (an <iframe src="/session/:id">, not an OS window — so the hand keeps
control); (C) scripts/span-codeman.sh runs Codeman in a single window spanned
across both monitors (Brave-first; needs macOS "Displays have separate Spaces"
OFF + re-login), launchable one-click from a new multi-monitor button in
Codeman's header. Confirmed: a panel drags across the monitor seam. Still pending:
getScreenDetails screen-snapping (C-snap) and the re-dock gesture.
Run it
cd gesture-proto
npm install
npm run dev
Open the URL Vite prints (http://localhost:5173). getUserMedia requires a
secure context — localhost qualifies, so the dev server is fine. Click
Start camera, allow the camera, hold a hand up. You should see each hand's
21-point skeleton, a cursor ring per hand (cyan = left, violet = right, green
while pinching), and a live HUD.
Choosing a camera: after Start, the dropdown lists every video device. On a Mac with an iPhone nearby (Continuity Camera) you'll typically see the built-in FaceTime cam, the iPhone Camera (main/wide lens), and a Desk View Camera — the latter is driven by the iPhone's ultra-wide lens aimed down at the desk (the overhead angle). Switching is live; no restart needed. The browser can't select the ultra-wide lens directly, so Desk View is how you reach it. The Desk View / superwide camera now works with no issues and tracking is confirmed on it — earlier it was Safari-only and rendered stretched; that's resolved.
Using it: point at a tab (it highlights), pinch thumb+index to grab, move to another Screen column, release to drop. Both hands work at once. ⛶ Fullscreen makes the board fill the display (Esc exits). No open-hand gesture commands in this build — it's pinch-drag only (see the note up top).
Other scripts: npm run build (tsc + vite build), npm run preview.
Developing on the Mac mini, running on the MacBook
The prototype is run/tested on the MacBook (better for sitting at the desk with the camera). To sync:
git pull # on the MacBook
cd gesture-proto && npm install && npm run dev
No machine-specific state is committed (node_modules/, dist/, and
.claude/settings.local.json are gitignored).
Framerate note
The HUD fps turns green at ≥ 25, amber below. Confirmed 60fps on the iPhone 17 Pro (Continuity Camera). If it's ever low, improve even, frontal lighting on the hand zone first — that matters more than the sensor.
Codeman integration
Phase 5 ships a separate consumer, src/codeman/entry.ts (the demo +
main.ts are untouched — they stay as a desk-testing harness). It imports the
same unchanged src/gesture/ core and binds its events to the real Codeman
dashboard. Build it standalone (MediaPipe inlined) with:
npm run build:codeman # esbuild → dist-codeman/gesture-codeman.js
Codeman serves that bundle at /gesture/gesture-codeman.js and injects it into
the dashboard only when started with CODEMAN_GESTURE=1 (which also widens
its CSP for WebAssembly). Deploy = copy the bundle into Codeman's
src/web/public/gesture/ and reload (static is served from disk).
Gestures (all off one pinch, routed by what's under your fingertips):
- Fullscreen camera by default — mirrored, dimmed, full-viewport so you see your hands over the real tabs; the ⛶ button toggles a corner preview.
- Grab → in-page floating panel (pivoted 2026-06-08 — replaced the old
OS-window detach) — pinch a session tab, a ghost of it follows your hand, pull
it out of the strip (>70px) and release → the session pops into a re-grabbable
in-page
.cg-floatpanel (an<iframe src="/session/:id">, 640×420) at the drop point. Pinch the panel again to move it anywhere. It stays inside the camera-owning page, so the hand keeps control (the oldwindow.app.detachSessionwindow.openwas a one-way trip). A small twitch-and-release cancels. - Run / Run Shell — pinch over the Run (
#runBtn) or Run Shell (.btn-shell) toolbar button and release in place to fire it; drifting too far first cancels the tap. The button list isCLICK_SELECTORinentry.ts.
Self-hosted MediaPipe. The Codeman consumer loads the wasm runtime + the
gesture_recognizer.task model same-origin from /gesture/ (via
wasmBase/modelUrl options), not the CDN — a browser content/ad blocker can
otherwise block the CDN and startup fails with failed: {"isTrusted":true}.
Multi-monitor button. Codeman's header has a multi-monitor button (it
replaced the notification bell) → POST /api/system/span-displays → spawns
scripts/span-codeman.sh, opening a fresh browser --app window spanned across
all displays so floating panels can cross the monitor seam (PR #103 95b0035).
Caveats: Codeman serves static with a 1-year immutable cache, so
server.ts cacheBustAssets() appends ?v=<mtime> to every same-origin
.js/.css (and the gesture bundle), re-stat'd per render — without it an edited
module stays cached until a hard refresh (PR #103 b5ea711). The old OS-window
detach verb (kept only as a deliberate, non-default action) does window.open,
which a pinch can get popup-blocked — allow popups once if you ever wire it back.
Layout
gesture-proto/
├── index.html # demo page: video, tab board, HUD, controls
├── docs/
│ └── BUILD_PLAN.md # canonical spec: goals, algorithms, phases, tuning
├── src/
│ ├── main.ts # wires GestureController -> demo UI (board, HUD, camera, fullscreen)
│ ├── gesture/
│ │ ├── GestureController.ts # camera + recognizer + loop + per-hand state + event bus
│ │ ├── OneEuroFilter.ts # per-axis cursor smoothing
│ │ ├── pinch.ts # pinch distance + hysteresis detector
│ │ ├── commands.ts # debounced gesture→command bus (Phase 4)
│ │ ├── landmarks.ts # landmark indices, connections, helpers
│ │ └── types.ts # event payload types + config (full API surface)
│ ├── demo/
│ │ ├── overlay.ts # draws the hand skeleton + per-hand cursors
│ │ └── tabs.ts # the 3-column board: hit-testing + drag mechanics
│ └── codeman/
│ └── entry.ts # Phase 5 Codeman consumer (real tabs + Run/Run Shell);
│ # esbuild-bundled by `npm run build:codeman`
GestureController API
Transport-agnostic: it owns the camera, recognizer, per-hand smoothing, pinch
state machine, and gesture→command bus, and emits coordinate-only events. It
knows nothing about tabs or the DOM — hit-testing lives in the consumer (the
demo's tabs.ts, later Codeman). Integration (Phase 5) is just subscribing and
calling Codeman's existing tab-move / command functions.
const gc = new GestureController({
video: videoEl,
surface: stageEl, // normalized coords map against this element's rect
numHands: 2,
pinchOn: 0.35, pinchOff: 0.5, // pinch hysteresis (fractions of hand size)
minCutoff: 1.0, beta: 0.01, // One-Euro cursor smoothing
palmHoldMs: 1000, // Open_Palm hold before halt-all fires
deviceId: "", // specific camera; "" = default user-facing
});
// Drag events — surface pixels (X already mirrored), with a per-hand id.
gc.on("grab", ({ hand, x, y }) => {}); // pinch closed → start drag
gc.on("drag", ({ hand, x, y }) => {}); // moving while pinched (per frame)
gc.on("drop", ({ hand, x, y }) => {}); // released (or hand vanished mid-pinch)
// Discrete commands: "halt-all" | "approve" | "new-session".
// Still emitted by the controller, but THIS build's demo does not subscribe
// (pinch-only). Wire these up in Codeman (Phase 5) or re-enable in the demo.
gc.on("command", ({ name }) => {});
// Per-frame snapshot for HUD / hover highlighting / debug overlay.
// `haltProgress` (0–1 Open_Palm charge) is also still emitted but unused here.
gc.on("status", ({ fps, hands, haltProgress }) => {});
// hands: { handedness, cursor:{x,y}/*normalized*/, pinchDist, pinching, gesture }[]
gc.on("results", ({ result, timestampMs }) => {}); // raw recognizer result
await gc.start(); // requests camera, loads model
await gc.useCamera(deviceId); // switch camera live
await gc.listCameras(); // enumerate video inputs
gc.stop();
All events are live. Hover highlighting is derived from the status snapshot
(not a dedicated event), since only the consumer can hit-test against its tabs.
Full types in src/gesture/types.ts.
Tuning defaults (start here, then adjust by feel)
- One-Euro filter:
minCutoff ≈ 1.0,beta ≈ 0.01. Raisebetaif drag lags during fast moves; lowerminCutoffif it jitters when still. - Pinch:
PINCH_ON 0.35,PINCH_OFF 0.5(fractions of hand-size reference distance, wrist→middle-MCP). Two thresholds = hysteresis = no flicker. - Confidence: detection/tracking ≈ 0.6. Lower if quick gestures get missed, raise if you get false hands.
- Camera: target 30–60fps, even frontal lighting on the hand zone.
Design notes
- Standalone first. Fake tabs let us validate feel before touching Codeman.
- Main thread first. Inference runs on the main thread (60fps, no stutter); a Web Worker stays an optional optimization only if the UI ever stutters.
- Two hands (
numHands: 2). Each hand keeps its own cursor + pinch state, keyed by handedness so filters don't swap when MediaPipe reorders the hands. - Camera angle: front-facing default; superwide now usable too — iPhone 17 Pro main lens via Continuity Camera, pointing / pinch-to-grab grammar. The superwide / Desk View (overhead, ultra-wide) camera now works with no issues and tracking is confirmed on it; the earlier Safari-only / stretched blocker is resolved. The in-app picker switches cameras live.
- Transport-agnostic controller. It emits coordinate-only
grab/drag/drop+command; the consumer hit-tests. So Phase 5 only swaps the demo'stabs.tsfor Codeman wiring — the controller is untouched.