Auto-select the iTerm2 pane you're looking at, via webcam gaze tracking.
Project description
gaze-pane
Auto-select the iTerm2 pane you're looking at. Webcam tracks your eyes, a ridge-regularized affine model maps gaze to screen position, and the iTerm2 Python API focuses whichever pane the gaze lands in.
Requirements
- macOS (tested on Apple Silicon — M4 Max)
- Python 3.10, 3.11, or 3.12 (MediaPipe wheels don't ship for 3.13+ yet)
- iTerm2
- A working webcam
Install
git clone https://github.com/noahjohnson0/gaze-pane.git
cd gaze-pane
./setup.sh
setup.sh creates a .venv, installs requirements, runs pip install -e .
so the gaze-pane console script lands on the venv's PATH, and downloads the
~4 MB MediaPipe face-landmarker model.
After that, the entrypoint lives at ./.venv/bin/gaze-pane. Either activate
the venv (source .venv/bin/activate) or call it by full path. Symlink to
~/bin/gaze-pane if you want it bare.
iTerm2 setup
gaze-pane talks to iTerm2 over its built-in WebSocket Python API. Enable it once:
- Open iTerm2's Settings (Cmd+,) → General → Magic.
- Check Enable Python API.
- Set the dropdown below it to Allow all apps. The "Require Automation permission" option will reject the script with HTTP 401 unless you have already granted the Python binary Automation permission for iTerm2 — and macOS only surfaces that permission prompt after a successful connection, so it's a chicken-and-egg. "Allow all apps" is simpler.
The first time you run gaze-pane run, iTerm2 will pop a dialog asking to
authorize the script. Click Always allow.
If you ever see HTTP 401 or could not connect to iTerm2's Python API in
the runner output, the API toggle is off or the dropdown is on
Automation-permission mode.
Camera setup
On first calibration, macOS will prompt for Camera access for whichever terminal you launch gaze-pane from (iTerm2 itself, Terminal, your IDE's integrated terminal). Click Allow. Note: macOS can't grant the permission mid-process — if the prompt comes up after the run has already failed, retry the command.
Continuity Camera gotcha: a nearby iPhone registered as a Continuity
Camera usually shows up as camera 0 and the built-in MacBook camera as
camera 1. Pass --camera 1 for the built-in. To probe what's available:
.venv/bin/python scripts/list_cameras.py
That prints per-index resolution, mean brightness, and how many of 10 sample frames had a face detected.
Calibrate
gaze-pane calibrate --camera 1 # default 4x4 grid (16 points)
gaze-pane calibrate --camera 1 --grid 5 # 5x5 grid (25 points)
gaze-pane calibrate --camera 1 --skip-validate # skip the validation phase
The calibration flow has three phases:
-
Grid. For each grid dot in a fullscreen black canvas: look at the dot, press SPACE. The system averages 12 frames of MediaPipe features. ESC aborts.
-
Initial fit. Ridge-regularized affine (λ=0.1) on z-normalized features. Prints initial RMS residual.
-
Validation phase (4 s passive capture at 5 test points: 4 corners + center). Per point:
- 3 s countdown with a pulsing white target + face-detected indicator.
- 4 s capture phase. Target turns orange. No prediction overlay, so your eyes aren't pulled to the green dot.
- 1 s "result" frame: target plus the green prediction dot plus an error line.
After all 5 you get a summary screen with per-point errors. Choose:
- SPACE = use the refined fit (initial samples + validation samples)
- ENTER = keep the initial fit, ignore the validation samples
- ESC = abort, don't save anything
Active calibration is saved to ~/.config/gaze-pane/calibration.json. The
previous one (if any) is archived to
~/.config/gaze-pane/history/<finished_at>.json first, so you can revert by
cping it back. Metadata (start/end timestamps, duration, grid size, samples
per point, validation decision) is in the JSON's metadata block.
Run
gaze-pane run --camera 1 # plain
gaze-pane run --camera 1 --overlay # translucent gaze dot, always on top
gaze-pane run --camera 1 --overlay --voice # plus hands-free voice command entry
gaze-pane run --camera 1 --overlay --debug # plus per-tick gaze + head dump
Flags:
| flag | default | description |
|---|---|---|
--camera |
0 | cv2 camera index |
--dwell-ms |
350 | ms of stable gaze in a new pane before switching |
--alpha |
0.45 | EMA smoothing on the gaze feature vector, lower = smoother |
--hz |
20 | control loop rate; samples come in at ~30 Hz from the camera |
--pane-refresh |
1.0 | seconds between re-querying iTerm pane bounds |
--chrome-top |
52 | points deducted from top of the iTerm window for title+tab bar; auto-reduced by 28 in fullscreen |
--status-every |
2.0 | seconds between "looking at: " log lines |
--overlay |
off | translucent always-on-top dot; green inside a pane, red outside |
--overlay-fps |
30 | overlay redraw rate |
--voice |
off | enable voice command entry (see below) |
--wake-phrase |
"hey claude" |
phrase that begins a voice command |
--end-phrase |
"send it" |
phrase that submits the voice command + Enter |
--voice-model |
mlx-community/whisper-small-mlx |
MLX Whisper model id |
--debug |
off | print gaze + head features every ~250 ms |
Ctrl+C cleanly stops the runner (including with --overlay, via a SIGINT
handler that asks AppKit to stop).
Voice control
--voice adds a continuous-listening pipeline: mic → silero-vad → MLX
Whisper → wake/end-phrase match → Session.async_send_text(cmd + "\n")
into the currently-focused pane.
Usage is a wake-word sandwich:
"hey claude git status send it"
Whatever you say between the wake and end phrases gets typed into the pane gaze-pane currently thinks is focused, followed by Enter. Say nothing matching the pattern and nothing is sent. Say the wake without the end and nothing is sent.
Things to know:
- macOS will ask for Microphone permission for whatever terminal you launch from, the same way it asks for Camera permission. Allow it.
- The Whisper model (~500 MB for
small) is downloaded on first use to~/.cache/huggingface/hub/. First utterance after that has ~200-300 ms of transcription latency on M-series; subsequent are warmer. - Whisper is great at English prose, less great at shell syntax. Saying
"list files" gets typed verbatim, not translated to
ls. Phrase commands the way you'd actually type them: "ls dash l a", "cd repos slash gaze pane", "git pull". - The wake phrase is substring-matched in the transcript, so misrecognitions
like "hey clod" or "hey claud" will not trigger. If you have an accent or
Whisper consistently mishears yours, override
--wake-phrasewith a phrase that transcribes reliably. - The recipient pane is whatever gaze-pane considers focused at the moment the command is dispatched, not at the moment you started speaking. Look at the pane before you say the end phrase.
- Capture. OpenCV pulls frames from the webcam in a background thread at ~30 fps.
- Landmarks. MediaPipe's
FaceLandmarkerTasks API returns 478 face landmarks (iris included) plus a 4×4 head transformation matrix per frame. - Feature vector (9-dim):
- left/right iris offset (x, y), eye-width normalized — 4 dims
- head yaw, pitch from the transformation matrix — 2 dims
- left/right eye openness,
(lower_lid_y − upper_lid_y) / eye_width— 2 dims - inter-iris distance (proxy for camera distance) — 1 dim
- Map. Features are z-normalized per axis; a ridge-regularized affine
(
λ=0.1) maps them to top-left-normalized screen coords. Ridge prevents near-collinear features (left vs right iris move together) from blowing up the weights and extrapolating to ±5 at inference time. - Validation/refit (optional, by default on). 5 test points captured passively; refit with original + new samples if you press SPACE on the summary screen.
- Pane hit-test. iTerm2's
Sessiondoesn't expose a pixel frame, so we walktab.root(aSplittertree), weight each subtree by itsgrid_sizein cells, and recursively assign each pane a proportional rect inside the window's content area (window frame minus title/tab-bar chrome). Then we check which rect contains the gaze point. - Activate. After
--dwell-msof stable gaze in a non-active pane, callsession.async_activate().
The --overlay flag puts AppKit on the main thread with a transparent
borderless NSWindow at NSScreenSaverWindowLevel + 1, click-through, set
to join all spaces. The asyncio iTerm2 loop moves to a daemon background
thread; the two communicate through a small lock-protected dict.
Known limitations
- macOS only. AppKit overlay + iTerm2 API both pin this to macOS.
- Single monitor. Pane bounds are computed against the main display.
- Coarse precision. Webcam gaze without specialized hardware is roughly 2–3 inches at laptop distance. Comfortable for 2×2 or three-up layouts; frustrating with many small panes.
- Distance sensitivity. The
face_scalefeature compensates a little, but big posture changes (lying back vs leaning in) generally want a recalibrate. The validation phase is fast (~30 s) for this reason. - Single tab. We only consider panes inside the current iTerm tab.
- Continuity Camera trap. Nearby iPhone often hijacks index 0; use
--camera 1for your built-in.
Layout
gaze-pane/
setup.sh bootstraps venv + deps + editable install + model
requirements.txt
pyproject.toml
face_landmarker.task downloaded by setup.sh (gitignored)
gaze_pane/
__main__.py CLI: calibrate / run
gaze.py webcam thread + MediaPipe -> 9-dim feature vector
mapper.py ridge-regularized affine fit + save/load + history
iterm.py iTerm2 Python API helpers (splitter-tree pane rects)
calibrate.py cv2 fullscreen calibration UI + validation phase
main.py runtime orchestrator (asyncio + dwell + activation)
overlay.py AppKit translucent gaze dot, click-through
scripts/
diag.py webcam + MediaPipe one-shot diagnostic
list_cameras.py probe camera indices for brightness + face detect
License
MIT.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file gaze_pane-0.1.2.tar.gz.
File metadata
- Download URL: gaze_pane-0.1.2.tar.gz
- Upload date:
- Size: 41.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bce68c85ba340e8881b93c8c9cd0b67c18d4494f4fd822859c6a0b5667209b38
|
|
| MD5 |
babaf1a023c8e805be9567f2a73fba17
|
|
| BLAKE2b-256 |
21797c6dd5c63578abb9da63ae8287503ea02cebca7a3c6b8df9567bd5a0cd54
|
Provenance
The following attestation bundles were made for gaze_pane-0.1.2.tar.gz:
Publisher:
publish.yml on noahjohnson0/gaze-pane
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
gaze_pane-0.1.2.tar.gz -
Subject digest:
bce68c85ba340e8881b93c8c9cd0b67c18d4494f4fd822859c6a0b5667209b38 - Sigstore transparency entry: 1568652234
- Sigstore integration time:
-
Permalink:
noahjohnson0/gaze-pane@25c26e62e3e1b902328922934e1983bd52c59f57 -
Branch / Tag:
refs/tags/v0.1.2 - Owner: https://github.com/noahjohnson0
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@25c26e62e3e1b902328922934e1983bd52c59f57 -
Trigger Event:
push
-
Statement type:
File details
Details for the file gaze_pane-0.1.2-py3-none-any.whl.
File metadata
- Download URL: gaze_pane-0.1.2-py3-none-any.whl
- Upload date:
- Size: 42.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d873c4a426c373876649f5bddf6b5a79e21aa85c407521a5dab7377b3fbf4e2a
|
|
| MD5 |
3bea10fa6677b51b4511cd93b60bc006
|
|
| BLAKE2b-256 |
b496f0e6099a00d5e1c85dd7ebc93cdd02066ee71fa624d4c261526eec8e47ff
|
Provenance
The following attestation bundles were made for gaze_pane-0.1.2-py3-none-any.whl:
Publisher:
publish.yml on noahjohnson0/gaze-pane
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
gaze_pane-0.1.2-py3-none-any.whl -
Subject digest:
d873c4a426c373876649f5bddf6b5a79e21aa85c407521a5dab7377b3fbf4e2a - Sigstore transparency entry: 1568652255
- Sigstore integration time:
-
Permalink:
noahjohnson0/gaze-pane@25c26e62e3e1b902328922934e1983bd52c59f57 -
Branch / Tag:
refs/tags/v0.1.2 - Owner: https://github.com/noahjohnson0
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@25c26e62e3e1b902328922934e1983bd52c59f57 -
Trigger Event:
push
-
Statement type: