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
21 changes: 20 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.1.0] - 2026-07-12

### Changed

- Terminal rendering is ~4× faster under heavy TUI load (nvim scroll worst-case
Expand Down Expand Up @@ -89,5 +91,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Coalesced PTY output of hidden sessions in the backend to one event per 250 ms,
flushed immediately when the session is shown.
- Skipped the resize-driven refit for hidden terminals; they refit once on show.
- Shared one git-status poller per repository path with equality bailout: with
20+ session cards the idle burst of ~44 IPC calls and ~88 git subprocesses
every 3 s collapses to one fetch per path, and unchanged status no longer
re-renders anything.
- Removed the per-cell defensive copy in ghostty-web's `getLine` (pool-backed
row references), gated rendering while scrolled with nothing dirty, and
memoized scrollback lines — reading scrollback now costs ~0 paint, and heavy
TUI throughput renders at ~40fps instead of ~25.
- Terminal I/O now flows over a local binary WebSocket (random loopback port,
token-authenticated) instead of one Wails HTTP call per keystroke and one
`evaluate_javascript` per output chunk; falls back to the Wails paths
automatically if the socket drops.
- Forced `GDK_BACKEND=x11` on Linux (only when unset): WebKitGTK under Wayland
fractional scaling rendered every damage frame at 2x and downsampled on the
CPU, costing ~40ms per frame in a full-size window. Under Xwayland typing is
stall-free at full frame rate.

[Unreleased]: https://github.com/omartelo/lich/commits/main
[Unreleased]: https://github.com/omartelo/lich/compare/v0.1.0...HEAD
[0.1.0]: https://github.com/omartelo/lich/releases/tag/v0.1.0
23 changes: 20 additions & 3 deletions frontend/src/components/TerminalView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { patchFontMetrics } from "@/lib/font-metrics"
import { patchGlyphAtlas } from "@/lib/glyph-atlas"
import { patchPooledGetLine } from "@/lib/getline-pool"
import { patchScrollGate, patchScrollbackCache } from "@/lib/scrollback-perf"
import { ensureTransport, onSessionData, sendInput } from "@/lib/term-transport"
import { pauseRenderLoop, resumeRenderLoop } from "@/lib/render-pause"
import { patchRowPaint } from "@/lib/row-paint"
import { isTextPasteChord, missingKeySequence } from "@/lib/term-keys"
Expand Down Expand Up @@ -175,6 +176,15 @@ export function TerminalView({ sessionId, projectId, cwd, kind, visible }: Termi
patchScrollbackCache(term)
fitTerminal(term, container)
termRef.current = term
ensureTransport()

// Input goes over the local WebSocket when it is up; otherwise the
// Wails binding. Both land in the same PTY write on the backend.
const writeInput = (data: string) => {
if (!sendInput(sessionId, data)) {
void Service.Write(sessionId, data)
}
}

// Sequences ghostty-web 0.4.0 gets wrong (Shift+Tab, Alt chords) are
// written to the PTY directly; returning true stops the terminal from
Expand All @@ -195,11 +205,11 @@ export function TerminalView({ sessionId, projectId, cwd, kind, visible }: Termi
if (seq === null) {
return false
}
void Service.Write(sessionId, seq)
writeInput(seq)
return true
})

