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 @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- On macOS, lich has a menu-bar item: a lich whose window is closed shows it is still running its sessions there, with Show lich, the count of running sessions and Quit, as the tray does on Linux and Windows.

### Changed

- The quick filters over the pull request list in Pulls (All, Ready, Drafts, Failing) are lighter: small text with the selected one shaded, as in the dock's tabs, sharing the list's width. A filter with nothing in it is faded and Failing's count turns red when a check fails. They no longer run past the list's edge with a monospace interface font.
Expand All @@ -15,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- A session card no longer grows and shrinks as its agent starts and stops a tool, waits on you or compacts, pushing every card below it up and down. That line now takes the place of the card's path while it shows, and the path comes back when it goes.
- The native window holds less memory for its fonts. A font file asked for more than once, such as the interface and the terminal set in the same family or the bold face that also stands in for semibold, was parsed again each time, about 30MB for a Nerd Font face; it is now parsed once and shared while in use. An interface in a language other than Chinese, Japanese or Korean no longer loads a CJK font at start, tens of megabytes: it does on the first CJK text on screen, drawn meanwhile with a system font. With one Nerd Font for the interface, the terminal and the editor and no CJK on screen, the fonts take 119MB where they took 246MB.
- On macOS, opening lich from the Dock, Launchpad, Spotlight or Finder while it runs with its window closed opens the window again. lich started at login with `--no-window` included, where the click did nothing. Logging out now quits lich the way `lich quit` does, so the next launch does not read it as a crash.

## [0.78.1] - 2026-10-23

Expand Down
3 changes: 2 additions & 1 deletion build/darwin/Info.plist.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
Rendered by build/darwin/bundle.sh (@VERSION@ -> the release version).

Two executables share this plist: lich, the pure-Go binary LaunchServices
starts, which never touches AppKit, and lich-native, the window, which does.
starts, which reaches AppKit through purego for the menu-bar item and the
reopen (internal/appkit), and lich-native, the window.
macOS reads a process's bundle off its executable's path, so the window in
Contents/MacOS is Lich.app to the Dock, to Cmd-Tab and to the menu bar: its
icon is CFBundleIconFile and its name CFBundleName.
Expand Down
7 changes: 5 additions & 2 deletions docs/ceilings.md
Original file line number Diff line number Diff line change
Expand Up @@ -840,8 +840,11 @@ work when nobody knows it and that the call site never shows. The mechanism and
icon (below) is all that says it runs. Launching lich again opens a window on it; `lich quit` (or SIGINT /
SIGTERM) ends it. `task dev` behaves the same: kept running, the dev backend stays on 47822 until Ctrl+C or
`LICH_DEV=1 go run . quit`.
- **The tray icon is Linux and Windows only, and late** (`internal/tray`): macOS's menu-bar item needs cgo
and lich is built without it, so a windowless lich on a Mac shows nothing. On Linux it is a
- **The tray icon is late, and on macOS it rides AppKit through purego** (`internal/tray`, `internal/appkit`):
lich is built without cgo, so on a Mac the backend runs AppKit's loop on its main thread through purego, as
an accessory app (no Dock tile, no Cmd-Tab), and the menu-bar item and LaunchServices' reopen and terminate
live on that loop. A Mac where AppKit does not load runs lich without either, and opening lich again from
Finder then depends on LaunchServices launching a second copy. On Linux it is a
StatusNotifierItem: GNOME draws none without the AppIndicator extension, and a desktop with no tray host
shows nothing either. Its words are the window's interface language, so it comes up once a window has
handed them over (`native/cmd/lich-native/tray.go`); a lich whose window never opened has no tray. `task dev` puts up a second
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/UserExistsError/conpty v0.1.4
github.com/coder/websocket v1.8.14
github.com/creack/pty v1.1.24
github.com/ebitengine/purego v0.11.2
github.com/lichdotdev/godemirror v0.8.1
github.com/ncruces/zenity v0.10.14
golang.org/x/image v0.47.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ github.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f h1:OGqDDftRTwrvUoL6pO
github.com/dchest/jsmin v0.0.0-20220218165748-59f39799265f/go.mod h1:Dv9D0NUlAsaQcGQZa5kc5mqR9ua72SmA8VXi4cd+cBw=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.11.2 h1:WWiTx8rD8zYdOwSj8O/NiNbsOvSAgJMTlyV1XlbUSeU=
github.com/ebitengine/purego v0.11.2/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
Expand Down
132 changes: 132 additions & 0 deletions internal/appkit/appkit_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package appkit

