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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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.
- Reopening the window no longer types stray text into a session. Rebuilding a terminal from its recent output answered the terminal queries in it again, such as the ones nvim sends at start, and the answers landed on the session's input: in a shell as a `command not found`, in an agent as text in its prompt.
- A session's terminal opened while the session is printing no longer misses what it printed in that moment. The backend now hands the window the session's recent output in line with its live output, so nothing between the two is lost or shown twice.
- On Linux, stepping the terminal font size and opening a card for the first time hitch less. Every terminal looked its font up again through fontconfig each time, four lookups of about 10ms apiece inside one frame; lich now remembers the answer for the rest of its run.
- Stepping the terminal font size no longer reloads the font of every open terminal: each one keeps the font it has and only draws it at the new size.
- A session printing a lot of output costs the backend less. Each byte on its way to the window was copied about seven times into memory the garbage collector then had to reclaim; it is now copied once.

## [0.78.0] - 2026-10-22

Expand Down
2 changes: 1 addition & 1 deletion api/terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ var (
TerminalWrite = define[WriteOptions, None]("terminal.Write")
TerminalResize = define[ResizeOptions, None]("terminal.Resize")
TerminalSetVisible = define[SetVisibleOptions, None]("terminal.SetVisible")
TerminalReplay = define[SessionIDOptions, string]("terminal.Replay")
TerminalReplay = define[SessionIDOptions, bool]("terminal.Replay")
TerminalLive = define[SessionIDOptions, bool]("terminal.Live")
TerminalLiveCount = define[None, int]("terminal.LiveCount")
TerminalReady = define[SessionIDOptions, bool]("terminal.Ready")
Expand Down
2 changes: 1 addition & 1 deletion api/testdata/contract.golden
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ terminal.LastTurnSaid(SessionIDOptions) LastSaid
terminal.Live(SessionIDOptions) bool
terminal.LiveCount(None) int
terminal.Ready(SessionIDOptions) bool
terminal.Replay(SessionIDOptions) string
terminal.Replay(SessionIDOptions) bool
terminal.Resize(ResizeOptions) None
terminal.ResumeAvailable(ResumeAvailableOptions) bool
terminal.SearchTranscripts(SearchTranscriptsOptions) []TranscriptMatch
Expand Down
4 changes: 4 additions & 0 deletions docs/ceilings.md
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,10 @@ work when nobody knows it and that the call site never shows. The mechanism and
and macOS leave out the dual-width Nerd Font families (the ones not named "Mono", whose icons take two cells)
that fontconfig's `spacing=dual` lists on Linux; the "Mono" variant of each is offered. Its family names come
from parsing one file per family, a few hundred on a stock Windows, the first time a font picker opens.
On Linux each face fontconfig names is kept for the rest of the run (`fonts.Locate`), since every lookup is an
`fc-match` subprocess inside a frame: a family installed while lich runs, after a terminal or the editor
already asked for it, keeps drawing in the substitute fontconfig gave until lich restarts. Choosing it in a
font picker for the first time is not affected.
- **The Chinese font the native window downloads reaches its interface, not its terminal on Linux**
(`native/fonts/download.go`, `native/glyphs/raster.go`): the terminal finds a glyph its family lacks through
fontconfig (`fonts.Covering`), which never sees `<user cache dir>/lich/fonts`, so with no Chinese font
Expand Down
19 changes: 18 additions & 1 deletion internal/terminal/coalescer.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,23 @@ func (c *coalescer) Close() {
c.flushLocked()
}

// cut drops the pending output and runs then with the coalescer held, so no
// output is emitted between the two: the replay a cut queues already holds
// what was pending. A closed coalescer runs nothing.
func (c *coalescer) cut(then func()) {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return
}
if c.timer != nil {
c.timer.Stop()
c.timer = nil
}
c.pending = c.pending[:0]
then()
}

