[object Object]

← back to Dot Palette

dot-palette: always-on-top click palette to set the current iTerm2 tab's status dot

2629bec17f232ad3710b7477f5090d8b40ac4d73 · 2026-09-16 16:51:12 -0700 · Steve

Files touched

Diff

commit 2629bec17f232ad3710b7477f5090d8b40ac4d73
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Sep 16 16:51:12 2026 -0700

    dot-palette: always-on-top click palette to set the current iTerm2 tab's status dot
---
 .gitignore        | 10 ++++++
 electron-main.js  | 80 ++++++++++++++++++++++++++++++++++++++++++++++++
 package.json      |  7 +++++
 preload.js        |  5 +++
 public/index.html | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js         | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 start.command     | 15 +++++++++
 7 files changed, 295 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c4be4df
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+.port
+.pos
diff --git a/electron-main.js b/electron-main.js
new file mode 100644
index 0000000..20e5685
--- /dev/null
+++ b/electron-main.js
@@ -0,0 +1,80 @@
+// dot-palette — Electron shell: a small always-on-top, NON-ACTIVATING NSPanel
+// with 6 colored chips. Clicking a chip sets the CURRENT iTerm2 tab's status dot
+// via the local server (which resolves iTerm2's current-session tty + calls the
+// dot engine). Reuses the desktop-dotbar NSPanel recipe: type:'panel' +
+// focusable:false + alwaysOnTop('screen-saver') so clicks never steal focus from
+// the terminal you're working in.
+'use strict';
+const { app, BrowserWindow, ipcMain, screen } = require('electron');
+const { spawn } = require('child_process');
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+
+const DIR = __dirname;
+const W = 250, H = 52;
+const POS_FILE = path.join(DIR, '.pos');
+let win, serverProc, PORT = null;
+
+function portFromFile() { try { return parseInt(fs.readFileSync(path.join(DIR, '.port'), 'utf8'), 10); } catch { return null; } }
+function alive(port) {
+  return new Promise(res => {
+    if (!port) return res(false);
+    const r = http.get({ host: '127.0.0.1', port, path: '/health', timeout: 800 }, x => { x.resume(); res(x.statusCode === 200); });
+    r.on('error', () => res(false)); r.on('timeout', () => { r.destroy(); res(false); });
+  });
+}
+// Spawn our own server via Electron-as-node (no separate node install needed).
+async function ensureServer() {
+  serverProc = spawn(process.execPath, [path.join(DIR, 'server.js')], {
+    stdio: 'ignore', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } });
+  for (let i = 0; i < 40; i++) {
+    await new Promise(r => setTimeout(r, 150));
+    const p = portFromFile();
+    if (await alive(p)) return p;
+  }
+  return portFromFile() || 9791;
+}
+
+function readPos() {
+  try {
+    const [x, y] = fs.readFileSync(POS_FILE, 'utf8').split(',').map(n => parseInt(n, 10));
+    if (Number.isFinite(x) && Number.isFinite(y)) return { x, y };
+  } catch {}
+  const wa = screen.getPrimaryDisplay().workArea;          // default: top-right of primary
+  return { x: wa.x + wa.width - W - 24, y: wa.y + 24 };
+}
+function writePos() { try { if (win && !win.isDestroyed()) { const b = win.getBounds(); fs.writeFileSync(POS_FILE, `${b.x},${b.y}`); } } catch {} }
+function raise() { try { if (win && !win.isDestroyed()) { win.setAlwaysOnTop(true, 'screen-saver'); win.moveTop(); } } catch {} }
+
+async function createWindow() {
+  PORT = await ensureServer();
+  const pos = readPos();
+  win = new BrowserWindow({
+    x: pos.x, y: pos.y, width: W, height: H,
+    type: 'panel',                 // NSPanel: floats above normal windows, non-activating
+    frame: false, resizable: false, movable: true, minimizable: false, maximizable: false,
+    fullscreenable: false, skipTaskbar: true, hasShadow: true, transparent: false,
+    focusable: false,              // never steal focus from the terminal being colored
+    backgroundColor: '#14161a',
+    webPreferences: { preload: path.join(DIR, 'preload.js'), contextIsolation: true, nodeIntegration: false },
+  });
+  win.setAlwaysOnTop(true, 'screen-saver');
+  win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
+  win.loadURL(`http://127.0.0.1:${PORT}/`);
+  win.on('moved', writePos);
+  setInterval(raise, 2000);        // re-assert top above other apps
+  ipcMain.on('palette:quit', () => app.quit());
+  ipcMain.on('palette:move', (_e, dx, dy) => {
+    if (!win || win.isDestroyed()) return;
+    const b = win.getBounds(); win.setBounds({ ...b, x: b.x + Math.round(dx), y: b.y + Math.round(dy) }); writePos();
+  });
+}
+
+app.on('window-all-closed', () => app.quit());
+app.on('before-quit', () => { if (serverProc) try { serverProc.kill(); } catch {} });
+app.whenReady().then(() => {
+  if (app.dock) app.dock.hide();   // accessory app: panel floats above active apps
+  createWindow().catch(e => console.log('PALETTE createWindow ERROR', e && e.stack || e));
+});
+process.on('uncaughtException', e => console.log('PALETTE uncaught', e && e.stack || e));
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..feec484
--- /dev/null
+++ b/package.json
@@ -0,0 +1,7 @@
+{
+  "name": "dot-palette",
+  "version": "1.0.0",
+  "private": true,
+  "description": "Always-on-top clickable palette to set the CURRENT iTerm2 tab's status dot.",
+  "main": "electron-main.js"
+}
diff --git a/preload.js b/preload.js
new file mode 100644
index 0000000..f5be7d7
--- /dev/null
+++ b/preload.js
@@ -0,0 +1,5 @@
+const { contextBridge, ipcRenderer } = require('electron');
+contextBridge.exposeInMainWorld('palette', {
+  quit: () => ipcRenderer.send('palette:quit'),
+  move: (dx, dy) => ipcRenderer.send('palette:move', dx, dy),
+});
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..8d3914b
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,91 @@
+<!doctype html>
+<html>
+<head>
+<meta charset="utf-8">
+<title>dot-palette</title>
+<style>
+  :root { color-scheme: dark; }
+  * { box-sizing: border-box; -webkit-user-select: none; user-select: none; }
+  html, body { margin: 0; height: 100%; background: #14161a; overflow: hidden;
+    font: 12px -apple-system, system-ui, sans-serif; color: #cbd2da; }
+  #bar { display: flex; align-items: center; gap: 6px; height: 100%; padding: 0 8px; }
+  /* Left grip: the drag handle (the window is non-activating, so we move it via IPC deltas). */
+  #grip { cursor: grab; color: #4a525c; font-size: 13px; letter-spacing: -2px; padding: 0 2px; }
+  #grip:active { cursor: grabbing; }
+  .chip { width: 30px; height: 30px; border-radius: 50%; border: 2px solid rgba(255,255,255,.14);
+    display: flex; align-items: center; justify-content: center; font-size: 15px; cursor: pointer;
+    transition: transform .08s ease, box-shadow .12s ease; }
+  .chip:hover { transform: scale(1.12); }
+  .chip:active { transform: scale(.92); }
+  .chip.ok { box-shadow: 0 0 0 3px rgba(255,255,255,.85); }
+  .chip.bad { box-shadow: 0 0 0 3px #ff3b30; }
+  #x { margin-left: 2px; color: #565e69; cursor: pointer; font-size: 14px; padding: 0 3px; }
+  #x:hover { color: #ff5b52; }
+</style>
+</head>
+<body>
+  <div id="bar">
+    <div id="grip" title="drag to move">⠿</div>
+    <div id="chips"></div>
+    <div id="x" title="close palette">×</div>
+  </div>
+  <script>
+    const CHIPS = [
+      { c: 'green',     bg: '#00c853', em: '🟢', name: 'WORKING' },
+      { c: 'yellow',    bg: '#ffcc00', em: '🟡', name: 'DIRECTION?' },
+      { c: 'orange',    bg: '#ff8c00', em: '🟠', name: 'PASTE waiting' },
+      { c: 'purple',    bg: '#9400d3', em: '🟣', name: 'GATED' },
+      { c: 'lightblue', bg: '#00b0f0', em: '🔵', name: 'NEEDS STEVE' },
+      { c: 'pink',      bg: '#ff69b4', em: '🩷', name: 'PARKED' },
+    ];
+    const chipsEl = document.getElementById('chips');
+    chipsEl.style.display = 'flex';
+    chipsEl.style.gap = '6px';
+
+    for (const k of CHIPS) {
+      const el = document.createElement('div');
+      el.className = 'chip';
+      el.textContent = k.em;
+      el.style.background = k.bg + '22';       // faint fill; the ring + emoji carry the colour
+      el.style.borderColor = k.bg;
+      el.title = k.c + ' — ' + k.name;
+      el.addEventListener('click', async () => {
+        try {
+          const r = await fetch('/api/setdot', {
+            method: 'POST', headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify({ color: k.c }),
+          });
+          const j = await r.json();
+          el.classList.remove('ok', 'bad');
+          void el.offsetWidth;                 // restart the CSS transition
+          el.classList.add(j.ok ? 'ok' : 'bad');
+          el.title = j.ok ? (k.c + ' — set on ' + j.tty) : (k.c + ' — ' + (j.error || 'failed'));
+          setTimeout(() => el.classList.remove('ok', 'bad'), 650);
+        } catch (e) {
+          el.classList.add('bad');
+          setTimeout(() => el.classList.remove('bad'), 650);
+        }
+      });
+      chipsEl.appendChild(el);
+    }
+
+    // Drag-to-move: the panel is focusable:false so native title-bar drag won't fire;
+    // track pointer deltas on the grip and nudge the window via the main process.
+    const grip = document.getElementById('grip');
+    let dragging = false, lastX = 0, lastY = 0;
+    grip.addEventListener('pointerdown', (e) => {
+      dragging = true; lastX = e.screenX; lastY = e.screenY;
+      grip.setPointerCapture(e.pointerId); e.preventDefault();
+    });
+    grip.addEventListener('pointermove', (e) => {
+      if (!dragging) return;
+      const dx = e.screenX - lastX, dy = e.screenY - lastY;
+      lastX = e.screenX; lastY = e.screenY;
+      if (dx || dy) window.palette.move(dx, dy);
+    });
+    grip.addEventListener('pointerup', (e) => { dragging = false; try { grip.releasePointerCapture(e.pointerId); } catch {} });
+
+    document.getElementById('x').addEventListener('click', () => window.palette.quit());
+  </script>
+</body>
+</html>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..5d23a70
--- /dev/null
+++ b/server.js
@@ -0,0 +1,87 @@
+#!/usr/bin/env node
+// dot-palette — tiny local server behind the always-on-top color palette.
+// One endpoint: POST /api/setdot {color,label?} -> resolve the CURRENT iTerm2
+// pane's tty via AppleScript, then call the dot engine so the ~/.claude/tab-dots
+// registry (what dot-screen-router reads) stays the single source of truth.
+// Zero deps (Node built-ins only). $0 local — reads/sets only your own tabs.
+'use strict';
+
+const http = require('http');
+const { execFile } = require('child_process');
+const fs = require('fs');
+const path = require('path');
+
+const ENGINE = `${process.env.HOME}/Projects/terminal-status/terminal_status.py`;
+// The six status colours the engine accepts (COLORS in terminal_status.py).
+const COLORS = ['green', 'yellow', 'orange', 'purple', 'lightblue', 'pink'];
+
+function run(cmd, args, timeoutMs = 8000) {
+  return new Promise((resolve) => {
+    execFile(cmd, args, { timeout: timeoutMs, maxBuffer: 1 << 20 }, (err, stdout, stderr) => {
+      resolve({ err, stdout: (stdout || '').trim(), stderr: (stderr || '').trim() });
+    });
+  });
+}
+
+// The crux: ask iTerm2 for ITS current session's tty (the tab Steve last used),
+// NOT the OS frontmost app — so a non-activating palette click still targets the
+// right pane even though iTerm2 may not be the active application at click time.
+async function frontTty() {
+  const script = 'tell application "iTerm2" to get tty of current session of current window';
+  const { stdout } = await run('osascript', ['-e', script], 5000);
+  return /^\/dev\/ttys\d+$/.test(stdout) ? stdout : '';
+}
+
+async function setDot(color, label) {
+  if (!COLORS.includes(color)) return { ok: false, error: `bad color: ${color}` };
+  const tty = await frontTty();
+  if (!tty) return { ok: false, error: 'no current iTerm2 session (is iTerm2 open?)' };
+  const args = [ENGINE, 'set', color];
+  if (label) args.push(String(label).slice(0, 120));
+  args.push('--tty', tty);
+  const { err, stdout, stderr } = await run('python3', args, 8000);
+  return { ok: !err, tty, color, out: stdout, error: err ? (stderr || String(err)).slice(0, 200) : null };
+}
+
+function send(res, code, body, type = 'application/json') {
+  res.writeHead(code, { 'Content-Type': type, 'Cache-Control': 'no-store' });
+  res.end(typeof body === 'string' ? body : JSON.stringify(body));
+}
+
+const server = http.createServer(async (req, res) => {
+  try {
+    const url = new URL(req.url, 'http://x');
+    if (url.pathname === '/health') {
+      return send(res, 200, { ok: true, port: server.address() && server.address().port });
+    }
+    if (url.pathname === '/api/setdot' && req.method === 'POST') {
+      let raw = '';
+      req.on('data', c => (raw += c));
+      req.on('end', async () => {
+        let color = '', label = '';
+        try { const j = JSON.parse(raw); color = j.color; label = j.label || ''; } catch {}
+        send(res, 200, await setDot(color, label));
+      });
+      return;
+    }
+    if (url.pathname === '/' || url.pathname === '/index.html') {
+      return send(res, 200, fs.readFileSync(path.join(__dirname, 'public', 'index.html'), 'utf8'), 'text/html');
+    }
+    send(res, 404, { error: 'not found' });
+  } catch (e) {
+    send(res, 500, { error: String(e) });
+  }
+});
+
+function listen(port, tries = 20) {
+  server.once('error', (e) => {
+    if (e.code === 'EADDRINUSE' && tries > 0) return listen(port + 1, tries - 1);
+    throw e;
+  });
+  server.listen(port, '127.0.0.1', () => {
+    const p = server.address().port;
+    fs.writeFileSync(path.join(__dirname, '.port'), String(p));
+    console.log(`dot-palette on http://127.0.0.1:${p}`);
+  });
+}
+listen(parseInt(process.env.PORT || '9791', 10));
diff --git a/start.command b/start.command
new file mode 100755
index 0000000..4654ff6
--- /dev/null
+++ b/start.command
@@ -0,0 +1,15 @@
+#!/bin/zsh
+# Launch the always-on-top dot palette. Reuses desktop-dotbar's Electron runtime
+# so there is nothing to npm-install. Double-click this file, or run it from a shell.
+cd "$(dirname "$0")"
+ELECTRON="$HOME/Projects/desktop-dotbar/node_modules/.bin/electron"
+if [[ ! -x "$ELECTRON" ]]; then
+  echo "Electron not found at $ELECTRON — run 'npm i' in ~/Projects/desktop-dotbar first." >&2
+  exit 1
+fi
+# Single instance: if a palette is already up, don't stack another.
+if pgrep -f "dot-palette/electron-main.js" >/dev/null 2>&1; then
+  echo "dot-palette already running."
+  exit 0
+fi
+exec "$ELECTRON" . >/tmp/dot-palette.log 2>&1

(oldest)  ·  back to Dot Palette  ·  dot-palette: side-effect-free test seam + selftest + safe-by bc3244f →