import (
"log/slog"
"os"
"runtime"
"sync"

"github.com/ebitengine/purego"
"github.com/ebitengine/purego/objc"
)

// AppKit runs the main thread's loop only if the main goroutine still holds
// that thread when Run is called, and an init is the one place to pin it.
func init() {
runtime.LockOSThread()
}

const appKitPath = "/System/Library/Frameworks/AppKit.framework/AppKit"

// NSApplicationActivationPolicyAccessory keeps the backend off the Dock and
// out of Cmd-Tab: the window, lich-native, is the app a person sees there.
const activationPolicyAccessory = 1

// NSTerminateLater holds the logout until lich has quit on its own, through
// the same quit `lich quit` runs, instead of AppKit's exit skipping it.
const terminateLater = 2

var (
mu sync.Mutex
reopen func()
terminate func()
queue []func()
// runner is the object Do hands its queue to on the main thread; zero
// until Run brought AppKit up.
runner objc.ID
)

// Handle sets what a reopen (lich opened again while it runs) and a terminate
// (logout, shutdown) do. Either one arriving before Handle is dropped.
func Handle(onReopen, onTerminate func()) {
mu.Lock()
defer mu.Unlock()
reopen, terminate = onReopen, onTerminate
}

// Run runs app beside AppKit's loop and exits when app returns. A Mac where
// AppKit cannot load runs app alone, with no reopen and no menu-bar item.
func Run(app func()) {
nsApp, err := start()
if err != nil {
slog.Warn("appkit unavailable: no reopen and no menu-bar item", "err", err)
app()
return
}
go func() {
app()
os.Exit(0)
}()
nsApp.Send(objc.RegisterName("run"))
}

// Do runs fn on the main thread, where every AppKit call has to be made. It
// does not wait for fn.
func Do(fn func()) {
mu.Lock()
r := runner
if r != 0 {
queue = append(queue, fn)
}
mu.Unlock()
if r == 0 {
slog.Warn("appkit: not running, dropped a main-thread call")
return
}
r.Send(objc.RegisterName("performSelectorOnMainThread:withObject:waitUntilDone:"),
objc.RegisterName("drain:"), objc.ID(0), false)
}

func start() (objc.ID, error) {
if _, err := purego.Dlopen(appKitPath, purego.RTLD_GLOBAL|purego.RTLD_LAZY); err != nil {
return 0, err
}
class, err := objc.RegisterClass("LichAppDelegate", objc.GetClass("NSObject"), nil, nil, []objc.MethodDef{
{Cmd: objc.RegisterName("applicationShouldHandleReopen:hasVisibleWindows:"), Fn: handleReopen},
{Cmd: objc.RegisterName("applicationShouldTerminate:"), Fn: shouldTerminate},
{Cmd: objc.RegisterName("drain:"), Fn: drain},
})
if err != nil {
return 0, err
}
delegate := objc.ID(class).Send(objc.RegisterName("new"))
nsApp := objc.ID(objc.GetClass("NSApplication")).Send(objc.RegisterName("sharedApplication"))
nsApp.Send(objc.RegisterName("setActivationPolicy:"), activationPolicyAccessory)
nsApp.Send(objc.RegisterName("setDelegate:"), delegate)
mu.Lock()
runner = delegate
mu.Unlock()
return nsApp, nil
}

// handleReopen answers NO: lich opens its window itself, off the main thread,
// since opening one may start a process.
func handleReopen(_ objc.ID, _ objc.SEL, _ objc.ID, _ bool) bool {
mu.Lock()
fn := reopen
mu.Unlock()
if fn != nil {
go fn()
}
return false
}