const dataInput = term.onData((data) => Service.Write(sessionId, data))
const dataInput = term.onData(writeInput)
const resizeInput = term.onResize(({ cols, rows }) => {
if (visibleRef.current) {
void Service.Resize(sessionId, cols, rows)
Expand Down Expand Up @@ -228,10 +238,17 @@ export function TerminalView({ sessionId, projectId, cwd, kind, visible }: Termi
term.write(bytes)
recordChunk(t1 - t0, performance.now() - t1, bytes.length)
})
// Output arrives here while the WebSocket is up, on the Wails event
// above while it is down; the backend routes each chunk to exactly one.
const offWsData = onSessionData(sessionId, (payload) => {
const t0 = performance.now()
term.write(payload)
recordChunk(0, performance.now() - t0, payload.length)
})
const offExit = Events.On(EXIT_EVENT_PREFIX + sessionId, () => {
term.write("\r\n[process exited]\r\n")
})
cleanups.push(offData, offExit)
cleanups.push(offData, offWsData, offExit)

const resizeObserver = new ResizeObserver(scheduleRefit)
resizeObserver.observe(container)
Expand Down
36 changes: 36 additions & 0 deletions frontend/src/lib/term-frame.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {describe, expect, it} from "vitest"
import {decodeFrame, encodeFrame} from "./term-frame"

describe("term-frame", () => {
it("round-trips id and payload", () => {
const frame = encodeFrame("sess-1", new Uint8Array([1, 2, 3]))
expect(frame).not.toBeNull()
const decoded = decodeFrame(frame as Uint8Array)
expect(decoded?.id).toBe("sess-1")
expect(Array.from(decoded?.payload ?? [])).toEqual([1, 2, 3])
})

it("round-trips an empty payload", () => {
const frame = encodeFrame("s", new Uint8Array(0))
const decoded = decodeFrame(frame as Uint8Array)
expect(decoded?.id).toBe("s")
expect(decoded?.payload).toHaveLength(0)
})

it("handles multi-byte ids", () => {
const frame = encodeFrame("sessão-1", new Uint8Array([9]))
const decoded = decodeFrame(frame as Uint8Array)
expect(decoded?.id).toBe("sessão-1")
})

it("rejects invalid ids", () => {
expect(encodeFrame("", new Uint8Array(0))).toBeNull()
expect(encodeFrame("x".repeat(256), new Uint8Array(0))).toBeNull()
})

it("rejects malformed frames", () => {
expect(decodeFrame(new Uint8Array(0))).toBeNull()
expect(decodeFrame(new Uint8Array([0, 65]))).toBeNull()
expect(decodeFrame(new Uint8Array([10, 65]))).toBeNull()
})
})
35 changes: 35 additions & 0 deletions frontend/src/lib/term-frame.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Binary frame codec for the local terminal I/O WebSocket, mirroring
// internal/terminal/transport.go: [1 byte id length][session id][payload].

export interface TermFrame {
id: string
payload: Uint8Array
}

const MAX_ID_BYTES = 255

export function encodeFrame(id: string, payload: Uint8Array): Uint8Array | null {
const idBytes = new TextEncoder().encode(id)
if (idBytes.length === 0 || idBytes.length > MAX_ID_BYTES) {
return null
}
const frame = new Uint8Array(1 + idBytes.length + payload.length)
frame[0] = idBytes.length
frame.set(idBytes, 1)
frame.set(payload, 1 + idBytes.length)
return frame
}

export function decodeFrame(frame: Uint8Array): TermFrame | null {
if (frame.length < 1) {
return null
}
const idLength = frame[0]
if (idLength === 0 || frame.length < 1 + idLength) {
return null
}
return {
id: new TextDecoder().decode(frame.subarray(1, 1 + idLength)),
payload: frame.subarray(1 + idLength),
}
}
27 changes: 27 additions & 0 deletions frontend/src/lib/term-perf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,30 @@ const stats = {
renderMs: 0,
worstRenderMs: 0,
sprites: 0,
rafMs: 0,
rafN: 0,
rafWorstMs: 0,
}
let started = false

// wrapRaf measures every requestAnimationFrame callback so rAF work (ghostty
// render loops, scrollbar fades) can be separated from engine work (paint,
// GC, IPC dispatch) inside a stall: raf≈stall means the callback is guilty,
// raf≪stall means the engine is. Loops that started before the wrap pick it
// up on their next self-rescheduled frame.
function wrapRaf(): void {
const original = window.requestAnimationFrame.bind(window)
window.requestAnimationFrame = (callback) =>
original((time) => {
const t0 = performance.now()
callback(time)
const dt = performance.now() - t0
stats.rafMs += dt
stats.rafN++
stats.rafWorstMs = Math.max(stats.rafWorstMs, dt)
})
}

// instrumentRender wraps a ghostty renderer's render() so paint cost shows up
// in the per-second report — it runs inside ghostty's own rAF, invisible to
// the decode/write timings.
Expand Down Expand Up @@ -68,6 +89,7 @@ function start(): void {
return
}
started = true
wrapRaf()

// rAF gap watcher: frames >33ms are main-thread stalls not accounted for by
// decode/write — eval of the event payload, ghostty paint, GC, React.
Expand Down Expand Up @@ -100,6 +122,8 @@ function start(): void {
`render=${stats.renderMs.toFixed(1)}ms n=${stats.renders} ` +
`maxRender=${stats.worstRenderMs.toFixed(0)}ms ` +
`sprites/s=${stats.sprites} ` +
`raf=${stats.rafMs.toFixed(0)}ms rafN=${stats.rafN} ` +
`rafWorst=${stats.rafWorstMs.toFixed(0)}ms ` +
`stalls=${stats.stalls} worst=${stats.worstMs.toFixed(0)}ms` +
(stallAt.length > 0 ? ` at=${stallAt.join(",")}` : ""),
)
Expand All @@ -114,5 +138,8 @@ function start(): void {
stats.renderMs = 0
stats.worstRenderMs = 0
stats.sprites = 0
stats.rafMs = 0
stats.rafN = 0
stats.rafWorstMs = 0
}, 1000)
}
85 changes: 85 additions & 0 deletions frontend/src/lib/term-transport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Local WebSocket transport for terminal I/O, replacing the Wails bridge for
// the hot path: every Service.Write is an HTTP fetch through the WebKit
// network process and every data event an evaluate_javascript call — ~60
// engine crossings/s while typing, measured as ~40ms main-thread stall trains
// (2026-07-10). One binary-frame socket carries input and output for every
// session; when it is down, callers fall back to the Wails paths, which the
// backend keeps serving.

