Skip to content

iTerm2Axis v0.1.3

Latest

Choose a tag to compare

@Jeshii Jeshii released this 18 Jun 06:59
9659473

2026-06-18

  • Claude Code sidebar now shows model name, no Python dependency — fetchClaudeAgentsData runs claude agents --json directly via /usr/bin/env, parses JSON with hs.json.decode, and reads the session's .jsonl file in pure Lua to extract modelID. Removed RESOLVE_PYTHON_PATH(), buildCCModelCmd(), and the pythonPath config option. The entire enrichment pipeline is now implemented in Lua using io.open and hs.json.decode. Displayed as a DETAIL_COLOR row under the "claude" header. Also fixed a string.format error caused by an unescaped % in the old Python inline script.
  • Claude CLI path auto-resolution — replaces the broken which claude async check with RESOLVE_CLAUDE_PATH() that probes ~/.local/bin/claude, /opt/homebrew/bin/claude, and /usr/local/bin/claude at startup. The claude binary is invoked via /bin/sh -c with the full resolved path.
  • Parsed-agents log gated behind debug config, SHORT_MODEL_NAME strips claude- prefix.

2026-06-16

  • Removed tiling on/off feature (_tilingEnabled, toggleTiling(), ⌘⇧T hotkey) — hiding the sidebar already disables tiling, making a separate toggle misleading. Tiling is now always active when the sidebar is visible. The tiling cascade guard (_tilingInProgress, _withTilingGuard) is preserved as a stability mechanism. Removed: _tilingEnabled state var and all 4 guard conditions across windows.lua/render.lua/watchers.lua, toggleTiling() function, toggleTiling from ACTION_LABELS and bindHotkeys(), and the Phase 3 "sidebar follows focus when tiling off" block in windowFocused handler.

  • Cleaned up _sidebarEnabled dead code — this variable was initialized to true and never mutated. Removed from init(), syncCanvasLevel(), refreshSleepWake(), _setupSpaceWatcher(), and 5 async callback guards in fetchers.lua.

  • Fixed sidebar-hidden dead-end state where _sidebarVisible could only be recovered via explicit toggle — _doBuildSidebar() was setting _sidebarVisible = false in two non-user-intent paths: (1) the #wins == 0 branch (no iTerm windows), and (2) the pcall error handler. Once _sidebarVisible was false, the early if not self._sidebarVisible then return end guard prevented ALL future rebuilds through watchers/refresh — only toggleSidebar() could recover it. Fixed by removing both _sidebarVisible = false assignments — the system now only hides/deletes the canvas without overwriting user intent. toggleSidebar() no longer calls tileITermWindows() on hide (canvas just hides, windows keep their positions). Added pcall error capture (local ok, err = pcall(...)) with print() in the error handler so the root cause of any "sidebar disappears on refresh" crash can be diagnosed.

  • macOS native full-screen (green button) now hides sidebar and excludes full-screened windows from tiling — Space watcher calls _checkFullScreenAndAdjust() on every Space switch; when hs.spaces.spaceType() returns "fullscreen", the sidebar canvas is hidden (removed from full-screen Spaces for other apps and full-screened iTerm windows). _doBuildSidebar() also checks _isCurrentSpaceFullScreen() as a guard on every rebuild. Full-screened iTerm windows are filtered from getITermWindows() via w:isFullScreen(), keeping them out of the tiling pool. Option-click (Zoom/fill) is unaffected — isFullScreen() returns false and no Space change occurs.

  • Added screensDidWake / screensDidUnlock to sleep watcher — refreshSleepWake() now also fires when the display wakes or the user unlocks the computer (not just system sleep wake), re-creating event taps and rebuilding the sidebar to fix unresponsive clicks after sleep/lock.

2026-06-15

  • Fixed sidebar not showing on build and eliminated double-resize — reordered _doBuildSidebar so that _sidebarVisible = true and tileITermWindows(sb) run before syncCanvasLevel() and show(). Previously windows were tiled after the canvas appeared, causing: (a) two visual transitions (canvas shows, then windows resize); (b) setFrame reshuffling z-order and burying the just-shown canvas. Now windows resize first while the canvas is still hidden, then the canvas appears already at the correct level above the tiled windows — single visual transition.

2026-06-12

  • Tiling cascade guard — added _tilingInProgress flag set before any setFrame loop (via _withTilingGuard) and cleared after settleDelay + 0.1s. handleWindowMoveOrResize checks and bails early if a tile is in flight, breaking the setFrame → windowMoved → retile cascade. Also guards the drift-path setFrame loop in handleWindowMoveOrResize. Cleaned up in stop()/init().
  • Eliminated double-tile on sidebar show — show path in toggleSidebar now inlines anchor computation and calls buildSidebar() directly (no synchronous tileITermWindows), letting _doBuildSidebar be the sole tile trigger. refreshLayout sets _skipTileOnThisBuild = true after its sync tile to prevent _doBuildSidebar from tiling again.
  • Guard watchers during hide — _rebuildAfterSettle now checks _toggleLock before scheduling any rebuild, preventing stale work during the 0.5s hide/show lock window.
  • Reload hygiene — stop() now cleans up _resizeDebounceTimer, _tilingClearTimer, _tilingInProgress, _pendingSidebarFrame, and _currentScreen. init() initializes _tilingInProgress and _tilingClearTimer.