func shouldTerminate(_ objc.ID, _ objc.SEL, _ objc.ID) uint {
mu.Lock()
fn := terminate
mu.Unlock()
if fn != nil {
go fn()
}
return terminateLater
}

func drain(_ objc.ID, _ objc.SEL, _ objc.ID) {
mu.Lock()
fns := queue
queue = nil
mu.Unlock()
for _, fn := range fns {
fn()
}
}
9 changes: 9 additions & 0 deletions internal/appkit/appkit_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//go:build !darwin

package appkit

// Run runs app: only macOS has an application loop lich has to run beside.
func Run(app func()) { app() }

// Handle does nothing: reopen and terminate are LaunchServices' events.
func Handle(onReopen, onTerminate func()) {}
7 changes: 7 additions & 0 deletions internal/appkit/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Package appkit makes lich's backend a macOS application, through purego
// rather than cgo: it runs AppKit's event loop on the main thread, which
// AppKit insists on, and the backend beside it. That loop is what hears
// LaunchServices: opening lich again while it runs reaches this process as a
// reopen, and logging out as a terminate. It is also the thread the menu-bar
// item lives on (internal/tray).
package appkit
139 changes: 136 additions & 3 deletions internal/tray/native_darwin.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,138 @@
package tray

// startNative puts nothing up on macOS: its menu-bar item needs cgo, and lich is
// built without it (docs/ceilings.md).
func startNative(*Tray) {}
import (
"log/slog"
"runtime"
"structs"
"time"
"unsafe"

"github.com/ebitengine/purego/objc"
"github.com/lichdotdev/lich/internal/appkit"
)

// NSVariableStatusItemLength: the item is as wide as its icon.
const variableStatusItemLength = -1.0

// iconPoints is the menu bar's icon size; the bar is 22 points tall.
const iconPoints = 18

type nsSize struct {
_ structs.HostLayout
Width, Height float64
}

// startNative puts the menu-bar item up on the main thread, where AppKit
// runs (internal/appkit).
func startNative(t *Tray) {
appkit.Do(t.ready)
}

func (t *Tray) icon() []byte { return t.icons.PNG }

// menu is the item's menu entries.
type menu struct {
show, running, quit objc.ID
}

func (t *Tray) ready() {
target, err := t.target()
if err != nil {
slog.Warn("tray: menu target", "err", err)
return
}
labels := t.currentLabels()
item := objc.ID(objc.GetClass("NSStatusBar")).Send(sel("systemStatusBar")).
Send(sel("statusItemWithLength:"), variableStatusItemLength)
// The status bar drops an item nothing holds.
item.Send(sel("retain"))
button := item.Send(sel("button"))
button.Send(sel("setImage:"), statusImage(t.icon()))
button.Send(sel("setToolTip:"), nsString("lich"))

m := menu{
show: menuItem(labels.Show, sel("show:"), target),
running: menuItem("", 0, 0),
quit: menuItem(labels.Quit, sel("quit:"), target),
}
nsMenu := objc.ID(objc.GetClass("NSMenu")).Send(sel("new"))
nsMenu.Send(sel("addItem:"), m.show)
nsMenu.Send(sel("addItem:"), m.running)
nsMenu.Send(sel("addItem:"), objc.ID(objc.GetClass("NSMenuItem")).Send(sel("separatorItem")))
nsMenu.Send(sel("addItem:"), m.quit)
item.Send(sel("setMenu:"), nsMenu)

t.setRelabel(func(l Labels) {
appkit.Do(func() {
m.show.Send(sel("setTitle:"), nsString(l.Show))
m.quit.Send(sel("setTitle:"), nsString(l.Quit))
})
t.refresh(m, l)
})
t.refresh(m, labels)
go t.serve(m)
}

// target registers the object the menu's entries call, which runs the tray's
// show and quit off the main thread.
func (t *Tray) target() (objc.ID, error) {
class, err := objc.RegisterClass("LichTrayTarget", objc.GetClass("NSObject"), nil, nil, []objc.MethodDef{
{Cmd: sel("show:"), Fn: func(_ objc.ID, _ objc.SEL, _ objc.ID) { go t.show() }},
{Cmd: sel("quit:"), Fn: func(_ objc.ID, _ objc.SEL, _ objc.ID) {
go func() {
if err := t.quit(); err != nil {
slog.Warn("tray: quit", "err", err)
}
}()
}},
})
if err != nil {
return 0, err
}
return objc.ID(class).Send(sel("new")), nil
}

