From 8d02ffac5079b64ecfb75d39c37919c96c46a793 Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 23 Oct 2026 11:44:57 -0300 Subject: [PATCH] fix(native): fit the terminal to the sidebar's destination as it starts moving Through an expand the stage keeps the collapsed sidebar's width and only shrinks once the morph lands, so the terminal refit refitSettle after that and jumped. A lone session's terminal now takes the expanded width from the morph's first frame (sidebar.stageOverhang), and every frame the morph holds the stage still, plus the one it lands in, refits at once. --- CHANGELOG.md | 1 + native/cmd/lich-native/main.go | 16 +++---- native/cmd/lich-native/sidebar_morph.go | 16 ++++++- native/cmd/lich-native/sidebar_morph_test.go | 44 ++++++++++++++++++++ native/cmd/lich-native/stale_chrome_test.go | 1 + native/cmd/lich-native/terminals.go | 6 +-- 6 files changed, 73 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a465b51c..b6e208ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Branch, changes and pull request on the cards and the footer catch up within a second of a commit, checkout or edit made outside a turn, where they could lag up to three seconds, and selecting a card reads its checkout at once instead of showing what the last read found. - Kiro CLI sessions follow `KIRO_HOME`. With it set, lich read Kiro's conversations, cost and context from `~/.kiro` while Kiro kept them under `KIRO_HOME`, so they showed nothing and History listed none; the agent that carries lich's hooks was installed where Kiro never looked, and a sandboxed session was not given the directory it writes into. - Opening or closing the dock no longer leaves the session's terminal cut off at its edge for a beat once the dock stops. The terminal takes its new width as the dock starts moving, so the agent redraws while the dock slides in, where the terminal waited until a tenth of a second after the dock stopped and then jumped. A split layout's terminals fit the moment the dock stops. +- Expanding the sidebar no longer leaves the session's terminal at its old width for a beat once the sidebar is open. The terminal takes its new width as the sidebar starts moving, as with the dock, where it waited until a tenth of a second after the sidebar stopped and then jumped; collapsing it refits at once too. A split layout's terminals fit the moment the sidebar stops. ## [0.78.0] - 2026-10-22 diff --git a/native/cmd/lich-native/main.go b/native/cmd/lich-native/main.go index 0934f5ed..2c67de50 100644 --- a/native/cmd/lich-native/main.go +++ b/native/cmd/lich-native/main.go @@ -622,8 +622,8 @@ func (u *window) body(gtx C, v stageView) (int, sidebarAction, error) { // island is the stage's rounded box: Home, the empty notice, the diff or // the terminal, in the color the active tab merges with. Its top-left corner // is square while that tab sits on it. A lone session's terminal is laid out -// termWidth wide, which a dock slide sets to where the box is going: wider, -// the box clips it; narrower, its fill shows past it. +// termWidth wide, which a dock slide or a sidebar morph sets to where the box +// is going: wider, the box clips it; narrower, its fill shows past it. func (u *window) island(gtx C, v stageView, termWidth int) error { size := gtx.Constraints.Max gtx.Constraints.Min = size @@ -729,15 +729,17 @@ func (u *window) stageRow(gtx C, v stageView) error { d.slide.snap(open) } share := d.slide.at(gtx, open) - u.terms.refitNow = d.slide.began || d.slide.landed + u.terms.refitNow = d.slide.began || d.slide.landed || u.sidebar.morph.holdsStage() defer func() { u.terms.refitNow = false }() size := gtx.Constraints.Max - // The session's terminal takes the width the slide ends at from its first - // frame, so the agent redraws for it while the dock is still moving. - termWidth := size.X + // The session's terminal takes the width the dock's slide and the + // sidebar's morph end at from their first frame, so the agent redraws for + // it while they are still moving. + termWidth := size.X - u.sidebar.stageOverhang(gtx) if open && !d.fullscreen { - termWidth = max(size.X-d.width(gtx)-gtx.Dp(gutter), 0) + termWidth -= d.width(gtx) + gtx.Dp(gutter) } + termWidth = max(termWidth, 0) if share == 0 { d.islands = nil return u.island(gtx, v, termWidth) diff --git a/native/cmd/lich-native/sidebar_morph.go b/native/cmd/lich-native/sidebar_morph.go index 818c39cd..b34e42d3 100644 --- a/native/cmd/lich-native/sidebar_morph.go +++ b/native/cmd/lich-native/sidebar_morph.go @@ -63,6 +63,9 @@ type sidebarMorph struct { p float32 // this frame's eased progress side morphSide boxes [2]map[string]image.Rectangle // by side, in window coordinates + + // landed is whether this frame ended the morph. + landed bool } // begin starts a morph to the shape the sidebar was just switched to. @@ -73,10 +76,15 @@ func (m *sidebarMorph) begin(gtx C) { func (m *sidebarMorph) active() bool { return m.phase != morphIdle } +// holdsStage is whether the stage's size this frame holds until the morph +// lands, or is the size it landed on. +func (m *sidebarMorph) holdsStage() bool { return m.active() || m.landed } + // advance sets the frame's progress. It runs once, before the frame is laid // out, so every part of the frame reads the same one. func (m *sidebarMorph) advance(gtx C) { m.capturing = m.phase == morphCapture + m.landed = false switch m.phase { case morphIdle: return @@ -87,7 +95,7 @@ func (m *sidebarMorph) advance(gtx C) { m.start = gtx.Now } if m.p = morphProgress(gtx.Now.Sub(m.start)); m.p >= 1 { - m.phase = morphIdle + m.phase, m.landed = morphIdle, true return } } @@ -183,6 +191,12 @@ func (s *sidebar) stageReserve(gtx C) int { return min(s.boxWidth(gtx, true), s.boxWidth(gtx, false)) } +// stageOverhang is how much wider the stage is this frame than where the +// morph leaves it: the shapes' difference through an expand, else 0. +func (s *sidebar) stageOverhang(gtx C) int { + return s.boxWidth(gtx, s.open) - s.stageReserve(gtx) +} + // morphing is the sidebar mid-morph: its box eases between the two shapes' // widths, each shape drawn at its own size from the box's leading corner and // clipped by it, the old one fading out as the new one fades in. diff --git a/native/cmd/lich-native/sidebar_morph_test.go b/native/cmd/lich-native/sidebar_morph_test.go index 26860162..5944995f 100644 --- a/native/cmd/lich-native/sidebar_morph_test.go +++ b/native/cmd/lich-native/sidebar_morph_test.go @@ -152,3 +152,47 @@ func TestSidebarMorphResizesTheStageOnce(t *testing.T) { } } } + +// The terminal fits the width the morph ends at from its first frame, so the +// agent redraws while the sidebar is still moving: either way the terminal's +// width changes once, as the morph starts, to the final one, and the frames +// from there to the one the morph lands in refit it at once. +func TestSidebarMorphFitsTheTerminalToWhereItEndsAsItStarts(t *testing.T) { + const window = 1400 + for _, opening := range []bool{true, false} { + s := newSidebar(new(ui.Root)) + s.open = !opening + gtx := layout.Context{Ops: new(op.Ops), Metric: unit.Metric{PxPerDp: 1}, Now: time.Unix(0, 0)} + var widths []int + landings := 0 + frame := func() { + s.morph.advance(gtx) + widths = append(widths, window-s.stageReserve(gtx)-s.stageOverhang(gtx)) + if s.morph.landed { + landings++ + } + if s.morph.active() && !s.morph.holdsStage() { + t.Fatalf("opening=%v: a morph frame does not hold the stage", opening) + } + s.morph.capture(gtx.Ops) + gtx.Now = gtx.Now.Add(testFrame) + } + frame() + s.setOpen(gtx, opening, new(sidebarAction)) + for range 2 + int(sidebarMorphDuration/testFrame) + 2 { + frame() + } + after := window - s.boxWidth(gtx, opening) + for i, w := range widths[1:] { + if w != after { + t.Fatalf("opening=%v: frame %d terminal width %d, want %d from the morph's first frame", opening, i+1, w, after) + } + } + if landings != 1 { + t.Fatalf("opening=%v: landed in %d frames, want 1", opening, landings) + } + if s.morph.holdsStage() { + t.Fatalf("opening=%v: still holds the stage a frame after landing", opening) + } + } +} diff --git a/native/cmd/lich-native/stale_chrome_test.go b/native/cmd/lich-native/stale_chrome_test.go index e4295ccf..17ba99a5 100644 --- a/native/cmd/lich-native/stale_chrome_test.go +++ b/native/cmd/lich-native/stale_chrome_test.go @@ -14,6 +14,7 @@ func stageFrames(t *testing.T, tab dockTab) (*window, *frameRig) { r := newFrameRig(rigWindow, nil) u := &window{model: newModel(nil, r.invalidate), terms: &terminals{byID: map[string]*termView{}, invalidate: r.invalidate}} u.topbar = newTopbar(&u.root) + u.sidebar = newSidebar(&u.root) _, client := newSidebarBackend(t) comments := new(commentStore) code, err := newCodeTab(client, r.invalidate, "monospace", 12, comments, func(ui.Toast) {}) diff --git a/native/cmd/lich-native/terminals.go b/native/cmd/lich-native/terminals.go index f079e63a..91885871 100644 --- a/native/cmd/lich-native/terminals.go +++ b/native/cmd/lich-native/terminals.go @@ -99,9 +99,9 @@ type terminals struct { // unzoomed is the window's own scale, which terminals draw at whatever // the interface zoom: a zoom step never changes a grid's cols×rows. unzoomed unit.Metric - // refitNow is a frame a dock slide began or ended in: the area just took - // the size it will hold, so the grid follows it at once rather than after - // refitSettle more of a clipped grid. + // refitNow is a frame whose area holds its size until a dock slide or a + // sidebar morph lands, or the frame one landed in, so the grid follows it + // at once rather than after refitSettle more of a clipped grid. refitNow bool mu sync.Mutex