2026-06-09

  • Phase 1: Decouple tiling from sidebar — added _tilingEnabled state variable (init'd true), toggleTiling() function, and ⌘⇧T hotkey. No behavioral change yet — the toggle flips state and re-tiles when re-enabled, but nothing is gated behind it (Phase 2 adds the actual guards).
  • Phase 2: Gate all window frame manipulation behind _tilingEnabled — tileITermWindows(), the setFrame() loop in handleWindowMoveOrResize(), the retile guard in _doBuildSidebar(), and _rebuildAfterSettle() all check _tilingEnabled before touching any window frame. Toggling tiling OFF now actually prevents window layout changes; the sidebar repositions itself to the focused window but does not resize/move any iTerm windows.
  • Phase 3: Sidebar follows focused window when tiling is off — windowFocused handler repositions the sidebar canvas to the newly focused iTerm window using direct canvas:setFrame() instead of going through the buildSidebar() pipeline. This bypasses the snapshot-check/debounce/rebuild machinery and moves the canvas instantly. Only fires when position actually differs (avoids redundant calls on stacked windows).

2026-06-06

  • Fixed canvas unresponsive after sleep/wake — added hs.caffeinate.watcher (_setupSleepWatcher) that re-creates the click and drag event taps and rebuilds the sidebar canvas on wake from sleep, since hs.eventtap instances can be silently invalidated by the system after macOS sleep. Added refreshSleepWake() helper that invalidates all state, re-creates event taps, and conditionally rebuilds if the sidebar was visible.
  • Attempted to fix sidebar toggle reliability — _sidebarVisible is now the single source of truth in toggleSidebar (decoupled from sidebarCanvas existence). Screen watcher (_setupScreenWatcher) no longer forces _sidebarVisible = true and only rebuilds if the sidebar was already visible. Space watcher (_setupSpaceWatcher) now checks _sidebarVisible before rebuilding. Added _toggleLock to the show path for consistency with the hide path. Added _sidebarVisible guards to all async fetcher callbacks to prevent unnecessary rebuilds when the sidebar is hidden.
  • Attempted to fix tiling never firing after first build — _doBuildSidebar body is now wrapped in pcall so _buildPending is always reset (preventing the "stuck" state where any Lua error silently blocks all future rebuilds and tiling). Added tileITermWindows() call directly in refreshLayout so tiling always fires on explicit layout commands regardless of _doBuildSidebar's snapshot-based early-exit logic.

2026-06-05

  • Fixed sidebar context loss on focused Claude Code window during spinner animation — when the focused iTerm2 window had a Claude Code session running, every spinner tick in the title (⠋ /path, ⠙ /path, etc.) triggered windowTitleChanged. The stableCore() comparison was intended to detect these as no-ops, but lastRawTitle was never seeded on windowFocused — it was only written by windowTitleChanged itself. So the very first spinner frame after focusing always diffed against a nil/stale baseline, newCore != prevCore, which invalidated wd/tabInfo and fired FETCH_WINDOW_INFO with the spinning title. The AppleScript first window whose name = "⠋ /path" failed to match, leaving tabInfo as MISSING and the sidebar falling back to parts.basename (the last word of the title). Fixed by seeding CACHE.wc(winId).lastRawTitle = win:title() in the windowFocused handler so the first spinner diff always has a clean baseline. The bug only manifested on the focused window because unfocused windows don't receive windowFocused and their lastRawTitle was already populated from prior title changes.

  • Attempts to fix sidebar cache corruption during Claude Code spinner animation — CC's dynamic spinner frames (braille U+2800-U+28FF) in the window title lacked the ✳/·/🔔 prefix, so windowTitleChanged treated them as real navigation events, calling CACHE.invalidateWindow which wiped wd/tabInfo before the async AppleScript could re-populate them. The AppleScript then failed because the spinner-frame title didn't match any iTerm window name. Replaced the fragile CC-prefix-based invalidation gate with a stableCore() comparison — strips all stable CC decorations (✳, ·, 🔔, braille U+2800-U+28FF, trailing resolution suffix) and only invalidates when the meaningful path portion actually changed.

    • None of this actually helped
  • Attempts to fix sidebar button flickering during Claude Code thinking — Claude resets the terminal title to its base form between prefix transitions (✳ /path → /path → · /path). This triggered two bugs in windowTitleChanged:

    1. The intermediate /path lacks a CC prefix, so the handler treated it as a real navigation, calling FLASH.stopFlashing (resetting button color to blue) and scheduling a stale rebuild — despite the cache being intact. Fixed by storing lastRawTitle per-window and return-ing early when the stripped previous title matches the current non-CC title, skipping ALL flash changes and rebuilds for transient resets.
    2. The · (busy) state fell through to the else catch-all which called stopFlashing with the normal (blue) color instead of busyColor (green) from agents data. Added an explicit elseif state == "busy" that stops flashing but schedules a rebuild so _gatherWindowData renders the correct busyColor.
      Also renamed _fetchWindowInfo → FETCH_WINDOW_INFO (UPPERCASE convention), changed guard to type(tabInfo) == "table", and preserved all cached fields in the AppleScript callback when the AS fails mid-flight.
    • None of this actually helped
  • Code review cleanup (Phase E): Made _adjustFlashTimer local in flash.lua. Renamed shouldShowAndTile → needsRetile with clarifying comment about pre-render timing. Guarded CACHE._claudeAgentsData against wipe on empty poll results. Added CACHE.MISSING sentinel constant replacing bare false in 7 fetcher assignments. Hoisted hs.window.focusedWindow() out of per-window loop in _gatherWindowData. Scoped all 6 flash module variables (_sharedFlashTimer, _currentFlashInterval, _flashingWindows, _flashState, _flashNormalColor, _flashType) to local with FLASH.flashState(winId) getter and FLASH.reset() replacing the duplicate reset blocks in init()/stop().

  • CR follow-up: Added :stop() before nilling _sharedFlashTimer in FLASH.reset() to prevent timer leak. Added sentinel comment for CACHE.MISSING (nil = not yet fetched, false = fetched but absent).

  • Fixed canvas disappearing on async data updates — _renderFullSidebar always runs when the sidebar snapshot changes (triggered by async callbacks: git branch, claude/opencode polling, window titles, etc.), deleting the old canvas and creating a new hidden one. But show() was gated by shouldShowAndTile, which only fires when the window structure (count/dimensions) changes. Any non-structural snapshot change caused the canvas to hide. Moved show() and _sidebarVisible = true outside the guard — only tiling remains conditional. Also removed _flashState from sidebarStateSnapshot since the flash timer handles its own visual updates via direct elementAttribute mutations, reducing unnecessary rebuilds.

  • Global naming convention cleanup — moved display-state globals to RENDER.* namespace (sidebarStateSnapshot, startFlashing, claudeState, 14 total), uppercased stateless helpers (COLOR, IS_ITERM, GET_GIT_BRANCH_FOR_PATH, BASE_PATH, 12 total), left _-prefixed internals as-is. .luarc.json diagnostics.globals shrunk from 8 entries to just ["hs"]. Zero lowercase-global warnings.

  • Code review cleanup (Phase A): Fixed closeMenu() in _renderPopupMenu — replaced manual teardown with self:_closeMenus() call, removing 8 lines of duplicate code. Removed duplicate _currentFlashInterval = nil in _adjustFlashTimer.

  • Code review cleanup (Phase B): Created FLASH namespace for flash functions (claudeState, startFlashing, stopFlashing, etc.), moved off RENDER. isSidebarClickAllowed changed from RENDER.* global to local in mouse.lua. Converted _syncOrderedIds from free function to OBJ:_syncOrderedIds(). Made HEADER_COLOR/DETAIL_COLOR/PAD_TOP/GAP local in render.lua. Namespaced _claudeAgentsData under CACHE._claudeAgentsData.

  • Code review cleanup (Phase C): Moved 5 hardcoded color literals to config (borderColor, wsNameColor, branchColor, menuBgColor, menuHighlightColor).

  • Code review cleanup (Phase D): Moved OBJ:findWindowScreen and OBJ:getScreen from cache.lua to windows.lua (screen resolution is layout logic, not cache logic).

  • Fixed opencode data fetch hanging: sqlite3 fallback query on large DBs (~121MB) could block indefinitely, leaving _opencodePending stuck at true and preventing all future polls. Added LIMIT 50 to the query and clear _opencodePending before launching the sqlite3 task so subsequent polls aren't blocked.

  • Phase 1 (Change 1): Fixed _hotkeyLabels dead state — added formatHotkeyLabel() helper with MOD_SYMBOLS/KEY_SYMBOLS lookup tables that convert modifier/key arrays to display strings (e.g. {"cmd","shift"},"S" → "⌘⇧S"). bindHotkeys() now populates self._hotkeyLabels from the binding parameters, so user-customized hotkeys automatically update context menu shortcuts. Removed the dead self._hotkeyLabels and self._hotkeyLabels.xxx or "fallback" pattern from showWindowMenu/showGlobalMenu.

  • Phase 1 (Change 2): Replaced inline menu teardown with self:_closeMenus()

  • Phase 1 (Change 3): Fixed _fetchWindowInfo silent false-negative

  • Phase 1 (Change 4): Added busyColor to config

  • Phase 1 (Change 5): Fixed startFlashing mixed-type interval with _adjustFlashTimer() — extracted flashIntervalForType(), minActiveFlashInterval(), and _adjustFlashTimer() helpers. startFlashing and stopFlashing now both call _adjustFlashTimer(), which (re)creates the shared timer at the minimum interval across all active flash types. When a fast-type window leaves, the timer correctly grows back to the next shortest interval. Removed the single-type interval assignment and manual teardown in stopFlashing. — replaced the only hardcoded inline color in _gatherWindowData with a new cfg.busyColor entry, making it themable like all other colors. — early-return condition ~= nil matched both nil (unfetched) and false (fetch returned empty), preventing retry when AppleScript failed. Changed to truthy check so false values trigger a new fetch while respecting the pending guard. — showWindowMenu and showGlobalMenu each had an identical 8-line inline block duplicating _closeMenus(). Replaced both with a single self:_closeMenus() call.

  • Phase 2 (Change 1): Extracted _syncOrderedIds() — the ~20-line live-window reconciliation block (filter _orderedWindowIds against current windows, append new ones) was duplicated in moveWindowById, moveWindowToExtent, and focusNextWindow. Extracted into _syncOrderedIds() helper, which also handles _orderedWindowIds initialization. The slightly different pattern in focusNextWindow's else branch and its empty-IDs fill branch both reduce to the same _syncOrderedIds() call.

  • Phase 2 (Change 2): Extracted normalizeOCSession() — the s.model JSON decode + record construction was duplicated in both the HTTP and SQLite branches of fetchOpenCodeData. Extracted into normalizeOCSession(s) pure helper. Callers keep their different dedup strategies (HTTP: replace-when-newer, SQLite: first-seen-wins).

  • Phase 2 (Change 3): Extracted windowStatusColor() — the 7-branch btnColor chain in _gatherWindowData extracted into a pure function taking (state, isActive, isDragHover, isFlashing, isFocused). Captures cfg as upvalue. Replaced inline chain and removed the orphaned local btnColor declaration.

  • Phase 2 (Change 4): Renamed needsFullRebuild to shouldShowAndTile — in _doBuildSidebar, the variable controls whether to show the canvas and tile (rendering is always full via _renderFullSidebar). New name matches actual semantics.

  • Phase 3: Collapsed 10 parallel cache tables into _winCache — replaced _gitBranchCache, _gitBranchPending, _gitWsNameCache, _prCache, _prBranchCache, _prPending, _wdCache, _tabInfoCache, _tabInfoPending, _hostnameCache with single _winCache[winId] record containing .wd, .tabInfo, .tabPending, .hostname, .branch, .brPending, .wsName, .pr, .prBranch, .prPending fields. Added wc(winId) auto-vivify helper and invalidateWindow(id, fields) for partial clears. All 10 invalidation sites replaced with _winCache[id] = nil (full) or invalidateWindow(id, {fields...}) (partial). Full resets in init()/stop() collapsed from 10 lines to _winCache = {}.

  • Phase 4: Decomposed single 2318-line init.lua into 8 focused modules — extracted cache.lua, flash.lua, fetchers.lua, render.lua, windows.lua, mouse.lua, watchers.lua. Uses dofile with global declarations (no local on shared symbols). init.lua reduced from 2318 to 406 lines.

  • Phase 4: CACHE namespace for cache globals — _winCache → CACHE.winCache, _iTermWindowsCache → CACHE.iTermWindowsCache, getITermWindows() and wc() moved to CACHE table. Intentional bare globals (isITerm, color, etc.) listed in .luarc.json to silence lua-language-server lowercase-global diagnostics.

2026-06-04

  • Phase 4: Tab-name buttons with ・ dot indicators: label now shows the iTerm2 tab name (from _tabInfoCache) wrapped in ・ dots indicating tab count and focused position (e.g. ・tabname・). makeTabLabel() helper safely handles missing/falsy tab info, unknown focused index (falls back to position 1), and edge cases. Basename suppression compares against plain tabName instead of the dotted label. When tab info is unavailable (async miss), falls back to basename or hostname or "Window N" with no dots.

  • Fixed tmux window support in _fetchWindowInfo: replaced session-ID matching tab iteration with title of current tab + title-match for focused index — session IDs differ between window and tab contexts for tmux connections, causing the script to return "" and block all cache population. Also always sets _tabInfoCache[winId] (to false on failure) to prevent infinite re-fetch loops.

  • Phase 3: Hostname as separate line — _hostnameCache[winId] (fallback parts.host from title parsing) rendered as line 2 in buildTextRows with DETAIL_COLOR, suppressed when identical to label. Label fallback updated to use hostname instead of parts.host directly. Label itself unchanged — hostname line sets up Phase 4 where label becomes tab name and they diverge.

  • Phase 2: Combined tab-info + session variable AppleScript — replaced getWindowWorkingDir() with _fetchWindowInfo(win) that returns tabCount\x1efocusedIdx\x1etabName\x1epath\x1ehostname in a single call. Added _tabInfoCache, _tabInfoPending, _hostnameCache module-level tables. Removed _wdFlight. All callers updated to _fetchWindowInfo(win) + _wdCache[winId]. Cache invalidation added for _tabInfoCache/_hostnameCache in windowDestroyed, windowTitleChanged, handleWindowMoveOrResize screenChanged. No visible UI changes — path/branch display unaffected.

  • Phase 1: Removed custom rename feature — stripped all rename/custom-name infrastructure (~200 lines): removed renameWindow, _saveCustomName, startRenameMode, _updateRenameBar, _handleRenameKey, commitRename, cancelRenameMode, _positionRenameBar methods; removed _renameMode/_renameBuffer/_renameWindowId/_renameEventTap/_renameBlink/_renameBarIdx/_renameTextIdx/_cursorVisible/_customNames/_customNamesByPath/_pendingPathNames state fields; removed SETTINGS_KEY_NAMES_BY_PATH persistence key; removed rename bar elements from _renderFullSidebar; removed rename cancel guards from 4 watcher/eventtap locations; removed rename hotkey (⌘⇧E) and Rename context menu item; removed path-name persistence in stop()/_restorePersistedState()/getWindowWorkingDir callback. Label falls back to parts.host or basename or "Window N". No custom names.

  • Rename bar repositioned above target button: instead of always appearing at the bottom of the sidebar, the rename bar now appears just above the button being renamed (2px gap, clamped to not go off-screen). _renderFullSidebar no longer hardcodes sb.h - BAR_H - BAR_BOTTOM_MARGIN; positioning extracted into _positionRenameBar() helper called from both startRenameMode and _renderFullSidebar (so the bar stays positioned correctly on async rebuilds during rename mode). Also added strokeWidth = 0 to the bar rect element to fix a thin black line artifact from the default 1px stroke.

  • Canonical hotkey dictionary + alerts: added formatHotkeyLabel() helper with MOD_SYMBOLS/KEY_SYMBOLS lookup tables that produce the correct display string from modifier/key arrays (e.g., { "cmd", "shift" }, "S" → "⌘⇧S"). bindHotkeys() now stores all resolved labels in self._hotkeyLabels[name] and fires hs.alert.show() on every hotkey press. All 13 hardcoded UTF-8 shortcut strings in showWindowMenu() and showGlobalMenu() replaced with self._hotkeyLabels.* references — changing defaults or user overrides auto-updates everywhere. Added completion alerts for the 3 slow operations: toggleSidebar() shows "Sidebar Hidden"/"Sidebar Shown", toggleSide() shows "Swap Side Complete" after _doBuildSidebar finishes tiling, and the refresh hotkey shows "Layout Refreshed".

  • Single render path: removed _renderInPlace() and all dual-path machinery (_elementMap, _btnStructureKeys, buttonStructureKey(), bKey, needsAnyWindowRebuild). _doBuildSidebar() now always calls _renderFullSidebar() — the sidebarStateSnapshot() early-exit guard already prevents unnecessary work. This eliminates the class of bug where text element frames desync from click areas when row counts change (e.g., a claude waitingFor line appears). ~80 lines removed.

  • Fixed getWindowWorkingDir(): changed AppleScript to match by window title (whose name = "...") instead of by CGWindowID (whose id is %d). Hammerspoon's win:id() returns a CGWindowID that does not match iTerm2's AppleScript window ID, causing every path lookup to fail (Can't get current session) and leaving _wdCache[winId] = false. Title-based matching with proper \ and " escaping unblocks git branch display, claude section, and opencode path matching.

  • Removed Claude Code .jsonl parsing: the never-relaible file-based reading of ~/.claude/projects/*.jsonl for model/token data has been removed along with claudeEncodeDir(), claudeProjectDir(), fetchClaudeCodeForWindow(), fetchClaudeCodeData(), and all associated caches/render code. The claudecode config key is kept for backward compat.

  • Replaced with claude agents --json polling: new fetchClaudeAgentsData() runs claude agents --json on each poll interval (default 5s), parses structured JSON indexed by cwd, and populates _claudeAgentsData[cwd] = { status, waitingFor }. Matches windows by their working directory (_wdCache). New startClaudeAgentsPolling() wired into start()/stop()/init(). The claudeState() title-based heuristic (✳/·/🔔) is kept for flash responsiveness; claude agents --json status will be preferred for button color in a follow-up.

  • State detection upgrade: _gatherWindowData() now prefers claude agents --json status ("waiting"/"busy") over the title-prefix heuristic (✳/·), with 🔔 bell kept from title only. The heuristic remains as fallback when agent data is absent or reports "idle".

  • Dynamic button layout with no-gap text packing: replaced the hardcoded 5-line button layout with dynamic textRows computed per-window via buildTextRows(). Lines pack without gaps — each line (basename, branch, opencode section, claude section + waitingFor + status) only appears when its data exists. Button height is uniform across all windows, computed as computeBtnHeight(maxNumRows, dfs). _elementMap now stores a rows array of element indices instead of named line1..line5 fields. buttonStructureKey() simplified to just the row count. Removed btnFontSizes() and old btnHeight(). New computeTextArea() helper for text positioning within each button.

  • _gatherWindowData() now skips the redundant cfg parameter (module-level cfg is already in scope).

  • Configurable font sizes: added defaultFontSize config option (default 11). Line 1 (label) renders at defaultFontSize + 1, lines 2–3 (basename/branch) at defaultFontSize, lines 4–5 (opencode/claudecode) at defaultFontSize - 1. Button height auto-calculates from the font sizes via btnHeight()/btnFontSizes() helpers.

2026-06-03

  • Extracted _rebuildAfterSettle() helper: consolidated 4 identical hs.timer.doAfter(settleDelay, ...) blocks from windowCreated/windowDestroyed/windowMinimized/windowUnminimized watcher callbacks into a single _rebuildAfterSettle(tileWhenHidden) method — reduces duplication, makes the settle-delay pattern explicit

  • Tile inside _doBuildSidebar(): moved tileITermWindows() call into _doBuildSidebar() so tiling fires after the canvas is fully built with correct geometry; added _skipTileOnThisBuild flag to prevent double-tiling from handleWindowMoveOrResize (sets flag before calling buildSidebar, checked inside _doBuildSidebar)

  • tileITermWindows() now accepts optional sidebar anchor: passes the freshly-built sidebar frame (sb) directly instead of re-reading it via getSidebarAnchor(), eliminating a stale-frame race

  • toggleSidebar hide uses tileITermWindows(): replaced manual frame arithmetic in the hide branch with a direct call to tileITermWindows(), consistent with all other tiling paths

  • De-monolith _doBuildSidebar(): extracted 5 pure-block-move helpers (_closeMenus, _orderedWindows, _gatherWindowData, _renderFullSidebar, _renderInPlace). _doBuildSidebar() shrunk from ~477 lines to ~58. No behavior change.

  • Stripped redundant tileITermWindows() calls: removed all 10 external self:tileITermWindows() calls that were unnecessary duplicates of the tiling triggered by buildSidebar() → _doBuildSidebar(). Tiling now fires only via the 50ms debounced _doBuildSidebar rather than also from each caller.

  • Reverted tile-timing fix: the Phase 2 attempt to move tileITermWindows() inside _doBuildSidebar() caused a cycle (setFrame → windowMoved → watcher → handleWindowMoveOrResize → buildSidebar → _doBuildSidebar → setFrame → ...) and snapped windows to tiled position on drift-triggered rebuilds. The stale-canvas-frame race condition will be addressed separately.

  • Refactored start() into extracted methods: pulled 5 watcher setup blocks into named methods (_setupWindowWatcher, _restartWindowWatcher, _setupAppWatcher, _restorePersistedState, _setupScreenWatcher, _setupSpaceWatcher) — start() reduced from ~260 lines to ~15, each extracted method has a clear single responsibility

  • New settleDelay config option (default 0.3): replaces 8 previously hardcoded delays across all watcher subscribers (windowCreated, windowDestroyed, windowMinimized, windowUnminimized, windowTitleChanged, handleWindowMoveOrResize, screenWatcher, spaceWatcher) with a single tunable setting

  • Right-side sidebar support: added sidebarSide config ("left" or "right"), layoutFrames() replaces computeLayout() for bidirectional layout math, new toggleSide() method and ⌘⇧S hotkey to swap sides at runtime; handleWindowMoveOrResize drift detection and toggleSidebar hide restore both handle right-side positioning

  • Start hidden mode: added startHidden config — when true, sidebar starts invisible; _sidebarVisible initialized from config instead of always true

  • Drag-over-sidebar tab merging: new _dragWatchTap eventtap monitors leftMouseDragged/leftMouseUp — dragging a tab over a sidebar button brings the corresponding iTerm window to front and highlights the button with dragHighlightColor (green). Button color reverts when drag leaves the sidebar or ends

  • Combined left/right click eventtap: _leftClickTap + _rightClickTap merged into single _clickTap via _setupSidebarClickTap(); handleSidebarClick now activates iTerm2 app on any sidebar click, focuses the window on left-click, and calls showGlobalMenu() for right-clicks on empty sidebar area

  • Global right-click menu: showGlobalMenu() appears when right-clicking empty sidebar space — offers Refresh Layout, Show/Hide Axis, Swap Side; _renderPopupMenu extracted from showWindowMenu for reuse

  • Removed SETTINGS_KEY_NAMES persistence: _saveCustomName no longer writes to hs.settings under the old key; custom names are now only stored by path in SETTINGS_KEY_NAMES_BY_PATH

  • buildSidebar defers during open menu: sets _needsRebuild flag when menu is visible; rebuild fires after menu closes, preventing stale canvas state

  • _sidebarVisible guards added: to _doBuildSidebar, tileITermWindows, and handleWindowMoveOrResize for safety against stale geometry or rebuilds when sidebar is hidden

  • _sidebarVisible set earlier in toggle: moved before refreshLayout() call to prevent race with debounced _doBuildSidebar

  • _toggleLock in toggleSidebar hide: prevents drift detection during programmatic window moves when hiding the sidebar

  • Fixed left-click not registering when non-iTerm app is frontmost: inverted orderedWindows() guard to block clicks only when a non-iTerm2 window actually covers the click point, rather than requiring an iTerm2 window to be topmost (which fails when another app is frontmost)

  • Fixed toggle show/hide still looping: removed duplicate tileITermWindows() call from show branch of toggleSidebar — refreshLayout() already calls buildSidebar → _doBuildSidebar → tileITermWindows, so the second call was creating an extra wave of windowMoved events that fired after _toggleLock expired

  • Fixed f:contains(clickPt) nil error: replaced hs.geometry.rect:contains() call with a manual bounds check, since w:frame() can return a plain Lua table (truthy but lacking the :contains() method) for off-screen/minimized windows

  • Applied stylua formatting pass across the entire file for consistent indentation

  • Fixed sidebar left clicks being swallowed: corrected rectContains(f, ...) → rectContains(sf, ...) variable name bug in _leftClickTap — f was nil at that scope, causing the sidebar click handler to always skip

  • Cleaned up syncCanvasLevel: removed dead code (window-level scanning loop, unused targetWin/best/bestLvl variables) that was calculating the topmost iTerm window level but never using the result

  • Changed syncCanvasLevel non-iTerm fallback from floating - 1 to normal to prevent sidebar from floating above other apps

  • Consolidated canvas level setting: removed duplicate _appWatcher level calls (floating on activate, floating - 1 on deactivate) so syncCanvasLevel is the single source of truth, eliminating race conditions between the two systems

  • Replaced manual bounds check in _leftClickTap window-scanning loop with rectContains helper for consistency

  • Simplified syncCanvasLevel: always uses normal window level + orderAbove(nil) instead of toggling between floating and normal; other apps naturally layer above the canvas when frontmost, eliminating level-based race conditions

  • Replaced hs.window.orderedWindows() walk in _leftClickTap with isSidebarClickAllowed() helper (checks frontmost app is iTerm2 or Hammerspoon) for simpler, more reliable click gating

  • Changed eventtap handlers to return false instead of true — sidebar clicks are no longer swallowed at the OS level, fixing focus forwarding and click-through issues

  • Replaced canvasMouseEvents(false, false, false, false) with clickActivating(false) + noop mouseCallback in _doBuildSidebar to prevent canvas from intercepting clicks while still allowing eventtaps to work

  • Removed debugTitles() debugging utility no longer needed in production

  • Added .vscode/* to .gitignore

  • Fixed toggle sidebar loop: added _toggleLock flag that suppresses drift detection in handleWindowMoveOrResize during programmatic window moves from toggleSidebar; also added _sidebarEnabled guard to tileITermWindows to prevent stale geometry tiling when the sidebar is hidden

  • Fixed _leftClickTap: restored hs.window.orderedWords() walk to determine the topmost window at the click coordinate — the frontmostApplication() shortcut dropped all sidebar clicks when any other app had focus, even if iTerm2 windows covered the sidebar area

  • Fixed _appWatcher deactivation level: changed from windowLevels.normal to windowLevels.floating - 1 for consistency with syncCanvasLevel (normal level gets buried under other app windows on recent macOS)

  • Fixed orphaned context menu taps: _doBuildSidebar now cleans up _menuCanvas/_menuEventTap/_menuKeyTap before any canvas rebuild, preventing stale global interceptors if a rebuild fires while the menu is open

  • Fixed sidebar visibility loss: syncCanvasLevel non-iTerm2 branch changed from windowLevels.normal to windowLevels.floating - 1 — normal level can be buried behind iTerm2 windows on macOS Sonoma/Sequoia; floating - 1 stays above most content while still yielding focus

2026-06-02

  • Fixed toggleSidebar show bug: removed manual sidebarCanvas:show() after refreshLayout() — the canvas show is now handled by the debounced _doBuildSidebar (which fires 50ms later), preventing the show from hitting a stale or nil canvas reference before the rebuild completes

  • Fixed _doBuildSidebar: moved canvas:show() to after syncCanvasLevel() so the level is set before the canvas renders, preventing it from being buried behind iTerm windows on macOS

  • Fixed syntax error in getOpenPRForWindow: args table ({"-c", ...}) was being passed as 3rd arg to hs.timer.doAfter instead of hs.task.new — same pattern fix as getGitBranchForPath

  • Added _sidebarEnabled guard to syncCanvasLevel for safety; added sidebarCanvas nil guard in toggleSidebar show branch to prevent potential crash

  • Fixed toggleSidebar bug: on hide, iTerm windows now expand left to fill the vacated sidebar area; on show, sidebar position is read directly from the existing canvas frame instead of using a broken heuristic that subtracted sidebarWidth from iTerm's current X

  • Fixed right-click context menu showing duplicate entries (Rename through Move to Bottom appeared twice in showWindowMenu items table)

  • Fixed ⌘⇧N new window hotkey: replaced hs.application.find("iTerm2") with hs.application.get("com.googlecode.iterm2") and added a 0.15s delay before sending ⌘N so iTerm2 has time to gain focus

  • Revised default hotkeys: toggle sidebar ⌘⇧A → ⌘⇧B, rename ⌘⇧W → ⌘⇧E, move up/down ⌘⇧[/] → ⌘⇧↑/↓, move to top/bottom ⌘⇧↑/↓ → ⌘⇧⌥↑/↓; updated context menu shortcut labels to match

  • Replaced hs.dialog.textPrompt with an inline vim-style rename bar at the bottom of the sidebar — type ⌘⇧W or right-click → Rename to show the bar, Return to commit, Escape to cancel, ⌘V to paste, ⌦ to clear

  • Rename bar: 40-char max length with visual [max] nudge, ▏ slim cursor, 6px bottom margin, paste strips newlines

  • Added rename state management (startRenameMode, commitRename, cancelRenameMode) with global hs.eventtap key capture and a 0.5s blinking cursor

  • Added _saveCustomName helper shared by both dialog and bar rename paths

  • Added BAR_H = 18 constant reserving space for the rename bar at the bottom of the canvas

  • Rename mode auto-cancels on: mouse click, iTerm2 deactivation, screen change, or stop()

  • Fixed clicks being swallowed by sidebar canvas when a floating app (e.g. Calendar) overlapped it: disabled canvasMouseEvents (sidebar no longer captures clicks at the OS level)

  • Removed redundant mouseCallback — all click handling is now done by _leftClickTap / _rightClickTap eventtaps

  • Changed _leftClickTap guard from frontmostApplication() to walking hs.window.orderedWindows() — finds the first window actually covering the click coordinate, so clicks pass through to floating apps above the canvas at that exact point

  • Fixed bringWindowToFront not updating button colors immediately on keyboard-triggered focus switching (e.g. ⌥⌘↑/↓) — now paints colors synchronously, matching the click-path behavior

  • Fixed canvas visibility bug: hide sidebar when all iTerm windows are closed/minimized, re-show when windows return

  • Added windowMinimized / windowUnminimized subscriptions to trigger rebuilds on minimize/unminimize

  • Added Git workspace / worktree support — parses PR numbers from iTerm2 title bar (parsePRFromTitle), detects worktree context from git worktree list --porcelain (_gitWsNameCache), displays workspace-aware Line 3 with color-coded states, short-circuits gh CLI when PR is already in the title

  • Right-click handling moved to narrow _rightClickTap eventtap that only fires when iTerm2 is already frontmost

  • Added _leftClickTap eventtap for fast left-click interception (fires before canvas mouseCallback, paints colors + focuses inline in eventtap)

  • _leftClickTap swallows non-button sidebar clicks only when iTerm2 is frontmost — prevents click stealing from other apps overlapping the sidebar area

  • Stripped mouseCallback down to handleSidebarClick only — left-click handling done entirely by _leftClickTap

  • windowFocused subscriber now paints button colors immediately via elementAttribute (no debounce wait)

  • Removed _levelPollTimer (1s syncCanvasLevel poll) — now fully reactive via _appWatcher

  • bringWindowToFront calls app:activate() before win:raise()/win:focus() for faster cold-start focus switching

  • Removed buildSidebar call from bringWindowToFront (redundant — windowFocused handles rebuild)

  • Removed handleWindowMoveOrResize from windowFocused subscriber (focus changes don't move windows, was causing 300ms debounced stall / beach ball)

  • Added 100ms TTL cache to getITermWindows() to avoid repeated hs.window.allWindows() calls during rapid sequential operations

  • Replaced hs.chooser context menu with a canvas-based popup menu (showWindowMenu) — avoids the chooser UI and provides native-feeling hover highlight and click handling via hs.eventtap

  • Removed iTerm Settings menu item (the showPreferencesTip overlay and preferences_tip.png asset)

  • Added _menuCanvas/_menuEventTap/_menuKeyTap state fields for the new context menu; cleaned up in stop() and init()

  • Added iTerm2 configuration section to README documenting the "Reuse previous session's directory" setting

  • Added keyboard shortcut labels (⌘⇧W, ⌘⇧[, etc.) to each canvas context menu item, right-aligned in dimmer text