import {Service} from "../../bindings/github.com/omartelo/lich/internal/terminal"
import {decodeFrame, encodeFrame} from "./term-frame"

const RECONNECT_MS = 1_000

let socket: WebSocket | null = null
let ready = false
let starting = false
const handlers = new Map<string, (payload: Uint8Array) => void>()

// ensureTransport starts the singleton connection; safe to call from every
// terminal mount. A backend without a transport (port 0) leaves the Wails
// bridge as the permanent path.
export function ensureTransport(): void {
if (starting) {
return
}
starting = true
void connect()
}

async function connect(): Promise<void> {
try {
const info = await Service.Transport()
if (!info.port) {
return
}
const ws = new WebSocket(`ws://127.0.0.1:${info.port}/ws?token=${info.token}`)
ws.binaryType = "arraybuffer"
ws.onopen = () => {
ready = true
}
ws.onclose = () => {
ready = false
socket = null
setTimeout(() => void connect(), RECONNECT_MS)
}
ws.onmessage = (event) => {
const frame = decodeFrame(new Uint8Array(event.data as ArrayBuffer))
if (frame) {
handlers.get(frame.id)?.(frame.payload)
}
}
socket = ws
} catch {
setTimeout(() => void connect(), RECONNECT_MS)
}
}

// sendInput delivers keyboard data for a session. Returns false when the
// socket is down so the caller falls back to Service.Write.
export function sendInput(sessionId: string, data: string): boolean {
if (!ready || !socket) {
return false
}
const frame = encodeFrame(sessionId, new TextEncoder().encode(data))
if (!frame) {
return false
}
socket.send(frame)
return true
}

// onSessionData registers the output sink for one session. The backend routes
// output through the socket only while it is connected, so subscribing here
// alongside the Wails event never double-delivers.
export function onSessionData(
sessionId: string,
callback: (payload: Uint8Array) => void,
): () => void {
handlers.set(sessionId, callback)
return () => {
if (handlers.get(sessionId) === callback) {
handlers.delete(sessionId)
}
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ module github.com/omartelo/lich
go 1.25.0

require (
github.com/coder/websocket v1.8.14
github.com/creack/pty v1.1.24
github.com/wailsapp/wails/v3 v3.0.0-alpha2.116
modernc.org/sqlite v1.53.0
)

require (
github.com/adrg/xdg v0.5.3 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
Expand Down
22 changes: 20 additions & 2 deletions internal/terminal/terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,20 @@ type Service struct {
mu sync.Mutex
sessions map[string]*session
bins BinResolver
// ws is the local WebSocket transport for terminal I/O (see transport.go);
// nil when it failed to start, leaving the Wails event bridge as the path.
ws *transport
}

// New returns a ready-to-use terminal service that resolves the binary to spawn
// through bins.
func New(bins BinResolver) *Service {
return &Service{sessions: make(map[string]*session), bins: bins}
s := &Service{sessions: make(map[string]*session), bins: bins}
ws, err := newTransport(func(id string, data []byte) { _ = s.writeBytes(id, data) })
if err == nil {
s.ws = ws
}
return s
}

// defaultBin is the Claude Code binary spawned when the user has not configured
Expand Down Expand Up @@ -119,6 +127,9 @@ func (s *Service) Start(id, projectID, cwd, kind string, cols, rows int) error {
}

out := newCoalescer(func(data []byte) {
if s.ws != nil && s.ws.send(id, data) {
return
}
encoded := base64.StdEncoding.EncodeToString(data)
application.Get().Event.Emit(dataEventPrefix+id, encoded)
}, visibleFlushInterval, hiddenFlushInterval)
Expand Down Expand Up @@ -158,11 +169,18 @@ func (s *Service) stream(id string, ptmx *os.File, cmd *exec.Cmd, out *coalescer

// Write forwards keyboard input from the frontend to a session's PTY.
func (s *Service) Write(id, data string) error {
return s.writeBytes(id, []byte(data))
}

// writeBytes delivers input bytes to a session's PTY; unknown sessions are a
// no-op. It is the shared sink for the Wails binding and the WebSocket
// transport's input frames.
func (s *Service) writeBytes(id string, data []byte) error {
ptmx := s.ptmxOf(id)
if ptmx == nil {
return nil
}
_, err := ptmx.Write([]byte(data))
_, err := ptmx.Write(data)
return err
}

Expand Down
Loading