From da5e762d042566a3e13fbe26f90e3c711bcdfd2d Mon Sep 17 00:00:00 2001 From: Artjoms Stukans Date: Mon, 8 Jun 2026 16:31:45 +0200 Subject: [PATCH 1/6] chore(deps): bump bubbletea v2 to v2.0.7 Pulls in renderer/cursor patches plus matched ultraviolet, x/ansi, colorprofile, and go-runewidth updates. Co-Authored-By: Claude Opus 4.8 (1M context) --- go.mod | 16 ++++++++-------- go.sum | 32 ++++++++++++++++---------------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/go.mod b/go.mod index 41b818a..614b31f 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 ) diff --git a/go.sum b/go.sum index ac8c4d4..6abeced 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -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= @@ -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= From 114247eb59ab51bd1d3522e8b09a430c912559dd Mon Sep 17 00:00:00 2001 From: Artjoms Stukans Date: Mon, 8 Jun 2026 16:31:54 +0200 Subject: [PATCH 2/6] fix(daemon): boot panes at correct size on Windows ConPTY Windows ConPTY drops resize events fired before the child reads console input (claude/node mid-boot) and never replays them, leaving the child at the spawn-time 80x24. Two fixes: - resizeKick: re-apply the pane size with a 1-column jiggle on the pane's first output, when the child's console is provably wired up. - Persist cols/rows in workspace.json and respawn restored panes via NewWithSize so they start at the real dimensions. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/daemon/daemon.go | 73 ++++++++++++++++++++++++++-- internal/daemon/resize_kick_test.go | 44 +++++++++++++++++ internal/daemon/restore_size_test.go | 56 +++++++++++++++++++++ internal/daemon/spawn_pane_test.go | 22 +++++---- 4 files changed, 182 insertions(+), 13 deletions(-) create mode 100644 internal/daemon/resize_kick_test.go create mode 100644 internal/daemon/restore_size_test.go diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index e4b3503..7bc0f68 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -452,12 +452,20 @@ func (d *Daemon) restoreWorkspace() error { } } + // Restore last known size (JSON numbers decode as float64; + // absent on pre-size snapshots → stays 0 and respawn falls + // back to the default PTY dimensions). + cols, _ := paneData["cols"].(float64) + rows, _ := paneData["rows"].(float64) + pane := &Pane{ ID: paneID, TabID: tabID, CWD: cwd, Name: name, Type: paneType, + Cols: int(cols), + Rows: int(rows), PluginState: pluginState, InstanceName: instanceName, InstanceArgs: instanceArgs, @@ -520,7 +528,7 @@ func (d *Daemon) respawnPanes() { continue // Already has a PTY } - ptySession := apty.New() + ptySession := newRestoredPTY(pane) if pane.CWD != "" { if info, err := os.Stat(pane.CWD); err != nil || !info.IsDir() { log.Printf("pane %s: saved cwd %q gone, using default", pane.ID, pane.CWD) @@ -531,17 +539,29 @@ func (d *Daemon) respawnPanes() { if err := d.spawnPane(pane, ptySession, true); err != nil { log.Printf("respawn pane %s (type=%s): %v — falling back to terminal", pane.ID, pane.Type, err) pane.Type = "terminal" - ptySession2 := apty.New() + ptySession2 := newRestoredPTY(pane) if err := d.spawnPane(pane, ptySession2, false); err != nil { log.Printf("fallback shell for pane %s also failed: %v", pane.ID, err) } } else { - log.Printf("respawned pane %s (type=%s, cwd=%s)", pane.ID, pane.Type, pane.CWD) + log.Printf("respawned pane %s (type=%s, cwd=%s, size=%dx%d)", pane.ID, pane.Type, pane.CWD, pane.Cols, pane.Rows) } } } } +// newRestoredPTY creates the PTY for a restored pane at its persisted size, +// so the child boots at the real dimensions instead of the 80x24 default +// (which interactive TUIs latch onto when the first resize event is lost — +// see resizeKick). Falls back to the default constructor for pre-size +// snapshots where Cols/Rows were never recorded. +func newRestoredPTY(pane *Pane) apty.Session { + if pane.Cols > 0 && pane.Rows > 0 { + return apty.NewWithSize(pane.Cols, pane.Rows) + } + return apty.New() +} + func (d *Daemon) handleMessage(conn *ipc.Conn, msg *ipc.Message) { // Log all IPC messages except high-frequency ones (input, resize, layout) switch msg.Type { @@ -978,7 +998,9 @@ func (d *Daemon) handleResizePane(msg *ipc.Message) { if pane == nil || pane.PTY == nil { return } - pane.PTY.Resize(payload.Rows, payload.Cols) + if err := pane.PTY.Resize(payload.Rows, payload.Cols); err != nil { + log.Printf("resize pane %s to %dx%d: %v", payload.PaneID, payload.Cols, payload.Rows, err) + } pane.Cols = int(payload.Cols) pane.Rows = int(payload.Rows) } @@ -1029,9 +1051,35 @@ func (d *Daemon) handleUpdateLayout(msg *ipc.Message) { d.requestSnapshot() } +// resizeKick re-applies a pane's last known size to its PTY, with a +// 1-column jiggle so the child receives a real size-change event. +// +// Windows ConPTY delivers resizes to the child as WINDOW_BUFFER_SIZE_EVENTs +// in its console input queue; events fired before the child starts reading +// input (claude/node mid-boot) are dropped and never replayed. The TUI's +// initial resize_pane lands ~25 ms after spawn and can be lost that way, +// leaving the child rendering at the spawn-time 80x24 until the next window +// resize. Called on the pane's first output — the child is alive and its +// console is wired up by then. No-op while the size is still unknown +// (resize_pane not yet received). +func resizeKick(pty apty.Session, cols, rows int) { + if cols <= 0 || rows <= 0 { + return + } + if cols > 1 { + if err := pty.Resize(uint16(rows), uint16(cols-1)); err != nil { + log.Printf("resize kick (jiggle): %v", err) + } + } + if err := pty.Resize(uint16(rows), uint16(cols)); err != nil { + log.Printf("resize kick: %v", err) + } +} + func (d *Daemon) streamPTYOutput(paneID string, pty apty.Session) { readBuf := make([]byte, 32*1024) dataCh := make(chan []byte, 64) + firstChunk := true // Reader goroutine: continuously reads from PTY go func() { @@ -1100,6 +1148,15 @@ func (d *Daemon) streamPTYOutput(paneID string, pty apty.Session) { } return } + if firstChunk { + firstChunk = false + // First output proves the child's console is wired up — + // re-apply the size in case the initial resize event was + // dropped during boot (see resizeKick). + if pane := d.session.Pane(paneID); pane != nil { + resizeKick(pty, pane.Cols, pane.Rows) + } + } acc = append(acc, chunk...) if !flushTimer.Stop() { select { @@ -1282,6 +1339,14 @@ func (d *Daemon) workspaceStateFromSnapshot(activeTab string, tabs []*Tab, panes if len(pane.InstanceArgs) > 0 { paneData["instance_args"] = pane.InstanceArgs } + // Persist last known size so respawnPanes can recreate the + // ConPTY at the right dimensions instead of the 80x24 default + // (children that boot before the first resize event would + // otherwise render an 80-column UI — see resizeKick). + if pane.Cols > 0 && pane.Rows > 0 { + paneData["cols"] = pane.Cols + paneData["rows"] = pane.Rows + } paneList = append(paneList, paneData) } } diff --git a/internal/daemon/resize_kick_test.go b/internal/daemon/resize_kick_test.go new file mode 100644 index 0000000..5c5d40a --- /dev/null +++ b/internal/daemon/resize_kick_test.go @@ -0,0 +1,44 @@ +package daemon + +import ( + "reflect" + "testing" +) + +// Windows ConPTY delivers resizes as WINDOW_BUFFER_SIZE_EVENTs in the +// child's console input queue. Events fired before the child reads input +// (claude/node mid-boot) are dropped and never replayed — the TUI's +// initial resize_pane lands ~25 ms after spawn and can be lost, leaving +// the child at the spawn-time 80x24. resizeKick re-applies the size on +// the pane's first output, with a 1-column jiggle so a size-change event +// fires even if the buffer dimensions already match. + +func TestResizeKick_ReappliesSizeWithJiggle(t *testing.T) { + fake := &fakeSession{} + resizeKick(fake, 238, 45) + + want := [][2]uint16{{45, 237}, {45, 238}} + if !reflect.DeepEqual(fake.resizes, want) { + t.Errorf("resizes = %v, want %v (jiggle then real size)", fake.resizes, want) + } +} + +func TestResizeKick_UnknownSizeIsNoOp(t *testing.T) { + for _, dims := range [][2]int{{0, 0}, {0, 45}, {238, 0}} { + fake := &fakeSession{} + resizeKick(fake, dims[0], dims[1]) + if len(fake.resizes) != 0 { + t.Errorf("cols=%d rows=%d: expected no resizes, got %v", dims[0], dims[1], fake.resizes) + } + } +} + +func TestResizeKick_SingleColumnSkipsJiggle(t *testing.T) { + fake := &fakeSession{} + resizeKick(fake, 1, 45) + + want := [][2]uint16{{45, 1}} + if !reflect.DeepEqual(fake.resizes, want) { + t.Errorf("resizes = %v, want %v (no zero-width jiggle)", fake.resizes, want) + } +} diff --git a/internal/daemon/restore_size_test.go b/internal/daemon/restore_size_test.go new file mode 100644 index 0000000..47dfc9f --- /dev/null +++ b/internal/daemon/restore_size_test.go @@ -0,0 +1,56 @@ +package daemon + +import ( + "testing" + + "github.com/artyomsv/quil/internal/config" +) + +// TestSnapshotRestore_PaneSizeRoundTrip verifies pane dimensions survive the +// workspace snapshot → restore cycle. Without persisted cols/rows the daemon +// respawns every restored pane's ConPTY at the 80x24 default and the child +// boots at the wrong size — interactive TUIs (claude-code) then render an +// 80-column UI inside a full-width pane until the next window resize. +func TestSnapshotRestore_PaneSizeRoundTrip(t *testing.T) { + tmp := t.TempDir() + t.Setenv("QUIL_HOME", tmp) + + d := New(config.Default()) + tab := d.session.CreateTab("Shell") + pane, err := d.session.CreatePane(tab.ID, "/tmp") + if err != nil { + t.Fatalf("CreatePane: %v", err) + } + pane.Cols = 238 + pane.Rows = 45 + + // A second pane with no size recorded yet — must restore at 0/0 so the + // respawn path falls back to the default constructor. + pane2, err := d.session.CreatePane(tab.ID, "/tmp") + if err != nil { + t.Fatalf("CreatePane: %v", err) + } + + d.snapshot() + + d2 := New(config.Default()) + if err := d2.restoreWorkspace(); err != nil { + t.Fatalf("restoreWorkspace: %v", err) + } + + restored := d2.session.Pane(pane.ID) + if restored == nil { + t.Fatalf("pane %s not restored", pane.ID) + } + if restored.Cols != 238 || restored.Rows != 45 { + t.Errorf("restored size = %dx%d, want 238x45", restored.Cols, restored.Rows) + } + + restored2 := d2.session.Pane(pane2.ID) + if restored2 == nil { + t.Fatalf("pane %s not restored", pane2.ID) + } + if restored2.Cols != 0 || restored2.Rows != 0 { + t.Errorf("size-less pane restored as %dx%d, want 0x0", restored2.Cols, restored2.Rows) + } +} diff --git a/internal/daemon/spawn_pane_test.go b/internal/daemon/spawn_pane_test.go index 74f635c..6ad71ff 100644 --- a/internal/daemon/spawn_pane_test.go +++ b/internal/daemon/spawn_pane_test.go @@ -10,14 +10,15 @@ import ( // fakeSession records PTY method calls without spawning a real process. // Used to verify that spawnPane applies CWD before Start. type fakeSession struct { - cwd string - env []string - started bool - startCmd string - startArgs []string - cwdSetAt int // call ordinal when SetCWD was invoked - startedAt int // call ordinal when Start was invoked - callSeq int + cwd string + env []string + started bool + startCmd string + startArgs []string + cwdSetAt int // call ordinal when SetCWD was invoked + startedAt int // call ordinal when Start was invoked + callSeq int + resizes [][2]uint16 // recorded (rows, cols) Resize calls } func (f *fakeSession) SetCWD(dir string) { @@ -41,7 +42,10 @@ func (f *fakeSession) Start(cmd string, args ...string) error { func (f *fakeSession) Read(buf []byte) (int, error) { return 0, fmt.Errorf("not implemented") } func (f *fakeSession) Write(data []byte) (int, error) { return 0, fmt.Errorf("not implemented") } -func (f *fakeSession) Resize(rows, cols uint16) error { return nil } +func (f *fakeSession) Resize(rows, cols uint16) error { + f.resizes = append(f.resizes, [2]uint16{rows, cols}) + return nil +} func (f *fakeSession) Close() error { return nil } func (f *fakeSession) Pid() int { return 0 } func (f *fakeSession) WaitExit() int { return 0 } From 5771468effa1e2416db3ef8fe3f236968f07eddb Mon Sep 17 00:00:00 2001 From: Artjoms Stukans Date: Mon, 8 Jun 2026 16:32:03 +0200 Subject: [PATCH 3/6] fix(tui): software caret for all panes, fix wide-char column drift - Draw the reverse-video caret for every pane type (claude-code, opencode, ...) instead of the Bubble Tea hardware cursor. Per-frame repositioning of the real cursor desynced the diff writer on Windows and dropped the first typed character a column to the right. - Skip Width==0 wide-char continuation cells in the cell renderers so emoji/CJK glyphs no longer drift following columns one cell right. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/tui/model.go | 83 ++++++++++++++++++++++-- internal/tui/model_cursor_test.go | 101 +++++++++++++++++++++++++++++ internal/tui/pane.go | 66 ++++++++++++------- internal/tui/pane_widechar_test.go | 70 ++++++++++++++++++++ 4 files changed, 292 insertions(+), 28 deletions(-) create mode 100644 internal/tui/model_cursor_test.go create mode 100644 internal/tui/pane_widechar_test.go diff --git a/internal/tui/model.go b/internal/tui/model.go index ac5bc32..68c1688 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -61,6 +61,36 @@ type PaneInfo struct { Type string } +// paneSettleRepaintMsg fires shortly after a pane's first live output and +// forces a full repaint. The child reflows its UI right after the daemon's +// spawn-time resize kick; when the host terminal disagrees with the renderer +// about glyph widths (Claude Code's logo on Windows fonts), that redraw +// leaves stale cells only a full repaint clears. +type paneSettleRepaintMsg struct{} + +// sizePollMsg fires on a fixed interval and re-queries the terminal size. +// conhost coalesces/drops WINDOW_BUFFER_SIZE_EVENTs during rapid resize → +// maximize, so the final WindowSizeMsg can simply never arrive; the poll +// closes the gap. Unchanged sizes no-op in the WindowSizeMsg handler. +type sizePollMsg struct{} + +// sizePollInterval balances recovery latency against poll cost (one +// terminal-size query per tick — a single syscall). +const sizePollInterval = 1 * time.Second + +func sizePollTick() tea.Cmd { + return tea.Tick(sizePollInterval, func(time.Time) tea.Msg { return sizePollMsg{} }) +} + +// sizePollProbe runs the conhost grid fixup (no-op off Windows / when the +// grid already fits) and then asks Bubble Tea to re-query the terminal +// size. One command instead of a batch so the fixup is guaranteed to run +// before the query. +func sizePollProbe() tea.Msg { + fixupConsoleGrid() + return tea.RequestWindowSize() +} + // resizeTickMsg fires after the debounce delay; seq tracks freshness. type resizeTickMsg struct { seq int @@ -315,7 +345,7 @@ func (m Model) ConfigChanged() bool { return m.configChanged } func (m Model) Init() tea.Cmd { log.Print("TUI Init — starting listener") startUpdateWatchdog(defaultWatchdogConfig()) - return tea.Batch(m.listenForMessages(), memoryTickCmd()) + return tea.Batch(m.listenForMessages(), memoryTickCmd(), sizePollTick()) } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -327,6 +357,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { }() switch msg := msg.(type) { case tea.WindowSizeMsg: + // Poll echo: size matches both the applied and any pending value — + // nothing to do. Keeps the 1s size poll free when idle. + if m.attached && msg.Width == m.width && msg.Height == m.height && + msg.Width == m.pendingWidth && msg.Height == m.pendingHeight { + return m, nil + } log.Printf("WindowSizeMsg: %dx%d", msg.Width, msg.Height) m.pendingWidth = msg.Width m.pendingHeight = msg.Height @@ -360,6 +396,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return resizeTickMsg{seq: seq} }) + case sizePollMsg: + return m, tea.Batch(sizePollProbe, sizePollTick()) + case resizeTickMsg: if msg.seq != m.resizeSeq { return m, nil // stale tick, newer resize pending @@ -608,6 +647,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, m.listenForMessages() + case paneSettleRepaintMsg: + return m, tea.ClearScreen + case spinnerTickMsg: // Advance spinner frame for the resuming/preparing pane for _, tab := range m.tabs { @@ -1131,6 +1173,8 @@ func (m Model) notesKeyExempt(key string) bool { kb.NewTab, kb.RenameTab, kb.RenamePane, kb.CycleTabColor, // Other modes. kb.FocusPane, + // Force repaint — view-level, harmless while the editor is open. + kb.Redraw, // Notification center. kb.NotificationToggle, kb.NotificationFocus, kb.GoBack, // Tools and dialogs. @@ -1257,11 +1301,15 @@ func (m Model) View() tea.View { content = lipgloss.JoinVertical(lipgloss.Left, sections...) } - // Hide the terminal cursor — we render our own via insertCursor() - content += "\x1b[?25l" v := tea.NewView(content) v.AltScreen = true v.MouseMode = tea.MouseModeCellMotion + // v.Cursor stays nil — the hardware cursor is never shown. Every pane + // type gets a software reverse-video caret drawn into the frame by + // renderContent/insertCursor instead. Positioning the real cursor via + // tea.View.Cursor was tried and reverted: the per-frame repositioning + // desynced Bubble Tea's diff writer on Windows and the first typed + // character landed one cell off ("Test" → "T est"). return v } @@ -1489,6 +1537,15 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case kbMatches(key, kb.CycleTabColor): return m, m.cycleTabColor() + case kbMatches(key, kb.Redraw): + // Recovery hatch for rendering artifacts: cell-diff drift (width + // disagreements with the host terminal) accumulates until a full + // repaint. sizePollProbe additionally recovers from a stale size — + // it grows the conhost grid if the window outgrew it (legacy + // conhost never grows it back itself) and re-queries the size. + // ClearScreen alone would repaint the same stale-size frame. + return m, tea.Batch(tea.ClearScreen, sizePollProbe) + case kbMatches(key, kb.ScrollPageUp): if tab := m.activeTabModel(); tab != nil { if pane := tab.ActivePaneModel(); pane != nil { @@ -1734,10 +1791,26 @@ func (m *Model) handlePaneOutput(msg PaneOutputMsg) tea.Cmd { appendStart := time.Now() leaf.Pane.AppendOutput(msg.Data) m.perfStats.recordPaneOutput(len(msg.Data), time.Since(appendStart)) + + var cmds []tea.Cmd + if !msg.Ghost && !leaf.Pane.liveOutputSeen { + leaf.Pane.liveOutputSeen = true + // First live output: the child reflows right after the + // daemon's resize kick lands. Repaint quickly to clean + // boot-frame leftovers, and once more after the UI settles + // (see paneSettleRepaintMsg). + cmds = append(cmds, + tea.Tick(300*time.Millisecond, func(time.Time) tea.Msg { return paneSettleRepaintMsg{} }), + tea.Tick(2*time.Second, func(time.Time) tea.Msg { return paneSettleRepaintMsg{} }), + ) + } if leaf.Pane.CWD != oldCWD && leaf.Pane.CWD != "" { - return m.updatePaneCWD(msg.PaneID, leaf.Pane.CWD) + cmds = append(cmds, m.updatePaneCWD(msg.PaneID, leaf.Pane.CWD)) } - return nil + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) } } return nil diff --git a/internal/tui/model_cursor_test.go b/internal/tui/model_cursor_test.go new file mode 100644 index 0000000..e4db7cc --- /dev/null +++ b/internal/tui/model_cursor_test.go @@ -0,0 +1,101 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/artyomsv/quil/internal/config" +) + +// Interactive plugin panes (claude-code, opencode, ...) position the VT +// cursor at their input caret. Quil renders it as a software reverse-video +// overlay (insertCursor) — the same mechanism terminal panes use — instead +// of the real hardware cursor: repositioning the hardware cursor every +// frame desynced Bubble Tea's diff writer on Windows, landing the first +// typed character one cell off ("Test" rendered as "T est"). + +func cursorTestModel(paneType string) (Model, *PaneModel) { + pane := NewPaneModel("p1", testRingBufSize) + pane.Type = paneType + pane.Active = true + tab := NewTabModel("t1", "Test") + tab.Root = &LayoutNode{Pane: pane} + tab.ActivePane = "p1" + m := Model{ + cfg: config.Default(), + tabs: []*TabModel{tab}, + activeTab: 0, + width: 80, + height: 24, + notifications: NewNotificationCenter(30, 50), + } + m.resizeTabs() + return m, pane +} + +func TestRenderContent_InteractivePane_OverlaysSoftwareCursor(t *testing.T) { + for _, typ := range []string{"claude-code", "opencode", "terminal"} { + _, pane := cursorTestModel(typ) + pane.AppendOutput([]byte("abc")) // VT cursor at (3, 0) + + first, _, _ := strings.Cut(pane.renderContent(nil), "\n") + if !strings.Contains(first, "\x1b[7m") { + t.Errorf("type %q: cursor line %q has no reverse-video overlay", typ, first) + } + } +} + +func TestRenderContent_NoOverlayWhenHidden(t *testing.T) { + t.Run("DECTCEM hidden", func(t *testing.T) { + _, pane := cursorTestModel("claude-code") + pane.AppendOutput([]byte("abc\x1b[?25l")) + first, _, _ := strings.Cut(pane.renderContent(nil), "\n") + if strings.Contains(first, "\x1b[7m") { + t.Error("overlay rendered although the app hid the cursor") + } + }) + + t.Run("inactive pane", func(t *testing.T) { + _, pane := cursorTestModel("claude-code") + pane.Active = false + pane.AppendOutput([]byte("abc")) + first, _, _ := strings.Cut(pane.renderContent(nil), "\n") + if strings.Contains(first, "\x1b[7m") { + t.Error("overlay rendered on an inactive pane") + } + }) +} + +// The caret line is rebuilt cell-by-cell by insertCursor. Styles must reset +// between cells exactly like styledCellLine does — without the reset, a +// styled run (claude-code's dim hint text) bleeds into the unstyled cells +// after it, painting typed characters in colors that can vanish against the +// background. +func TestInsertCursor_NoStyleBleedAcrossCells(t *testing.T) { + _, pane := cursorTestModel("claude-code") + // Red 'R' followed by explicitly reset 'N', cursor lands at col 2. + pane.AppendOutput([]byte("\x1b[31mR\x1b[0mN")) + + first, _, _ := strings.Cut(pane.renderContent(nil), "\n") + + // 'N' must not inherit the red style: between the styled 'R' and the + // unstyled 'N' there has to be an SGR reset. + rIdx := strings.IndexByte(first, 'R') + nIdx := strings.IndexByte(first, 'N') + if rIdx < 0 || nIdx < 0 || nIdx < rIdx { + t.Fatalf("unexpected caret line %q", first) + } + between := first[rIdx:nIdx] + if !strings.Contains(between, "\x1b[m") && !strings.Contains(between, "\x1b[0m") { + t.Errorf("style bleeds from styled cell into unstyled cell: %q", first) + } +} + +func TestView_NeverSetsHardwareCursor(t *testing.T) { + m, pane := cursorTestModel("claude-code") + pane.AppendOutput([]byte("abc")) + + if v := m.View(); v.Cursor != nil { + t.Errorf("View sets hardware cursor at (%d,%d) — must stay nil (software overlay only)", v.Cursor.X, v.Cursor.Y) + } +} diff --git a/internal/tui/pane.go b/internal/tui/pane.go index eb91947..ed7b684 100644 --- a/internal/tui/pane.go +++ b/internal/tui/pane.go @@ -22,25 +22,26 @@ import ( var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} type PaneModel struct { - ID string - Type string // plugin type ("terminal", "claude-code", etc.) - Name string // user-given name (empty if not set) - CWD string // current working directory from daemon - vt *vt.SafeEmulator - Width int - Height int - Active bool - scrollBack int - rawBuf *ringbuf.RingBuffer // raw PTY bytes for resize replay - cursorVisible bool // tracks shell's DECTCEM state - ghost bool // true while showing restored content - resuming bool // true while waiting for first live output after restore - preparing bool // true for newly created panes (not restored) - resumeStart time.Time // when resuming/preparing started (minimum display duration) - spinnerFrame int // current frame index in spinnerFrames - activeSel *Selection // set by Model before View() for selection rendering - focusMode bool // set by Model before View() when in focus mode - mcpHighlight bool // set by Model before View() when MCP is interacting + ID string + Type string // plugin type ("terminal", "claude-code", etc.) + Name string // user-given name (empty if not set) + CWD string // current working directory from daemon + vt *vt.SafeEmulator + Width int + Height int + Active bool + scrollBack int + rawBuf *ringbuf.RingBuffer // raw PTY bytes for resize replay + cursorVisible bool // tracks shell's DECTCEM state + ghost bool // true while showing restored content + resuming bool // true while waiting for first live output after restore + preparing bool // true for newly created panes (not restored) + resumeStart time.Time // when resuming/preparing started (minimum display duration) + spinnerFrame int // current frame index in spinnerFrames + activeSel *Selection // set by Model before View() for selection rendering + focusMode bool // set by Model before View() when in focus mode + mcpHighlight bool // set by Model before View() when MCP is interacting + liveOutputSeen bool // first live (non-ghost) output received — settle repaints scheduled } // newVTEmulator builds a SafeEmulator for this pane and starts a goroutine @@ -345,10 +346,14 @@ func (p *PaneModel) renderContent(sel *Selection) string { if p.scrollBack == 0 { // Live view — use Render() for full color support content := p.vt.Render() - // Only overlay cursor for terminal panes — TUI apps (Claude Code etc.) - // render their own cursor. - isTerminal := p.Type == "" || p.Type == "terminal" || p.Type == "ssh" - if p.Active && p.cursorVisible && isTerminal { + // Software reverse-video caret at the VT cursor for every pane + // type. Interactive apps (claude-code, opencode) position the VT + // cursor at their input caret exactly like shells do. A real + // hardware cursor (tea.View.Cursor) was tried and reverted: + // repositioning it every frame desynced Bubble Tea's diff writer + // on Windows — the first typed character after a fresh input line + // landed one cell off ("Test" → "T est"). + if p.Active && p.cursorVisible { content = p.insertCursor(content) } return content @@ -457,6 +462,11 @@ func (p *PaneModel) styledCellLineWithSelection(getCell func(x int) *uv.Cell, wi for x := 0; x < width; x++ { cell := getCell(x) + // Wide-char continuation cell — the lead cell already spans this + // column; emitting anything here drifts the rest of the row right. + if cell != nil && cell.Width == 0 { + continue + } ch := " " styled := false var sgr string @@ -532,6 +542,11 @@ func (p *PaneModel) styledCellLine(getCell func(x int) *uv.Cell, width int) stri for x := 0; x < width; x++ { cell := getCell(x) + // Wide-char continuation cell — the lead cell already spans this + // column; emitting anything here drifts the rest of the row right. + if cell != nil && cell.Width == 0 { + continue + } ch := " " styled := false var sgr string @@ -593,6 +608,11 @@ func (p *PaneModel) insertCursor(content string) string { for x := 0; x < w; x++ { cell := p.vt.CellAt(x, pos.Y) + // Wide-char continuation cell — the lead cell already spans this + // column (cursor landing on one is a degenerate case; skip it too). + if cell != nil && cell.Width == 0 { + continue + } ch := " " if cell != nil && cell.Content != "" { ch = cell.Content diff --git a/internal/tui/pane_widechar_test.go b/internal/tui/pane_widechar_test.go new file mode 100644 index 0000000..7b50cc6 --- /dev/null +++ b/internal/tui/pane_widechar_test.go @@ -0,0 +1,70 @@ +package tui + +import ( + "strings" + "testing" + + uv "github.com/charmbracelet/ultraviolet" + + "github.com/charmbracelet/x/ansi" +) + +// Wide glyphs (CJK, emoji) occupy two columns: the lead cell carries the +// rune with Width=2, the following continuation cell has Width=0 and empty +// content. The cell-loop renderers must skip continuation cells — emitting +// a space for them drifts everything after the glyph one column right. + +func wideCharPane(t *testing.T) *PaneModel { + t.Helper() + pane := NewPaneModel("wide", testRingBufSize) + pane.ResizeVT(20, 4) + pane.AppendOutput([]byte("你X")) // 你 at cols 0-1, X at col 2 + return pane +} + +func TestStyledCellLine_WideChar_NoPhantomSpace(t *testing.T) { + pane := wideCharPane(t) + + line := pane.styledCellLine(func(x int) *uv.Cell { + return pane.vt.CellAt(x, 0) + }, pane.vt.Width()) + + if got := ansi.StringWidth(line); got != 3 { + t.Errorf("rendered line %q has display width %d, want 3 (你=2 + X=1)", line, got) + } +} + +func TestStyledCellLineWithSelection_WideChar_NoPhantomSpace(t *testing.T) { + pane := wideCharPane(t) + + getCell := func(x int) *uv.Cell { return pane.vt.CellAt(x, 0) } + + t.Run("no selection on row", func(t *testing.T) { + line := pane.styledCellLineWithSelection(getCell, pane.vt.Width(), -1, -1) + if got := ansi.StringWidth(line); got != 3 { + t.Errorf("rendered line %q has display width %d, want 3", line, got) + } + }) + + t.Run("selection covering wide char", func(t *testing.T) { + line := pane.styledCellLineWithSelection(getCell, pane.vt.Width(), 0, 2) + if got := ansi.StringWidth(line); got != 3 { + t.Errorf("rendered line %q has display width %d, want 3", line, got) + } + }) +} + +func TestInsertCursor_WideChar_NoPhantomSpace(t *testing.T) { + pane := wideCharPane(t) + + // insertCursor rebuilds the cursor's row cell-by-cell across the full + // pane width; the phantom continuation space pushes it to width+1. + content := pane.vt.Render() + out := pane.insertCursor(content) + + firstLine, _, _ := strings.Cut(out, "\n") + w := pane.vt.Width() + if got := ansi.StringWidth(firstLine); got != w { + t.Errorf("cursor line has display width %d, want %d (pane width)", got, w) + } +} From 63ffa86377d7bfb681b639d7b336899938d5e346 Mon Sep 17 00:00:00 2001 From: Artjoms Stukans Date: Mon, 8 Jun 2026 16:32:29 +0200 Subject: [PATCH 4/6] feat(tui): window-size recovery and force-redraw for Windows console - redraw keybinding (default alt+shift+l): ClearScreen + re-query size, a recovery hatch for cell-diff drift and missed WindowSizeMsg. - 1s size poll plus a legacy-conhost grid fixup (CONOUT$ active buffer): conhost shrinks its screen buffer with the window but never grows it back on maximize, leaving the grid stuck small. Grow-only, no-op in Windows Terminal. - Settle repaints after a pane's first live output to clear boot-frame artifacts. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/config/config.go | 8 ++ internal/tui/consolefix.go | 32 +++++ internal/tui/consolefix_other.go | 7 ++ internal/tui/consolefix_test.go | 71 +++++++++++ internal/tui/consolefix_windows.go | 179 ++++++++++++++++++++++++++++ internal/tui/dialog.go | 1 + internal/tui/model_redraw_test.go | 57 +++++++++ internal/tui/model_settle_test.go | 51 ++++++++ internal/tui/model_sizepoll_test.go | 78 ++++++++++++ 9 files changed, 484 insertions(+) create mode 100644 internal/tui/consolefix.go create mode 100644 internal/tui/consolefix_other.go create mode 100644 internal/tui/consolefix_test.go create mode 100644 internal/tui/consolefix_windows.go create mode 100644 internal/tui/model_redraw_test.go create mode 100644 internal/tui/model_settle_test.go create mode 100644 internal/tui/model_sizepoll_test.go diff --git a/internal/config/config.go b/internal/config/config.go index d9c0be3..1b8a082 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -107,6 +107,11 @@ type KeybindingsConfig struct { NotificationFocus string `toml:"notification_focus"` GoBack string `toml:"go_back"` NotesToggle string `toml:"notes_toggle"` + // Redraw forces a full screen repaint (tea.ClearScreen). Recovery key + // for rendering artifacts left behind by cell-diff drift — width + // disagreements between Quil and the host terminal (most common on + // Windows) scramble characters until something repaints everything. + Redraw string `toml:"redraw"` } func Default() Config { @@ -174,6 +179,9 @@ func Default() Config { NotificationFocus: "f3", GoBack: "alt+backspace", NotesToggle: "alt+e", + // Mnemonic: Ctrl+L clears/redraws a shell; the Alt+Shift layer + // keeps plain Ctrl+L flowing to the PTY. + Redraw: "alt+shift+l", }, } } diff --git a/internal/tui/consolefix.go b/internal/tui/consolefix.go new file mode 100644 index 0000000..ea05b74 --- /dev/null +++ b/internal/tui/consolefix.go @@ -0,0 +1,32 @@ +package tui + +// consoleGridTarget computes the cell grid that fits a console window's +// client area, returning grow=true when it exceeds the current grid on +// either axis. +// +// Legacy conhost shrinks its screen buffer together with the window, but +// never grows it back when the window is enlarged or maximized — it paints +// dead space instead and GetConsoleScreenBufferInfo keeps reporting the +// stale grid, so polling alone can't recover. Only growth needs the Win32 +// fixup (fixupConsoleGrid); shrinks are handled natively. +func consoleGridTarget(clientW, clientH, fontW, fontH, curCols, curRows int) (cols, rows int, grow bool) { + if clientW <= 0 || clientH <= 0 || fontW <= 0 || fontH <= 0 { + return 0, 0, false + } + cols = clientW / fontW + rows = clientH / fontH + if cols < 1 || rows < 1 { + return 0, 0, false + } + if cols <= curCols && rows <= curRows { + return 0, 0, false + } + // Grow only — never shrink an axis as a side effect of the fixup. + if cols < curCols { + cols = curCols + } + if rows < curRows { + rows = curRows + } + return cols, rows, true +} diff --git a/internal/tui/consolefix_other.go b/internal/tui/consolefix_other.go new file mode 100644 index 0000000..90235f3 --- /dev/null +++ b/internal/tui/consolefix_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package tui + +// fixupConsoleGrid is a no-op outside Windows — only legacy conhost fails +// to grow its cell grid when the window is enlarged (see consolefix.go). +func fixupConsoleGrid() {} diff --git a/internal/tui/consolefix_test.go b/internal/tui/consolefix_test.go new file mode 100644 index 0000000..f714879 --- /dev/null +++ b/internal/tui/consolefix_test.go @@ -0,0 +1,71 @@ +package tui + +import "testing" + +// Legacy conhost shrinks its screen buffer when the window shrinks, but +// NEVER grows it back when the window is enlarged or maximized — it paints +// dead space instead, and GetConsoleScreenBufferInfo keeps reporting the +// stale grid (observed: window 1936x1056 px, grid stuck at 117x30). +// consoleGridTarget decides when the Win32 fixup must grow the grid. + +func TestConsoleGridTarget(t *testing.T) { + tests := []struct { + name string + clientW, clientH, fontW, fontH int + curCols, curRows int + wantCols, wantRows int + wantGrow bool + }{ + { + name: "maximized window with stale small grid grows", + clientW: 1920, clientH: 1020, fontW: 8, fontH: 16, + curCols: 117, curRows: 30, + wantCols: 240, wantRows: 63, wantGrow: true, + }, + { + name: "grid already matches client area — no grow", + clientW: 960, clientH: 480, fontW: 8, fontH: 16, + curCols: 120, curRows: 30, + wantGrow: false, + }, + { + name: "window smaller than grid (shrink) — conhost handles natively", + clientW: 800, clientH: 400, fontW: 8, fontH: 16, + curCols: 120, curRows: 40, + wantGrow: false, + }, + { + name: "one axis grows, other axis never shrinks below current", + clientW: 1920, clientH: 400, fontW: 8, fontH: 16, + curCols: 117, curRows: 30, + wantCols: 240, wantRows: 30, wantGrow: true, + }, + { + name: "zero font metrics — refuse to act", + clientW: 1920, clientH: 1020, fontW: 0, fontH: 16, + curCols: 117, curRows: 30, + wantGrow: false, + }, + { + name: "zero client area — refuse to act", + clientW: 0, clientH: 0, fontW: 8, fontH: 16, + curCols: 117, curRows: 30, + wantGrow: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cols, rows, grow := consoleGridTarget(tt.clientW, tt.clientH, tt.fontW, tt.fontH, tt.curCols, tt.curRows) + if grow != tt.wantGrow { + t.Fatalf("grow = %v, want %v", grow, tt.wantGrow) + } + if !grow { + return + } + if cols != tt.wantCols || rows != tt.wantRows { + t.Errorf("target = %dx%d, want %dx%d", cols, rows, tt.wantCols, tt.wantRows) + } + }) + } +} diff --git a/internal/tui/consolefix_windows.go b/internal/tui/consolefix_windows.go new file mode 100644 index 0000000..8441af7 --- /dev/null +++ b/internal/tui/consolefix_windows.go @@ -0,0 +1,179 @@ +//go:build windows + +package tui + +import ( + "syscall" + "unsafe" + + "golang.org/x/sys/windows" + + "github.com/artyomsv/quil/internal/logger" +) + +var ( + fixKernel32 = syscall.NewLazyDLL("kernel32.dll") + fixUser32 = syscall.NewLazyDLL("user32.dll") + procFixGetConsoleWindow = fixKernel32.NewProc("GetConsoleWindow") + procFixGetClientRect = fixUser32.NewProc("GetClientRect") + procFixGetCurrentConsoleFont = fixKernel32.NewProc("GetCurrentConsoleFont") + procFixGetConsoleFontSize = fixKernel32.NewProc("GetConsoleFontSize") + procFixSetConsoleScreenBufSize = fixKernel32.NewProc("SetConsoleScreenBufferSize") + procFixSetConsoleWindowInfo = fixKernel32.NewProc("SetConsoleWindowInfo") + procFixLargestWindowSize = fixKernel32.NewProc("GetLargestConsoleWindowSize") + procFixIsZoomed = fixUser32.NewProc("IsZoomed") +) + +type fixRect struct{ Left, Top, Right, Bottom int32 } + +type fixConsoleFontInfo struct { + Font uint32 + FontSize windows.Coord +} + +// activeConsoleOut opens CONOUT$ — a handle to the console's currently +// ACTIVE screen buffer (the alt screen while Bubble Tea is running). +func activeConsoleOut() (windows.Handle, func(), bool) { + name, err := windows.UTF16PtrFromString("CONOUT$") + if err != nil { + return 0, nil, false + } + h, err := windows.CreateFile(name, + windows.GENERIC_READ|windows.GENERIC_WRITE, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE, + nil, windows.OPEN_EXISTING, 0, 0) + if err != nil { + return 0, nil, false + } + return h, func() { _ = windows.CloseHandle(h) }, true +} + +// fixupConsoleGrid grows the console screen buffer + window to match the +// window's client pixel area. Legacy conhost never grows the grid itself +// when the window is enlarged/maximized (it paints dead space and keeps +// reporting the stale cell size), so the app has to do it. Safe no-op when +// the grid already fits, when any metric is unavailable, or when the window +// shrank (conhost handles shrinks natively). Windows Terminal never needs +// this — its grid always follows the window — and the grow branch simply +// never triggers there. +func fixupConsoleGrid() { + hwnd, _, _ := procFixGetConsoleWindow.Call() + if hwnd == 0 { + return + } + var rc fixRect + if ret, _, _ := procFixGetClientRect.Call(hwnd, uintptr(unsafe.Pointer(&rc))); ret == 0 { + return + } + + // CONOUT$ resolves to the console's ACTIVE screen buffer. This matters: + // Bubble Tea runs in the VT alternate screen, which conhost implements + // as a separate screen buffer — the visible window belongs to IT, while + // os.Stdout still refers to the original main buffer. Resize calls on + // the main buffer "succeed" but change nothing on screen (observed: + // fixup logged success every second while the grid stayed 117x30). + h, closeOut, ok := activeConsoleOut() + if !ok { + return + } + defer closeOut() + + var info windows.ConsoleScreenBufferInfo + if err := windows.GetConsoleScreenBufferInfo(h, &info); err != nil { + return + } + + // Font cell size in pixels: GetCurrentConsoleFont gives the font index, + // GetConsoleFontSize resolves it to pixels (the COORD comes back packed + // in the return value: X in the low word, Y in the high word). + var cfi fixConsoleFontInfo + if ret, _, _ := procFixGetCurrentConsoleFont.Call(uintptr(h), 0, uintptr(unsafe.Pointer(&cfi))); ret == 0 { + return + } + fsRaw, _, _ := procFixGetConsoleFontSize.Call(uintptr(h), uintptr(cfi.Font)) + fontW := int(int16(fsRaw & 0xffff)) + fontH := int(int16(fsRaw >> 16)) + + curCols := int(info.Window.Right - info.Window.Left + 1) + curRows := int(info.Window.Bottom - info.Window.Top + 1) + + cols, rows, grow := consoleGridTarget(int(rc.Right), int(rc.Bottom), fontW, fontH, curCols, curRows) + if !grow { + return + } + + // Diagnostics: conhost has been observed accepting the resize once and + // silently reverting/ignoring afterwards — capture every metric that + // could explain a clamp. + largestRaw, _, _ := procFixLargestWindowSize.Call(uintptr(h)) + largestW := int(int16(largestRaw & 0xffff)) + largestH := int(int16(largestRaw >> 16)) + zoomed, _, _ := procFixIsZoomed.Call(hwnd) + logger.Debug("console grid fixup: cur=%dx%d target=%dx%d client=%dx%dpx font=%dx%d buf=%dx%d largest=%dx%d zoomed=%v", + curCols, curRows, cols, rows, rc.Right, rc.Bottom, fontW, fontH, info.Size.X, info.Size.Y, largestW, largestH, zoomed != 0) + + // conhost refuses windows larger than GetLargestConsoleWindowSize. + if largestW > 0 && cols > largestW { + cols = largestW + } + if largestH > 0 && rows > largestH { + rows = largestH + } + if cols <= curCols && rows <= curRows { + return + } + + // The buffer must always be at least as large as the window: grow the + // buffer first, then the window rect. Never shrink an existing buffer + // axis (the main buffer may carry scrollback history). + bufW := cols + if int(info.Size.X) > bufW { + bufW = int(info.Size.X) + } + bufH := rows + if int(info.Size.Y) > bufH { + bufH = int(info.Size.Y) + } + bufCoord := uintptr(uint32(uint16(bufW)) | uint32(uint16(bufH))<<16) + if ret, _, err := procFixSetConsoleScreenBufSize.Call(uintptr(h), bufCoord); ret == 0 { + logger.Debug("console grid fixup: SetConsoleScreenBufferSize(%dx%d): %v", bufW, bufH, err) + return + } + sr := windows.SmallRect{Left: 0, Top: 0, Right: int16(cols - 1), Bottom: int16(rows - 1)} + if ret, _, err := procFixSetConsoleWindowInfo.Call(uintptr(h), 1, uintptr(unsafe.Pointer(&sr))); ret == 0 { + logger.Debug("console grid fixup: SetConsoleWindowInfo(%dx%d): %v", cols, rows, err) + return + } + + // Mirror the growth onto the MAIN screen buffer (os.Stdout handle is + // not used — open it via the std handle API). conhost consults the + // main buffer's dimensions when it rebuilds/validates the alt buffer, + // and a stale small main buffer has been observed snapping the visible + // grid back within a second. + if hStd, err := windows.GetStdHandle(windows.STD_OUTPUT_HANDLE); err == nil { + var mainInfo windows.ConsoleScreenBufferInfo + if windows.GetConsoleScreenBufferInfo(hStd, &mainInfo) == nil { + mw, mh := bufW, bufH + if int(mainInfo.Size.X) > mw { + mw = int(mainInfo.Size.X) + } + if int(mainInfo.Size.Y) > mh { + mh = int(mainInfo.Size.Y) + } + mainCoord := uintptr(uint32(uint16(mw)) | uint32(uint16(mh))<<16) + procFixSetConsoleScreenBufSize.Call(uintptr(hStd), mainCoord) + msr := windows.SmallRect{Left: 0, Top: 0, Right: int16(cols - 1), Bottom: int16(rows - 1)} + procFixSetConsoleWindowInfo.Call(uintptr(hStd), 1, uintptr(unsafe.Pointer(&msr))) + } + } + + // Verify the grow actually stuck — conhost can return success and + // keep the old grid. + var after windows.ConsoleScreenBufferInfo + if windows.GetConsoleScreenBufferInfo(h, &after) == nil { + logger.Debug("console grid fixup: applied %dx%d -> post-apply window=%dx%d buf=%dx%d", + cols, rows, + int(after.Window.Right-after.Window.Left+1), int(after.Window.Bottom-after.Window.Top+1), + after.Size.X, after.Size.Y) + } +} diff --git a/internal/tui/dialog.go b/internal/tui/dialog.go index 9b320f4..1dc1690 100644 --- a/internal/tui/dialog.go +++ b/internal/tui/dialog.go @@ -278,6 +278,7 @@ func shortcutsList(m *Model) []struct{ key, desc string } { {kbDisplay(kb.Paste), "Paste clipboard"}, {kbDisplay(kb.FocusPane), "Toggle focus mode"}, {kbDisplay(kb.NotesToggle), "Toggle pane notes"}, + {kbDisplay(kb.Redraw), "Force screen redraw"}, {"Ctrl+N", "New typed pane"}, {"Alt+1..9", "Switch to tab N"}, {"F1", "Help / About"}, diff --git a/internal/tui/model_redraw_test.go b/internal/tui/model_redraw_test.go new file mode 100644 index 0000000..fc0f444 --- /dev/null +++ b/internal/tui/model_redraw_test.go @@ -0,0 +1,57 @@ +package tui + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/artyomsv/quil/internal/config" +) + +// Force-redraw recovers from accumulated cell-diff drift (width +// disagreements between the renderer and the host terminal scramble +// characters until something forces a full repaint — frequent on Windows). + +func TestHandleKey_Redraw_EmitsClearScreen(t *testing.T) { + m := Model{ + cfg: config.Default(), + notifications: NewNotificationCenter(30, 50), + } + m.cfg.Keybindings.Redraw = "f9" + + _, cmd := m.handleKey(tea.KeyPressMsg{Code: tea.KeyF9}) + if cmd == nil { + t.Fatal("redraw key produced no command") + } + + // The redraw key must BOTH repaint and re-query the window size — a + // missed WindowSizeMsg (maximize/restore on Windows) leaves the layout + // model stale, and ClearScreen alone repaints the same wrong frame. + batch, ok := cmd().(tea.BatchMsg) + if !ok { + t.Fatalf("redraw key produced %T, want tea.BatchMsg", cmd()) + } + var haveClear, haveWinSize bool + for _, c := range batch { + switch msg := c(); msg { + case tea.ClearScreen(): + haveClear = true + case tea.RequestWindowSize(): + haveWinSize = true + default: + t.Errorf("unexpected message in redraw batch: %T", msg) + } + } + if !haveClear { + t.Error("redraw batch missing ClearScreen") + } + if !haveWinSize { + t.Error("redraw batch missing RequestWindowSize") + } +} + +func TestDefaultConfig_RedrawBound(t *testing.T) { + if config.Default().Keybindings.Redraw == "" { + t.Error("redraw keybinding must ship with a default") + } +} diff --git a/internal/tui/model_settle_test.go b/internal/tui/model_settle_test.go new file mode 100644 index 0000000..0cdfde8 --- /dev/null +++ b/internal/tui/model_settle_test.go @@ -0,0 +1,51 @@ +package tui + +import ( + "testing" + + tea "charm.land/bubbletea/v2" +) + +// A child process reflows its UI right after the daemon's spawn-time resize +// kick lands. When the host terminal disagrees with the renderer about glyph +// widths (Claude Code's logo on Windows fonts), that redraw leaves stale +// cells behind that only a full repaint clears. The TUI schedules settle +// repaints automatically on a pane's FIRST live output — the same recovery +// as the manual redraw key, without the keypress. + +func TestHandlePaneOutput_FirstLiveOutput_SchedulesSettleRepaint(t *testing.T) { + m, _ := cursorTestModel("claude-code") + + cmd := m.handlePaneOutput(PaneOutputMsg{PaneID: "p1", Data: []byte("hello")}) + if cmd == nil { + t.Fatal("first live output must schedule settle repaints") + } + + // Subsequent output must NOT reschedule — one settle window per pane. + cmd = m.handlePaneOutput(PaneOutputMsg{PaneID: "p1", Data: []byte("more")}) + if cmd != nil { + t.Error("second live output scheduled another settle repaint") + } +} + +func TestHandlePaneOutput_GhostOutput_DoesNotScheduleRepaint(t *testing.T) { + m, _ := cursorTestModel("claude-code") + m.cfg.GhostBuffer.Dimmed = true + + cmd := m.handlePaneOutput(PaneOutputMsg{PaneID: "p1", Data: []byte("replay"), Ghost: true}) + if cmd != nil { + t.Error("ghost replay scheduled a settle repaint — only live output should") + } +} + +func TestUpdate_PaneSettleRepaint_EmitsClearScreen(t *testing.T) { + m, _ := cursorTestModel("claude-code") + + _, cmd := m.Update(paneSettleRepaintMsg{}) + if cmd == nil { + t.Fatal("paneSettleRepaintMsg produced no command") + } + if got, want := cmd(), tea.ClearScreen(); got != want { + t.Errorf("got %T, want tea.ClearScreen message", got) + } +} diff --git a/internal/tui/model_sizepoll_test.go b/internal/tui/model_sizepoll_test.go new file mode 100644 index 0000000..b72d21d --- /dev/null +++ b/internal/tui/model_sizepoll_test.go @@ -0,0 +1,78 @@ +package tui + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/artyomsv/quil/internal/config" +) + +// conhost coalesces/drops WINDOW_BUFFER_SIZE_EVENTs during rapid resize → +// maximize, so the final WindowSizeMsg can simply never arrive and the TUI +// renders at a stale size until the user presses the redraw key. A periodic +// size poll (tea.RequestWindowSize) closes the gap; the WindowSizeMsg +// handler no-ops when nothing changed so unchanged polls cost nothing. + +func TestUpdate_SizePoll_RequestsWindowSizeAndReschedules(t *testing.T) { + m, _ := cursorTestModel("claude-code") + + _, cmd := m.Update(sizePollMsg{}) + if cmd == nil { + t.Fatal("size poll produced no command") + } + batch, ok := cmd().(tea.BatchMsg) + if !ok { + t.Fatalf("size poll produced %T, want tea.BatchMsg", cmd()) + } + if len(batch) != 2 { + t.Fatalf("size poll batch has %d cmds, want 2 (query + reschedule)", len(batch)) + } + // First element queries the terminal; second is the next tick (not + // executed here — it sleeps for the poll interval). + if got, want := batch[0](), tea.RequestWindowSize(); got != want { + t.Errorf("batch[0] = %T, want tea.RequestWindowSize message", got) + } +} + +func TestUpdate_WindowSizeMsg_UnchangedSizeIsNoOp(t *testing.T) { + m := Model{ + cfg: config.Default(), + notifications: NewNotificationCenter(30, 50), + attached: true, + width: 80, + height: 24, + pendingWidth: 80, + pendingHeight: 24, + } + + out, cmd := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + got := out.(Model) + if cmd != nil { + t.Error("unchanged WindowSizeMsg scheduled work — poll echoes must be free") + } + if got.resizeSeq != m.resizeSeq { + t.Error("unchanged WindowSizeMsg bumped resizeSeq") + } +} + +func TestUpdate_WindowSizeMsg_ChangedSizeStillDebounces(t *testing.T) { + m := Model{ + cfg: config.Default(), + notifications: NewNotificationCenter(30, 50), + attached: true, + width: 80, + height: 24, + pendingWidth: 80, + pendingHeight: 24, + } + + out, cmd := m.Update(tea.WindowSizeMsg{Width: 240, Height: 60}) + got := out.(Model) + if cmd == nil { + t.Fatal("changed WindowSizeMsg must schedule the debounce tick") + } + if got.pendingWidth != 240 || got.pendingHeight != 60 { + t.Errorf("pending size = %dx%d, want 240x60", got.pendingWidth, got.pendingHeight) + } +} From f101dccab52b0c24b88036ca6c5f3cd37a31f572 Mon Sep 17 00:00:00 2001 From: Artjoms Stukans Date: Mon, 8 Jun 2026 16:32:37 +0200 Subject: [PATCH 5/6] docs: document caret model, redraw key, and Windows size recovery Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/CLAUDE.md | 4 ++++ docs/configuration.md | 2 ++ docs/keybindings.md | 1 + 3 files changed, 7 insertions(+) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 1f1319d..a702940 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -137,6 +137,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 ''` at every spawn (never modifies `~/.claude/settings.json`) and passes `QUIL_PANE_ID=` 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/.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":[""]}'` at every spawn (never writes to `~/.config/opencode/`) and passes `QUIL_PANE_ID=` + `QUIL_HOME=` 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-.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-.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 diff --git a/docs/configuration.md b/docs/configuration.md index 9fc8c58..271c3fe 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -70,6 +70,7 @@ scroll_page_up = "alt+pgup" scroll_page_down = "alt+pgdown" paste = "ctrl+v" focus_pane = "ctrl+e" +redraw = "alt+shift+l" # force full screen repaint (clears rendering artifacts) ``` ## `[daemon]` @@ -142,6 +143,7 @@ Multiple modifiers stack with `+` (no spaces). Mouse buttons are not bindable he | `scroll_page_up` / `scroll_page_down` | `alt+pgup` / `alt+pgdown` | Pane scrollback | | `paste` | `ctrl+v` | Paste from clipboard (text or image) | | `focus_pane` | `ctrl+e` | Toggle focus mode | +| `redraw` | `alt+shift+l` | Force a full screen repaint — clears rendering artifacts (scrambled or misplaced characters) without restarting the TUI | ## Per-plugin instances diff --git a/docs/keybindings.md b/docs/keybindings.md index 4492a9f..73e57bd 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -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 From 1ddcbd1400a86e81f3f36a5a75913da6056f4148 Mon Sep 17 00:00:00 2001 From: Artjoms Stukans Date: Mon, 8 Jun 2026 21:14:48 +0200 Subject: [PATCH 6/6] fix(site): correct vertical-split key to Alt+Shift+V on install page Splits moved to Alt+Shift+H/V so Alt+V stays free for Claude Code's image paste; the install HowTo step still showed the old Alt+V. Co-Authored-By: Claude Opus 4.8 (1M context) --- site/src/pages/install.astro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/install.astro b/site/src/pages/install.astro index 19ba795..a7b02d8 100644 --- a/site/src/pages/install.astro +++ b/site/src/pages/install.astro @@ -39,7 +39,7 @@ const schemas = [ { name: "Run the install script", text: "curl -sSfL https://raw.githubusercontent.com/artyomsv/quil/master/scripts/install.sh | sh" }, { name: "Launch the TUI", text: "quil" }, { name: "Verify the install", text: "quil --version" }, - { name: "Create your first pane", text: "Press Ctrl+T to add a tab, then Alt+V to split vertically. The daemon auto-starts the first time you launch the TUI." }, + { name: "Create your first pane", text: "Press Ctrl+T to add a tab, then Alt+Shift+V to split vertically. The daemon auto-starts the first time you launch the TUI." }, ], }), ];