Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,10 @@ AI tool configuration:
- Claude Code session-id rotation tracking: `/clear`, `/resume`, and compaction rotate Claude's session id to a new jsonl file. Quil registers a `SessionStart` hook via `claude --settings '<inline JSON>'` at every spawn (never modifies `~/.claude/settings.json`) and passes `QUIL_PANE_ID=<paneID>` in the PTY env. The hook script — embedded in `internal/claudehook/scripts/` (sh + ps1) and written to `$QUIL_HOME/claudehook/` by `claudehook.EnsureScripts()` — reads Claude's stdin JSON, extracts `session_id`, and atomically writes `$QUIL_HOME/sessions/<paneID>.id`. On daemon restore, `resumeTemplateFor` (daemon.go) dispatches by plugin name to `claudeResumeTemplate`, which calls `readHookSessionIDFn` (defaults to `claudehook.ReadPersistedSessionID`) and prefers the hook-recorded id over the original preassigned id, with the existing `claudeSessionExistsFn` probe as the on-disk sanity gate. Both functions are swappable via package-level vars so `spawn_args_test.go` never touches real `~/.claude/` or `$QUIL_HOME/sessions/`. The probe path is built via `escapeClaudeCWD(cwd)` which replaces `/`, `\`, `:`, AND `_` with `-` to mirror Claude's per-project directory naming under `~/.claude/projects/`; the underscore handling is what fixes restore on macOS homes like `/Users/Foo_Bar`. `Daemon.Stop()` also calls `refreshPluginStateFromHooks()` before the final snapshot, copying the live hook-recorded id into `PluginState["session_id"]` for every claude-code and opencode pane so `workspace.json` carries the post-rotation id even if the hook file is later lost — empty/error reads preserve the existing value rather than clobbering with `""`
- OpenCode session-id rotation tracking: opencode mints a new session id on `/new`, fork, or compaction. Quil registers a JS plugin via `OPENCODE_CONFIG_CONTENT='{"plugin":["<abs path>"]}'` at every spawn (never writes to `~/.config/opencode/`) and passes `QUIL_PANE_ID=<paneID>` + `QUIL_HOME=<dir>` in the PTY env. The plugin — embedded in `internal/opencodehook/scripts/quil-session-tracker.js` and written to `$QUIL_HOME/opencodehook/` by `opencodehook.EnsureScripts()` — hooks opencode's `session.created` / `session.updated` / `session.idle` / `session.compacted` / `session.deleted` events, extracts `event.sessionID` / `event.session_id`, and atomically writes `$QUIL_HOME/sessions/opencode-<paneID>.id`. On daemon restore, `resumeTemplateFor` → `opencodeResumeTemplate` calls `readOpencodeSessionIDFn` (defaults to `opencodehook.ReadPersistedSessionID`) and promotes the resume args to `["--session", "{session_id}"]` when an id is present, falling back to `["--continue"]` otherwise. No session-exists probe in v1 — opencode handles stale ids itself; SQLite probe (`~/.local/share/opencode/opencode.db`) deferred to v2 if needed. `opencodeHookScriptStatFn` and `readOpencodeSessionIDFn` are swappable via package-level vars so tests never touch real filesystem state. Static templates (e.g. `--continue` with no `{placeholder}`) now pass through `resolveSpawnArgs`'s gate without requiring `PluginState` — see `templateHasPlaceholder` helper — so a fresh opencode pane that closed before its first session event still respawns with the fallback args
- Window size persistence: `~/.quil/window.json` stores cols, rows, pixel dimensions, and maximized state. Saved on TUI exit, restored on launch via platform-specific code (`cmd/quil/window_windows.go` uses Win32 `MoveWindow`/`ShowWindow`, `cmd/quil/window_unix.go` uses xterm resize sequence). Follows the same build-tag file-split pattern as `proc_unix.go`/`proc_windows.go`
- Pane cursor model: terminal/ssh panes get a software reverse-video overlay (`insertCursor`, gated by `isTerminalPane` in `internal/tui/pane.go`); every other plugin pane (claude-code, opencode, …) gets the REAL hardware cursor via Bubble Tea v2 `tea.View.Cursor`, positioned by `Model.paneHardwareCursor()` (model.go) at the active pane's VT cursor + pane rect offset (+1 for the border; rects collected with oy=1 below the tab bar; focus mode uses the full tab area). Returns nil (hardware cursor hidden) during dialogs/rename/selection/scrollback/notes-editor-focus or when the app sent DECTCEM hide. The old `\x1b[?25l` append in View() is gone — a nil `View.Cursor` is what hides the cursor now. Cell-loop renderers (`styledCellLine`, `styledCellLineWithSelection`, `insertCursor`) skip `Width==0` wide-char continuation cells — emitting a space there drifted scrollback/selection rendering +1 column per emoji/CJK glyph (`pane_widechar_test.go` guards this)
- Force redraw: `redraw` keybinding (default `alt+shift+l`) emits `tea.ClearScreen` + `tea.RequestWindowSize` — recovery hatch for accumulated cell-diff drift AND a missed `WindowSizeMsg` (conhost drops resize events on maximize/restore; ClearScreen alone would repaint the same stale-size frame). Listed in the F1 shortcuts dialog; exempt in notes mode via `notesKeyExempt`
- Window-size poll: `sizePollTick()` (1s, started in `Init`) fires `sizePollMsg` → `sizePollProbe` — automatic recovery for the missed-WindowSizeMsg class (resize → maximize leaves the TUI at a stale size). `sizePollProbe` first runs `fixupConsoleGrid()` (`internal/tui/consolefix_windows.go` + no-op `consolefix_other.go`): legacy conhost shrinks its screen buffer with the window but NEVER grows it back on enlarge/maximize — it paints dead space and `GetConsoleScreenBufferInfo` keeps reporting the stale grid, so polling alone can't see the real size. The fixup compares the window's client pixel area (GetClientRect ÷ GetConsoleFontSize cell metrics) against the current grid via the pure `consoleGridTarget()` (consolefix.go, unit-tested) and grows buffer+window via `SetConsoleScreenBufferSize`/`SetConsoleWindowInfo` (grow-only, never shrinks a buffer axis). Then returns `tea.RequestWindowSize()` (direct `term.GetSize` syscall — no ANSI query). The redraw key reuses `sizePollProbe`. The `WindowSizeMsg` handler no-ops when the size matches both applied and pending values, so idle polls are free (no log spam, no resize IPC)
- Pane spawn-size healing (Windows ConPTY drops resize events fired before the child reads console input — claude/node mid-boot — and never replays them): (1) daemon `resizeKick` re-applies `pane.Cols/Rows` with a 1-column jiggle on the pane's FIRST output (`streamPTYOutput`), (2) `cols`/`rows` are persisted in `workspace.json` and `respawnPanes` creates the ConPTY via `newRestoredPTY` → `apty.NewWithSize` so restored children boot at the real size, (3) TUI schedules `paneSettleRepaintMsg` ClearScreen ticks (300ms + 2s) on a pane's first live output (`PaneModel.liveOutputSeen`) to clean stale cells left by the kick-induced reflow (host font/width disagreement on Claude's logo glyphs)
- Text selection: `internal/tui/selection.go` — keyboard (Shift+Arrow, Ctrl+Shift+Arrow word jump, Ctrl+Alt+Shift+Arrow 3-word jump) and mouse (click+drag). Enter copies selection to clipboard via `internal/clipboard`. Shell cursor follows selection horizontally in real-time (same-line only; cross-line is visual-only to avoid triggering command history). Selection bounded by `lastContentLine()` — won't extend into empty terminal area
- Clipboard: `internal/clipboard/` — platform-native Read/Write. Windows: Win32 `GetClipboardData`/`SetClipboardData`. Unix: `pbpaste`/`pbcopy` (macOS), `xclip`/`xsel` (Linux). Paste (`Ctrl+V`) wraps content in bracketed paste sequences. Dialog paste sanitizes control characters. **Image paste proxy**: `clipboard.ReadImage()` reads `CF_DIBV5`/`CF_DIB` on Windows (Unix is a stub), `dib.go` parses the DIB into an `image.Image` (24bpp BI_RGB, 32bpp BI_RGB and BI_BITFIELDS, top-down + bottom-up, all-zero-alpha promotion). `pasteClipboard` falls through to image when text is empty: saves PNG to `config.PasteDir()` (`~/.quil/paste/quil-paste-<timestamp>.png`) and types the path into the PTY. Works around the upstream Claude Code Windows clipboard bug (anthropics/claude-code#32791). Paste keys: `Ctrl+V` (kb.Paste — eaten by Windows Terminal), `Ctrl+Alt+V` and `F8` are hardcoded aliases; `F8` is the recommended Windows trigger because it has no AltGr ambiguity

Expand Down
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ notification_focus = "f3" # jump focus to the sidebar (alt path when alt+n
mute_pane = "alt+m" # toggle notification mute for the active pane
go_back = "alt+backspace" # pane history back (after jumping via sidebar Enter)
notes_toggle = "alt+e" # toggle pane notes editor
redraw = "alt+shift+l" # force full screen repaint (clears rendering artifacts)
```

## `[daemon]`
Expand Down Expand Up @@ -179,6 +180,7 @@ Multiple modifiers stack with `+` (no spaces). Mouse buttons are not bindable he
| `mute_pane` | `alt+m` | Toggle notification mute on the active pane. Muted panes show `[muted]` on their border and never fire idle / bell / process-exit / hook events. Persisted in `workspace.json` so mute survives daemon restart. |
| `go_back` | `alt+backspace` | Pane history back — return to the pane you were on before the sidebar's `Enter` jump |
| `notes_toggle` | `alt+e` | Open / close the per-pane notes editor |
| `redraw` | `alt+shift+l` | Force a full screen repaint — clears rendering artifacts (scrambled or misplaced characters) without restarting the TUI |

## Per-plugin instances

Expand Down
1 change: 1 addition & 0 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ The active tab is prefixed with `* ` in the tab bar so it's visible even when [t
| `Alt+Shift+V` | Split top/bottom |
| `Alt+F2` / `Alt+Shift+R` | Rename active pane. `Alt+Shift+R` is a macOS-friendly fallback since `F2` is often eaten by the OS and `Option` is not always passed through as Meta. |
| `Ctrl+E` | Toggle focus mode (active pane full-screen) |
| `Alt+Shift+L` | Force a full screen redraw — clears rendering artifacts (scrambled/misplaced characters) without restarting. Mnemonic: `Ctrl+L` redraws a shell. |

## Pane navigation

Expand Down
16 changes: 8 additions & 8 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ go 1.25.0

require (
github.com/BurntSushi/toml v1.6.0
github.com/charmbracelet/x/ansi v0.11.6
github.com/charmbracelet/x/ansi v0.11.7
github.com/charmbracelet/x/conpty v0.2.0
github.com/charmbracelet/x/vt v0.0.0-20260309091332-e8ca31595cc4
github.com/creack/pty/v2 v2.0.1
Expand All @@ -21,21 +21,21 @@ require (
)

require (
charm.land/bubbletea/v2 v2.0.2
charm.land/bubbletea/v2 v2.0.7
charm.land/lipgloss/v2 v2.0.2
github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.42.0
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0
)
32 changes: 16 additions & 16 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0=
charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs=
charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs=
charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff h1:uY7A6hTokHPJBHfq7rj9Y/wm+IAjOghZTxKfVW6QLvw=
github.com/charmbracelet/ultraviolet v0.0.0-20260303162955-0b88c25f3fff/go.mod h1:E6/0abq9uG2SnM8IbLB9Y5SW09uIgfaFETk8aRzgXUQ=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 h1:FpSYhY28ucg9ZRr+2wj67FAQ0Ey5yiK0072PmRDJNek=
github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/conpty v0.2.0 h1:eKtA2hm34qNfgJCDp/M6Dc0gLy7e07YEK4qAdNGOvVY=
github.com/charmbracelet/x/conpty v0.2.0/go.mod h1:fexgUnVrZgw8scD49f6VSi0Ggj9GWYIrpedRthAwW/8=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
Expand Down Expand Up @@ -40,10 +40,10 @@ github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbc
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc=
github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
Expand All @@ -62,9 +62,9 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
Loading
Loading