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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Leave on a page in the session browser that asks before you go no longer reloads the page instead on a busy machine; the browser goes where it was going.
- An agent clicking a point in the session browser no longer misses it after you move lich to another monitor, resize the browser pane or change its zoom. Its screenshots are now in the same pixels as its clicks, which a HiDPI monitor or a page zoom had made up to 1.5 times larger, and a click at a point read off a screenshot taken before the pane changed size is refused, telling the agent to take a new one.
- lich no longer keeps the GPU busy while a session works. A project tab's spinner redrew the whole window at the monitor's refresh rate, and with sessions busy in more than one project lich held over half of a laptop GPU, taking it from games and everything else. Every spinner, the sidebar's busy ring and a running service's beat now redraw at most 30 times a second, and hold still while the lich window is not focused, moving again when you come back to it.
- While a session works, the window draws 24 frames a second, film's rate, instead of 30, and output from the session's terminal is drawn on the busy ring's next frame instead of asking for frames of its own: a streaming agent and a spinning ring cost one frame rate, not the sum of the two. What you type still shows the moment the terminal echoes it.
- The lich window uses half the processor it did while a session works. Every icon, ring and arc on screen was rebuilt from its outline on every frame; each is now built once and kept on the graphics card.

## [0.83.0] - 2026-10-29
Expand Down
5 changes: 3 additions & 2 deletions native/cmd/lich-native/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,11 +222,12 @@ func run(w *app.Window, rt backend.Runtime) error {
th.Shaper = text.NewShaper(text.WithCollection(append(append(gofont.Collection(), sans...), faces...)))
th.Fg, th.Bg = ui.Dark.Foreground, ui.Dark.Background

beat := &ui.LoopBeat{}
u := &window{
ctx: ctx, client: client, th: th, win: w, zoom: defaultZoom, browserZoom: defaultZoom, browserViews: newBrowserQueue(),
theme: &ui.Theme{Palette: ui.Dark, Shaper: th.Shaper, Sans: fonts.Sans, Mono: faces[0].Font.Typeface, Surface: ui.Dark.Canvas},
theme: &ui.Theme{Palette: ui.Dark, Shaper: th.Shaper, Sans: fonts.Sans, Mono: faces[0].Font.Typeface, Surface: ui.Dark.Canvas, Beat: beat},
model: newModel(client, w.Invalidate),
terms: &terminals{client: client, invalidate: w.Invalidate, renderer: *flagRenderer, family: *flagFont,
terms: &terminals{client: client, invalidate: w.Invalidate, beat: beat, renderer: *flagRenderer, family: *flagFont,
size: unit.Sp(*flagSize), byID: map[string]*termView{}},
}
u.appearance = &appearance{ctx: ctx, client: client, invalidate: w.Invalidate}
Expand Down
4 changes: 2 additions & 2 deletions native/cmd/lich-native/sidebar_services_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ func TestServiceBeatPacing(t *testing.T) {
th := &ui.Theme{Palette: ui.Dark, Shaper: text.NewShaper(text.WithCollection(gofont.Collection())), Surface: ui.Dark.Background}
beat := time.UnixMilli(2400 * 1000)
inPulse, resting := beat.Add(100*time.Millisecond), beat.Add(1000*time.Millisecond)
if at, ok := beatWake(th, inPulse); !ok || !at.Equal(inPulse.Add(33*time.Millisecond)) {
t.Errorf("mid-pulse the beat asked for %v (asked %v), want the next frame 33ms on", at, ok)
if at, ok := beatWake(th, inPulse); !ok || !at.Equal(inPulse.Add(42*time.Millisecond)) {
t.Errorf("mid-pulse the beat asked for %v (asked %v), want the next frame 42ms on", at, ok)
}
if at, ok := beatWake(th, resting); !ok || !at.Equal(beat.Add(2400*time.Millisecond)) {
t.Errorf("at rest the beat asked for %v (asked %v), want the next beat", at, ok)
Expand Down
26 changes: 22 additions & 4 deletions native/cmd/lich-native/terminals.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,16 @@ type terminals struct {
client *backend.Client
stream *backend.Stream
invalidate func()
renderer string
family string
size unit.Sp
// beat is the window's looping frame: output that lands while one is
// due rides it, so a busy ring and a streaming agent cost one frame
// rate, not two.
beat *ui.LoopBeat
// sent is when the window last wrote to a PTY. What comes back in the
// next echoWindow is the answer to a keystroke, drawn at once.
sent atomic.Int64
renderer string
family string
size unit.Sp
// 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
Expand Down Expand Up @@ -193,11 +200,21 @@ func (ts *terminals) onOutput(id string, data []byte) {
return
}
t.vt.Write(data)
if shown {
if shown && !ts.rides(time.Now()) {
ts.invalidate()
}
}

// echoWindow is how long after a write to the PTY its output still counts as
// the echo of that write.
const echoWindow = 200 * time.Millisecond

// rides reports whether output at now can wait for the looping frame already
// due, which is never while a keystroke's echo may be in it.
func (ts *terminals) rides(now time.Time) bool {
return ts.beat.Pending(now) && now.UnixNano()-ts.sent.Load() > int64(echoWindow)
}

// onReplay seeds a terminal attach left waiting with its session's tail.
func (ts *terminals) onReplay(id string, tail []byte) {
ts.mu.Lock()
Expand Down Expand Up @@ -232,6 +249,7 @@ func (ts *terminals) send(id string, b []byte) {
if t != nil && t.readOnly.Load() {
return
}
ts.sent.Store(time.Now().UnixNano())
if err := stream.Send(context.Background(), id, b); err != nil {
log.Printf("send to %s: %v", id, err)
}
Expand Down
72 changes: 72 additions & 0 deletions native/cmd/lich-native/terminals_rides_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package main

import (
"testing"
"time"

"gioui.org/io/input"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/unit"

"github.com/lichdotdev/lich/native/gioterm"
"github.com/lichdotdev/lich/native/ui"
)

// ridingTerminals is a window's terminals with one shown session and the
// window's beat, counting the frames output asks for.
func ridingTerminals(t *testing.T) (*terminals, *ui.Theme, *int) {
t.Helper()
frames := 0
beat := &ui.LoopBeat{}
ts := &terminals{byID: map[string]*termView{}, shown: map[string]bool{"s": true}, beat: beat, invalidate: func() { frames++ }}
vt, err := gioterm.New(80, 24, 10, 20, func([]byte) {})
if err != nil {
t.Fatal(err)
}
tv := &termView{id: "s", vt: vt}
tv.seeded.Store(true)
ts.byID["s"] = tv
th := &ui.Theme{Palette: ui.Dark, Surface: ui.Dark.Background, Beat: beat}
return ts, th, &frames
}

// spin draws a looping animation at now, as a busy ring does each frame.
func spin(th *ui.Theme, now time.Time) {
var r input.Router
gtx := layout.Context{Ops: new(op.Ops), Now: now, Source: r.Source(), Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1}}
th.Loop(gtx)
}

func TestOutputRidesTheLoopingFrame(t *testing.T) {
ts, th, frames := ridingTerminals(t)
ts.onOutput("s", []byte("a"))
if *frames != 1 {
t.Fatalf("with nothing looping, output asked for %d frames, want its own", *frames)
}
spin(th, time.Now())
ts.onOutput("s", []byte("b"))
if *frames != 1 {
t.Errorf("with a looping frame due, output asked for %d frames, want to ride it", *frames)
}
spin(th, time.Now().Add(-ui.LoopFrame))
ts.onOutput("s", []byte("c"))
if *frames != 2 {
t.Errorf("with the looping frame past due, output asked for %d frames, want its own", *frames)
}
}

func TestAKeystrokesEchoNeverWaits(t *testing.T) {
ts, th, frames := ridingTerminals(t)
spin(th, time.Now())
ts.sent.Store(time.Now().UnixNano())
ts.onOutput("s", []byte("a"))
if *frames != 1 {
t.Errorf("output right after a write to the PTY asked for %d frames, want one at once", *frames)
}
ts.sent.Store(time.Now().Add(-echoWindow - time.Millisecond).UnixNano())
ts.onOutput("s", []byte("b"))
if *frames != 1 {
t.Errorf("output past the echo window asked for %d frames, want to ride the loop", *frames)
}
}
29 changes: 24 additions & 5 deletions native/ui/spinner.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"image"
"image/color"
"math"
"sync/atomic"
"time"

"gioui.org/f32"
Expand All @@ -21,16 +22,34 @@ const spinLap = time.Second

// LoopFrame is how often a looping animation (a spinner, the busy ring, a
// running service's beat) redraws. Every frame repaints the whole window, so
// this is the window's rate while one runs: 30fps reads as smooth on a glyph,
// where the display's rate cost 60% of a laptop GPU (measured 2026-11-01).
const LoopFrame = 33 * time.Millisecond
// this is the window's rate while one runs: 24fps, film's rate, reads as
// smooth on a glyph, where the display's rate cost 60% of a laptop GPU
// (measured 2026-11-01).
const LoopFrame = 42 * time.Millisecond

// LoopBeat is when the next looping frame is due, shared by every Theme of a
// window: output that arrives before it waits for that frame instead of
// asking for one of its own.
type LoopBeat struct {
due atomic.Int64
}

// Pending reports whether a looping frame is due after now.
func (b *LoopBeat) Pending(now time.Time) bool {
return b != nil && b.due.Load() > now.UnixNano()
}

// Loop asks for a looping animation's next frame, LoopFrame from now, unless
// the window is Still: then the animation holds the frame it stands at.
func (th *Theme) Loop(gtx layout.Context) {
if !th.Still {
gtx.Execute(op.InvalidateCmd{At: gtx.Now.Add(LoopFrame)})
if th.Still {
return
}
at := gtx.Now.Add(LoopFrame)
if th.Beat != nil {
th.Beat.due.Store(at.UnixNano())
}
gtx.Execute(op.InvalidateCmd{At: at})
}

var icSpinner = icons.Lucide("loader-circle")
Expand Down
28 changes: 25 additions & 3 deletions native/ui/spinner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ func loopWake(w func(C) D) (time.Time, bool) {

// Every frame of a looping animation repaints the whole window: one asking
// at the display's rate held a laptop GPU at 60%, so the next frame comes
// 33ms later, never at once.
func TestLoopingAnimationsRedrawAt30fps(t *testing.T) {
// 42ms later, never at once.
func TestLoopingAnimationsRedrawAt24fps(t *testing.T) {
th := testTheme()
want := time.Unix(100, 0).Add(33 * time.Millisecond)
want := time.Unix(100, 0).Add(42 * time.Millisecond)
for name, w := range map[string]func(C) D{
"Spinner": func(gtx C) D { return th.Spinner(gtx, 16, th.Foreground) },
"BusyRing": func(gtx C) D { return th.BusyRing(gtx, 16, th.Foreground, th.Border) },
Expand All @@ -32,6 +32,28 @@ func TestLoopingAnimationsRedrawAt30fps(t *testing.T) {
}
}

func TestLoopRecordsTheFrameItAskedFor(t *testing.T) {
th := testTheme()
th.Beat = &LoopBeat{}
now := time.Unix(100, 0)
if th.Beat.Pending(now) {
t.Fatal("a beat nobody asked for is pending")
}
loopWake(func(gtx C) D { return th.Spinner(gtx, 16, th.Foreground) })
if !th.Beat.Pending(now.Add(41 * time.Millisecond)) {
t.Error("41ms after the spinner drew, its frame is still to come")
}
if th.Beat.Pending(now.Add(42 * time.Millisecond)) {
t.Error("42ms after the spinner drew, its frame is due")
}
th.Still = true
th.Beat = &LoopBeat{}
loopWake(func(gtx C) D { return th.Spinner(gtx, 16, th.Foreground) })
if th.Beat.Pending(now) {
t.Error("a still window's spinner recorded a frame it did not ask for")
}
}

func TestStillThemeHoldsTheFrame(t *testing.T) {
th := testTheme()
th.Still = true
Expand Down
3 changes: 3 additions & 0 deletions native/ui/theme.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ type Theme struct {
// Still is set while the window has no focus: looping animations hold
// their frame instead of repainting a window nobody is looking at.
Still bool
// Beat is the window's, kept across On: where Loop records the frame
// it asked for.
Beat *LoopBeat
}

// On returns a copy of th drawing on surface, for the children of a widget
Expand Down
Loading