diff --git a/CHANGELOG.md b/CHANGELOG.md index c29b4b690..b61ffc118 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - 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. ## [0.78.0] - 2026-10-22 diff --git a/native/cmd/lich-native/dock_slide.go b/native/cmd/lich-native/dock_slide.go index 5c659d9c6..4140a123b 100644 --- a/native/cmd/lich-native/dock_slide.go +++ b/native/cmd/lich-native/dock_slide.go @@ -8,20 +8,22 @@ import ( // dockSlide eases the dock in from the window's right edge and back out, on // the sidebar morph's 180ms curve. The dock keeps its width while it moves, -// so only the terminal beside it changes size, and that refits once it holds -// still. +// so only the island beside it changes size. type dockSlide struct { open bool from float32 // the share shown when the current slide began start time.Time shown float32 + // began and landed are whether this frame started or ended a slide. + began, landed bool } // at returns the share of the dock on screen this frame, 0 to 1, starting a // slide toward open when the target changed; a slide reversed halfway turns // back from where it is. func (s *dockSlide) at(gtx C, open bool) float32 { - if open != s.open { + s.began = open != s.open + if s.began { s.open, s.from, s.start = open, s.shown, gtx.Now } target := float32(0) @@ -30,9 +32,11 @@ func (s *dockSlide) at(gtx C, open bool) float32 { } t := float32(gtx.Now.Sub(s.start)) / float32(sidebarMorphDuration) if t >= 1 { + s.landed = s.shown != target s.shown = target return s.shown } + s.landed = false s.shown = s.from + (target-s.from)*sidebarMorphCurve(max(t, 0)) gtx.Execute(op.InvalidateCmd{}) return s.shown diff --git a/native/cmd/lich-native/dock_slide_test.go b/native/cmd/lich-native/dock_slide_test.go index 852691766..c536786f0 100644 --- a/native/cmd/lich-native/dock_slide_test.go +++ b/native/cmd/lich-native/dock_slide_test.go @@ -33,3 +33,20 @@ func TestDockSlideEasesInAndTurnsBackFromWhereItIs(t *testing.T) { t.Fatalf("a new opening starts closed: got %v, want 0", got) } } + +func TestDockSlideMarksTheFramesItBeginsAndEnds(t *testing.T) { + var s dockSlide + t0 := time.Unix(100, 0) + if slideAt(&s, t0, true); !s.began { + t.Fatal("first frame of an opening: not began, want began") + } + if slideAt(&s, t0.Add(sidebarMorphDuration/2), true); s.began || s.landed { + t.Fatalf("halfway: began %v landed %v, want neither", s.began, s.landed) + } + if slideAt(&s, t0.Add(sidebarMorphDuration), true); !s.landed { + t.Fatal("first frame at the end: not landed, want landed") + } + if slideAt(&s, t0.Add(2*sidebarMorphDuration), true); s.landed { + t.Fatal("a frame after the end: landed again, want once") + } +} diff --git a/native/cmd/lich-native/main.go b/native/cmd/lich-native/main.go index 85ade52ec..0934f5ed2 100644 --- a/native/cmd/lich-native/main.go +++ b/native/cmd/lich-native/main.go @@ -621,8 +621,10 @@ 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. -func (u *window) island(gtx C, v stageView) error { +// 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. +func (u *window) island(gtx C, v stageView, termWidth int) error { size := gtx.Constraints.Max gtx.Constraints.Min = size r := gtx.Dp(ui.RadiusLG) @@ -653,7 +655,9 @@ func (u *window) island(gtx C, v stageView) error { } u.layoutStage.metric = gtx.Metric u.terms.onScreen(v.active.ID) - if err := u.content(gtx, v.active, u.terminalTakesKeys(gtx)); err != nil { + term := gtx + term.Constraints = layout.Exact(image.Pt(termWidth, size.Y)) + if err := u.content(term, v.active, u.terminalTakesKeys(gtx)); err != nil { return err } u.paneDropZones(gtx, v.active.ID, image.Rectangle{Max: size}, image.Rectangle{Max: size}.Add(u.islandAt), true) @@ -725,11 +729,19 @@ 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 + 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 + if open && !d.fullscreen { + termWidth = max(size.X-d.width(gtx)-gtx.Dp(gutter), 0) + } if share == 0 { d.islands = nil - return u.island(gtx, v) + return u.island(gtx, v, termWidth) } - size := gtx.Constraints.Max at, w, slot := 0, size.X, 0 if !d.fullscreen { w = d.width(gtx) @@ -745,7 +757,7 @@ func (u *window) stageRow(gtx C, v stageView) error { } island := gtx island.Constraints = layout.Exact(image.Pt(max(size.X-slot, 0), size.Y)) - if err := u.island(island, v); err != nil { + if err := u.island(island, v, termWidth); err != nil { return err } defer clip.Rect{Max: size}.Push(gtx.Ops).Pop() diff --git a/native/cmd/lich-native/terminals.go b/native/cmd/lich-native/terminals.go index 54a49d014..f079e63a7 100644 --- a/native/cmd/lich-native/terminals.go +++ b/native/cmd/lich-native/terminals.go @@ -99,6 +99,10 @@ 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 bool mu sync.Mutex byID map[string]*termView @@ -423,7 +427,7 @@ func sameCell(vt *gioterm.Terminal, w, h int) bool { // refit follows the area with the grid and the PTY once size has settled, // and until then asks for the frame that will. func (ts *terminals) refit(gtx layout.Context, t *termView, size image.Point, cols, rows int) error { - if due, at := t.refitDue(size, gtx.Now); !due { + if due, at := t.refitDue(size, gtx.Now); !due && !ts.refitNow { gtx.Execute(op.InvalidateCmd{At: at}) return nil }