// refresh rewrites the session count. A count it cannot read leaves the title
// as it was rather than show a wrong one.
func (t *Tray) refresh(m menu, l Labels) {
live, err := t.live()
if err != nil {
slog.Warn("tray: count the running sessions", "err", err)
return
}
title := l.running(live)
appkit.Do(func() { m.running.Send(sel("setTitle:"), nsString(title)) })
}

func (t *Tray) serve(m menu) {
ticker := time.NewTicker(refreshEvery)
defer ticker.Stop()
for range ticker.C {
t.refresh(m, t.currentLabels())
}
}

// menuItem is an entry calling action on target; one with no action is drawn
// disabled, which is what the session count is.
func menuItem(title string, action objc.SEL, target objc.ID) objc.ID {
item := objc.ID(objc.GetClass("NSMenuItem")).Send(sel("alloc")).
Send(sel("initWithTitle:action:keyEquivalent:"), nsString(title), action, nsString(""))
if target != 0 {
item.Send(sel("setTarget:"), target)
}
return item
}

func statusImage(png []byte) objc.ID {
data := objc.ID(objc.GetClass("NSData")).Send(sel("dataWithBytes:length:"), unsafe.Pointer(&png[0]), uint(len(png)))
runtime.KeepAlive(png)
image := objc.ID(objc.GetClass("NSImage")).Send(sel("alloc")).Send(sel("initWithData:"), data)
image.Send(sel("setSize:"), nsSize{Width: iconPoints, Height: iconPoints})
return image
}

func nsString(s string) objc.ID {
return objc.ID(objc.GetClass("NSString")).Send(sel("stringWithUTF8String:"), s)
}

func sel(name string) objc.SEL { return objc.RegisterName(name) }
3 changes: 2 additions & 1 deletion internal/tray/tray.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ type Tray struct {
}

// Icons are the two encodings the platforms take: PNG for the Linux
// StatusNotifierItem, ICO for the Windows notification area.
// StatusNotifierItem and the macOS menu bar, ICO for the Windows notification
// area.
type Icons struct {
PNG []byte
ICO []byte
Expand Down
14 changes: 14 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (

"github.com/lichdotdev/lich/api"
"github.com/lichdotdev/lich/internal/agentplugin"
"github.com/lichdotdev/lich/internal/appkit"
"github.com/lichdotdev/lich/internal/appupdate"
"github.com/lichdotdev/lich/internal/claudeprofile"
"github.com/lichdotdev/lich/internal/cli"
Expand Down Expand Up @@ -99,7 +100,13 @@ func main() {
if code := cli.Run(os.Args[1:], version, os.Getenv, os.Stdout, os.Stderr); code != cli.NotACommand {
os.Exit(code)
}
// On macOS AppKit's loop takes the main thread, and the app runs beside it
// (internal/appkit).
appkit.Run(app)
}

// app is lich itself: the backend, its listener and its window.
func app() {
noWindow := slices.Contains(os.Args[1:], restart.NoWindowArg)

configDir, err := os.UserConfigDir()
Expand Down Expand Up @@ -337,6 +344,13 @@ func registerServices(db *store.Service, term *terminal.Service, hub *events.Hub
Failed: windowFailed(configDir, liveCount, coord.Quit),
})
routes.System(dispatcher, sys)
// macOS opens lich again by telling the running one, and ends it at logout
// the same way.
appkit.Handle(func() { showWindow(sys, "on reopen") }, func() {
if err := coord.Quit(); err != nil {
slog.Warn("quit at logout", "err", err)
}
})
// Deleting a session for good takes any prompt parked on it, and the row is
// the only copy of what the user wrote. Both channels are used, and neither
// is the other's fallback: the toast is what the user reading the card sees,
Expand Down
Loading