// flushLocked emits pending output and disarms the timer. Caller holds mu.
func (c *coalescer) flushLocked() {
if c.timer != nil {
Expand All @@ -116,7 +133,7 @@ func (c *coalescer) flushLocked() {
return
}
c.emit(c.pending)
c.pending = nil
c.pending = c.pending[:0]
c.lastEmit = time.Now()
}

Expand Down
33 changes: 33 additions & 0 deletions internal/terminal/feed_bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package terminal

import (
"testing"
"time"
)

// BenchmarkHiddenFlood feeds PTY reads through a hidden session's output path
// the way stream does (replay tail, coalescer, outbox, the /ws frame) and
// reports what that path allocates per byte of output.
func BenchmarkHiddenFlood(b *testing.B) {
read := make([]byte, readBufSize)
for i := range read {
read[i] = 'x'
}
replay := newReplayBuffer(replayCapBytes)
header, err := encodeFrame("session-id", nil)
if err != nil {
b.Fatal(err)
}
box := newOutbox(header, func([]byte) {}, func([]byte) {}, outboxDepth)
out := newCoalescer(box.push, visibleFlushInterval, time.Hour)
out.SetVisible(false)
b.SetBytes(int64(len(read)))
b.ReportAllocs()
b.ResetTimer()
for range b.N {
replay.append(read)
out.Write(read)
}
out.Close()
box.close()
}
26 changes: 17 additions & 9 deletions internal/terminal/io.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package terminal

import (
"context"
"encoding/base64"
"math"
"time"

Expand All @@ -25,8 +24,10 @@ func (s *Service) stream(id string, sess *session) {
n, err := p.Read(buf)
if n > 0 {
s.noteOutput(id, sess, buf[:n])
sess.feed.Lock()
sess.replay.append(buf[:n])
sess.out.Write(buf[:n])
sess.feed.Unlock()
}
if err != nil {
break
Expand Down Expand Up @@ -138,19 +139,26 @@ func (s *Service) SetVisible(_ context.Context, opts api.SetVisibleOptions) (api
return api.None{}, nil
}

// Replay returns the capped tail of a session's PTY output, base64-encoded, so a
// window attaching to a running session can reseed a fresh terminal's
// scrollback. Empty for an unknown session — a brand-new one has no
// history yet. Base64 for the same reason the data event is: raw PTY bytes may
// split a multi-byte UTF-8 sequence across the JSON envelope.
func (s *Service) Replay(_ context.Context, opts api.SessionIDOptions) (string, error) {
// Replay queues the capped tail of a session's PTY output on /ws as a replay
// frame, so a window attaching to a running session can reseed a fresh
// terminal's scrollback: the output frames before it are in the tail, and the
// ones after it are not. It reports whether a replay frame is coming, false
// for an unknown session, which a brand-new one is: it has no history yet.
func (s *Service) Replay(_ context.Context, opts api.SessionIDOptions) (bool, error) {
s.mu.Lock()
sess, ok := s.sessions[opts.ID]
s.mu.Unlock()
if !ok {
return "", nil
return false, nil
}
return base64.StdEncoding.EncodeToString(sess.replay.snapshot()), nil
queued := false
sess.feed.Lock()
defer sess.feed.Unlock()
sess.out.cut(func() {
sess.outbox.pushReplay(sess.replay.snapshot())
queued = true
})
return queued, nil
}

// Resize updates a session's PTY window size. The window only calls this for
Expand Down
42 changes: 33 additions & 9 deletions internal/terminal/outbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,27 +18,51 @@ const outboxDepth = 32
// once the queue is full, that session's PTY. The backpressure that remains is
// honest — it reaches the session that is producing, never its neighbours.
type outbox struct {
frames chan []byte
frames chan outFrame
done chan struct{}
// prefix heads every output frame: the /ws frame header of its session.
prefix []byte
}

// newOutbox starts the delivery goroutine. deliver is called with one frame at
// a time, in order, and owns the slice it is handed.
func newOutbox(deliver func(data []byte), depth int) *outbox {
o := &outbox{frames: make(chan []byte, depth), done: make(chan struct{})}
type outFrame struct {
data []byte
replay bool
}

// newOutbox starts the delivery goroutine. deliver is called with each output
// frame, the pushed output behind prefix, and replay with each replay frame's
// tail, one at a time and in the order they were queued; both own the slice
// they are handed.
func newOutbox(prefix []byte, deliver, replay func(data []byte), depth int) *outbox {
o := &outbox{frames: make(chan outFrame, depth), done: make(chan struct{}), prefix: prefix}
go func() {
defer close(o.done)
for frame := range o.frames {
deliver(frame)
if frame.replay {
replay(frame.data)
continue
}
deliver(frame.data)
}
}()
return o
}

// push queues one frame for delivery, blocking only while this session's queue
// is full. The frame is copied: the coalescer reuses the buffer it passes.
// push queues data behind the prefix for delivery, blocking only while this
// session's queue is full. data is copied into the frame, which is the one
// copy output takes on its way to the socket: the coalescer reuses the buffer
// it passes.
func (o *outbox) push(data []byte) {
o.frames <- append([]byte(nil), data...)
frame := make([]byte, len(o.prefix)+len(data))
copy(frame, o.prefix)
copy(frame[len(o.prefix):], data)
o.frames <- outFrame{data: frame}
}

// pushReplay queues the session's replayed tail behind every frame already
// queued, so whoever reads it knows what came before the tail and what after.
func (o *outbox) pushReplay(tail []byte) {
o.frames <- outFrame{data: tail, replay: true}
}

// close stops the queue and waits for the frames still in it to be delivered,
Expand Down
10 changes: 5 additions & 5 deletions internal/terminal/outbox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ func TestOutboxQueuesWhileDeliveryIsStuck(t *testing.T) {
t.Parallel()
release := make(chan struct{})
entered := make(chan struct{}, 1)
box := newOutbox(func([]byte) {
box := newOutbox(nil, func([]byte) {
select {
case entered <- struct{}{}:
default:
}
<-release
}, 2)
}, nil, 2)

// The first frame is taken by the (stuck) delivery goroutine; two more fit
// the queue.
Expand Down Expand Up @@ -64,11 +64,11 @@ func TestOutboxDeliversInOrderAndDrainsOnClose(t *testing.T) {
t.Parallel()
var mu sync.Mutex
var got []string
box := newOutbox(func(data []byte) {
box := newOutbox(nil, func(data []byte) {
mu.Lock()
got = append(got, string(data))
mu.Unlock()
}, 4)
}, nil, 4)

for _, frame := range []string{"a", "b", "c", "d", "e"} {
box.push([]byte(frame))
Expand All @@ -87,7 +87,7 @@ func TestOutboxDeliversInOrderAndDrainsOnClose(t *testing.T) {
func TestOutboxCopiesTheFrame(t *testing.T) {
t.Parallel()
delivered := make(chan []byte, 1)
box := newOutbox(func(data []byte) { delivered <- data }, 1)
box := newOutbox(nil, func(data []byte) { delivered <- data }, nil, 1)

buf := []byte("live")
box.push(buf)
Expand Down
53 changes: 28 additions & 25 deletions internal/terminal/replay.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,50 +11,53 @@ const replayCapBytes = 2 << 20
// replayBuffer keeps a capped tail of a session's raw PTY output so a
// window attaching to a running session can reseed its scrollback: the PTY
// (and this tail) live on in the backend while no window holds the terminal.
// A chunk list dropped from the front on overflow, so after a drop the head
// may be a partial ANSI sequence, an accepted artifact (TUIs repaint, shells
// scroll it away). Safe for concurrent append (the stream goroutine) and
// snapshot (an RPC call).
// One buffer, grown as output arrives up to the cap and then overwritten from
// its oldest byte, so a read costs a copy and no allocation. After the first
// overwrite the head may be a partial ANSI or UTF-8 sequence, an accepted
// artifact (TUIs repaint, shells scroll it away). Safe for concurrent append
// (the stream goroutine) and snapshot (an RPC call).
type replayBuffer struct {
mu sync.Mutex
chunks [][]byte
total int
mu sync.Mutex
buf []byte
// oldest is where the tail starts in buf once buf holds capBytes.
oldest int
capBytes int
}

func newReplayBuffer(capBytes int) *replayBuffer {
return &replayBuffer{capBytes: capBytes}
}

// append copies data (the caller reuses its read buffer) and enqueues it,
// dropping the oldest chunks while the total exceeds the cap. One chunk is
// always kept, so a single write larger than the cap still replays.
// append copies data (the caller reuses its read buffer) into the tail,
// overwriting the oldest bytes once the tail holds capBytes. A write larger
// than the cap keeps its last capBytes.
func (b *replayBuffer) append(data []byte) {
if len(data) == 0 {
return
}
chunk := make([]byte, len(data))
copy(chunk, data)

b.mu.Lock()
defer b.mu.Unlock()
b.chunks = append(b.chunks, chunk)
b.total += len(chunk)
for b.total > b.capBytes && len(b.chunks) > 1 {
b.total -= len(b.chunks[0])
// Reslicing alone keeps the chunk reachable through the backing array.
b.chunks[0] = nil
b.chunks = b.chunks[1:]
if len(data) > b.capBytes {
data = data[len(data)-b.capBytes:]
}
if room := b.capBytes - len(b.buf); room > 0 {
n := min(room, len(data))
b.buf = append(b.buf, data[:n]...)
data = data[n:]
}
for len(data) > 0 {
n := copy(b.buf[b.oldest:], data)
b.oldest = (b.oldest + n) % b.capBytes
data = data[n:]
}
}

// snapshot returns the queued tail as one contiguous slice in arrival order.
// snapshot returns the tail as one contiguous slice in arrival order.
func (b *replayBuffer) snapshot() []byte {
b.mu.Lock()
defer b.mu.Unlock()
out := make([]byte, 0, b.total)
for _, c := range b.chunks {
out = append(out, c...)
}
out := make([]byte, len(b.buf))
n := copy(out, b.buf[b.oldest:])
copy(out[n:], b.buf[:b.oldest])
return out
}
Loading
Loading