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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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.
- **Services: commands a project keeps running, started by lich.** Save a container, a tunnel or a dev server once in Settings › Services of a project, and start it from the Services row at the bottom of the sidebar, above the profile. Closed, the row says how many run (2/3); opened, it lists each service with how long it has run or how it ended, and Start, Stop and Restart on hover. A click opens its console, where it takes input and Ctrl+C stops it. A service is not a terminal: when its command exits, it has stopped, its last output stays readable, and Start runs it again. Tick Start with lich and it starts every time lich does with its project open. Services saved in Settings › Services with Global selected run in your home directory and show on lich's home screen, the place for a `lazydocker` you keep open whatever the project. A service lives as long as its project's tab: closing the tab stops it, and reopening the project starts the ones saved to start with lich. Closing the window leaves them running, and the tray lists them with Show output, Restart and Stop; quitting lich stops them, and the close dialog counts them. With the sidebar collapsed, the rail's Services button opens the same list.

### 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.

### Fixed

- The collapsed sidebar shows the profile new sessions are born in, at the bottom of the rail, and opens the same menu the open sidebar's footer does. It used to show no profile at all.
- A tab in the top bar can no longer get stuck lifted, drawn in the accent color as if it were being dragged, with no tab moving again until lich restarted. A tab focused by a click and then given Space or Enter was picked up to reorder from the keyboard, and once a click elsewhere took the keyboard it could neither be dropped nor cancelled; it now goes back to its place when it loses focus. A layout tab renamed in the middle of a drag no longer gets stuck the same way.
- 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.
Expand Down
8 changes: 8 additions & 0 deletions api/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,14 @@ var (
// instead of the screen that made the change telling the others.
PullRequestChangedEvent = defineEvent[PRChanged]("pull-request-changed")

// ServicesEvent carries every project service and its state, emitted once a
// change to either is true: a save, a delete, a start, an exit.
ServicesEvent = defineEvent[[]Service]("services")

// ServiceRevealEvent asks the window to open a service's console, which the
// tray's Show output does from outside it.
ServiceRevealEvent = defineEvent[ServiceIDOptions]("service-reveal")

// WindowCloseEvent asks the native window to close, lich quitting.
WindowCloseEvent = defineEvent[None]("window-close")

Expand Down
65 changes: 65 additions & 0 deletions api/services.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package api

var (
ServicesList = define[None, []Service]("services.List")
ServicesSave = define[SaveServiceOptions, Service]("services.Save")
ServicesDelete = define[ServiceIDOptions, None]("services.Delete")
ServicesStart = define[ServiceIDOptions, None]("services.Start")
ServicesStop = define[ServiceIDOptions, None]("services.Stop")
ServicesForceStop = define[ServiceIDOptions, None]("services.ForceStop")
ServicesRestart = define[ServiceIDOptions, None]("services.Restart")
ServicesStartAll = define[ProjectIDOptions, None]("services.StartAll")
ServicesStopAll = define[ProjectIDOptions, None]("services.StopAll")
ServicesOutput = define[ServiceIDOptions, []byte]("services.Output")
)

// The states a Service is in. A service that is not running is stopped when it
// exited on its own with 0 or because it was asked to (Stop, or Ctrl+C typed at
// its console), and failed when it exited on its own with anything else.
const (
ServiceStopped = "stopped"
ServiceRunning = "running"
ServiceStopping = "stopping"
ServiceFailed = "failed"
)

// ServiceIDOptions names a service by its id.
type ServiceIDOptions struct {
ID string `json:"id"`
}

// ServiceConfig is a project service as the user saved it: a command lich runs
// in the project's directory, on its own PTY, until it exits. StartWithLich
// starts it whenever the lich backend starts.
type ServiceConfig struct {
ID string `json:"id"`
ProjectID string `json:"projectId"`
Name string `json:"name"`
Command string `json:"command"`
StartWithLich bool `json:"startWithLich"`
}

// SaveServiceOptions writes a service: a new one when ID is "", the one ID
// names otherwise. A changed command reaches a running service at its next start.
type SaveServiceOptions struct {
ID string `json:"id"`
ProjectID string `json:"projectId"`
Name string `json:"name"`
Command string `json:"command"`
StartWithLich bool `json:"startWithLich"`
}

// Service is a service and where its process is. Terminal is the id its PTY
// streams under on /ws while it runs, the one terminal.Write, Resize, Replay
// and SetVisible take. ExitCode is set once a run exited with a status of its
// own (nil while it runs, before it ever ran, or when it was killed). StartedAt
// and StoppedAt are unix milliseconds, 0 for never.
type Service struct {
ServiceConfig
ProjectName string `json:"projectName"`
Terminal string `json:"terminal"`
State string `json:"state"`
ExitCode *int `json:"exitCode,omitempty"`
StartedAt int64 `json:"startedAt"`
StoppedAt int64 `json:"stoppedAt"`
}
16 changes: 11 additions & 5 deletions api/system.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,16 @@ type Diagnostics struct {
LogPath string `json:"logPath"`
}

// TrayLabels are the tray menu's words, in the interface language. Running
// carries a {count} placeholder and is written so it needs no plural form.
// TrayLabels are the tray menu's words, in the interface language. Running and
// ServicesRunning carry a {count} placeholder and are written so they need no
// plural form.
type TrayLabels struct {
Show string `json:"show"`
Running string `json:"running"`
Quit string `json:"quit"`
Show string `json:"show"`
Running string `json:"running"`
Quit string `json:"quit"`
ServicesRunning string `json:"servicesRunning"`
ShowOutput string `json:"showOutput"`
Restart string `json:"restart"`
Stop string `json:"stop"`
StopAll string `json:"stopAll"`
}
47 changes: 47 additions & 0 deletions api/testdata/contract.golden
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,16 @@ relay.SendPrivate(SendOptions) SendResult
relay.SendSubagent(SendOptions) SendResult
relay.Submit(SubmitOptions) None
relay.Wait(WaitOptions) SendResult
services.Delete(ServiceIDOptions) None
services.ForceStop(ServiceIDOptions) None
services.List(None) []Service
services.Output(ServiceIDOptions) base64
services.Restart(ServiceIDOptions) None
services.Save(SaveServiceOptions) Service
services.Start(ServiceIDOptions) None
services.StartAll(ProjectIDOptions) None
services.Stop(ServiceIDOptions) None
services.StopAll(ProjectIDOptions) None
spawn.Ask(AskSessionOptions) SessionAnswer
spawn.Close(CloseSessionOptions) ClosedSession
spawn.ColorFolder(ColorFolderOptions) ColoredFolder
Expand Down Expand Up @@ -209,6 +219,8 @@ event project-closed ProjectClosed
event project-opened Project
event projects-reordered ProjectsReordered
event pull-request-changed PRChanged
event service-reveal ServiceIDOptions
event services []Service
event session-agent SessionAgent
event session-closed SessionClosed
event session-conversation SessionConversation
Expand Down Expand Up @@ -777,6 +789,28 @@ WaitOptions {
waitSeconds int
}

ServiceIDOptions {
id string
}

Service {
...ServiceConfig
projectName string
terminal string
state string
exitCode,omitempty *int
startedAt int64
stoppedAt int64
}

SaveServiceOptions {
id string
projectId string
name string
command string
startWithLich bool
}

AskSessionOptions {
from string
target string
Expand Down Expand Up @@ -1318,6 +1352,11 @@ TrayLabels {
show string
running string
quit string
servicesRunning string
showOutput string
restart string
stop string
stopAll string
}

AppUpdateProgress {
Expand Down Expand Up @@ -1628,6 +1667,14 @@ QuotaWindow {
ahead,omitempty bool
}

ServiceConfig {
id string
projectId string
name string
command string
startWithLich bool
}

ParkedSession {
id string
projectId string
Expand Down
30 changes: 30 additions & 0 deletions docs/ceilings.md
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,36 @@ work when nobody knows it and that the call site never shows. The mechanism and
it across sessions: a push that finds it full, and the flush a session runs before its exit banner so the banner
cannot overtake its own last bytes.

## Services

- **A service is up exactly as long as its command runs** (`internal/services`, `internal/terminal/service.go`):
there is no shell behind it and no supervisor. A command that detaches (`docker compose up -d`, a trailing
`&`, a tool that daemonizes) exits 0 at once and the service shows stopped while its work carries on, and a
service that fails stays failed until someone presses Start. Stop types Ctrl+C at the PTY, so a command that
ignores it stays stopping until Force stop. Force stop and quitting lich end the process lich started
(SIGTERM, then SIGKILL past the PTY's grace), not what it handed off to: a container `docker compose up`
started survives the kill of that command.
- **A service's command loads no rc file, except under fish** (`serviceArgs`): `$SHELL -c` on Linux and macOS,
PowerShell `-NoProfile` on Windows, so an alias, a function or a `PATH` entry from the user's rc is not there.
fish reads `config.fish` even for `-c`, so the same line can work there and nowhere else.
- **A service lives as long as its project's tab** (`services.ProjectChanged`, `main.go`): closing the tab asks
its services to stop and kills what is still up after 10 seconds, without asking first, and reopening the
project starts only the ones saved to start with lich; one started by hand stays down. Closing the window
stops nothing. Quitting lich stops everything the same way, and an in-place restart after an update is a quit:
the new lich starts the services saved to start with lich in the projects it opens with, so one started by
hand is down after an update. Home's services, edited from Settings › Services in Global, run for as long as
lich does, since Home has no tab to close.
- **What a stopped service printed is kept in memory only** (`services.Output`): the tail of its last run, as
long as a session's replay, until it starts again or lich exits. After a restart of lich a service that has
not run yet has no output to show.
- **The tray names at most eight running services** (`internal/tray`, `serviceSlots`): the menu is built once
and its entries shown and hidden, so past eight the count above them says how many run and the window lists
them all. The macOS menu-bar entries were never run on a Mac: they compile, and the logic they share with
Linux and Windows is tested.
- **On Windows, Stop is Ctrl+C through ConPTY** (`InterruptService`): it reaches the console's processes as
`CTRL_C_EVENT`, which a process that left the console or turned the event off never sees. Not measured: the
service tests spawn real PTYs and run on Linux and macOS only.

## Window, sidebar and settings

- **A closed card outlives its session by 180ms** (`native/cmd/lich-native/sidebar_closing.go`, `exitDuration`):
Expand Down
28 changes: 26 additions & 2 deletions internal/relay/relay_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1150,6 +1150,30 @@ func awaitDelivered(t *testing.T, svc *Service, n int) {
t.Fatalf("never saw %d delivered tickets", n)
}

// awaitSubmitted blocks until n tickets are submitted at their target: both
// writes of a typed delivery are in. A ticket is stamped delivered before the
// paste is written (stampDelivery), so a test that counts the target's writes
// to tell the delivery from what came after has to wait for this instead.
func awaitSubmitted(t *testing.T, svc *Service, n int) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
svc.mu.Lock()
count := 0
for _, tk := range svc.tickets {
if tk.submitted {
count++
}
}
svc.mu.Unlock()
if count == n {
return
}
time.Sleep(time.Millisecond)
}
t.Fatalf("never saw %d submitted tickets", n)
}

// TestADoneWithTwoErrandsOpenStallsBothAndAsksForTheTicket proves what a turn
// ending says, and what it does not. Two messages delivered back to back —
// inside the second or two before the target's busy report lands — queue as two
Expand Down Expand Up @@ -1178,7 +1202,7 @@ func TestADoneWithTwoErrandsOpenStallsBothAndAsksForTheTicket(t *testing.T) {
got, _ := svc.Send(context.Background(), SendOptions{From: "s3", Target: "docs", Prompt: "build the docs", WaitSeconds: 5})
second <- got
}()
awaitDelivered(t, svc, 2)
awaitSubmitted(t, svc, 2)
delivery := len(term.writesTo("s2"))
tickets := openTickets(svc)

Expand Down Expand Up @@ -1234,7 +1258,7 @@ func TestADoneWithOneErrandOpenStallsItWithoutANotice(t *testing.T) {
got, _ := svc.Send(context.Background(), SendOptions{From: "s1", Target: "docs", Prompt: "run the tests", WaitSeconds: 5})
only <- got
}()
awaitDelivered(t, svc, 1)
awaitSubmitted(t, svc, 1)
delivery := len(term.writesTo("s2"))

svc.Observe("s2", "busy")
Expand Down
1 change: 1 addition & 0 deletions internal/routes/routes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ func TestHandleAppServicesCoverEveryMethod(t *testing.T) {
"providers.": func(d *rpc.Handler) { Providers(d, nil) },
"terminal.": func(d *rpc.Handler) { Terminal(d, nil) },
"project.": func(d *rpc.Handler) { Project(d, nil) },
"services.": func(d *rpc.Handler) { Services(d, nil) },
} {
d := rpc.New()
route(d)
Expand Down
21 changes: 21 additions & 0 deletions internal/routes/services.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package routes

import (
"github.com/lichdotdev/lich/api"
"github.com/lichdotdev/lich/internal/rpc"
"github.com/lichdotdev/lich/internal/services"
)

// Services exposes the project services as api declares them.
func Services(d *rpc.Handler, s *services.Service) {
rpc.Handle(d, api.ServicesList, s.List)
rpc.Handle(d, api.ServicesSave, s.Save)
rpc.Handle(d, api.ServicesDelete, s.Delete)
rpc.Handle(d, api.ServicesStart, s.Start)
rpc.Handle(d, api.ServicesStop, s.Stop)
rpc.Handle(d, api.ServicesForceStop, s.ForceStop)
rpc.Handle(d, api.ServicesRestart, s.Restart)
rpc.Handle(d, api.ServicesStartAll, s.StartAll)
rpc.Handle(d, api.ServicesStopAll, s.StopAll)
rpc.Handle(d, api.ServicesOutput, s.Output)
}
49 changes: 49 additions & 0 deletions internal/services/rpc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package services

import (
"context"

"github.com/lichdotdev/lich/api"
)

// List is every service with its state, each project's in the order they were
// added.
func (s *Service) List(_ context.Context, _ api.None) ([]api.Service, error) {
return s.list()
}

// Start runs a service in its project's directory; see start.
func (s *Service) Start(_ context.Context, opts api.ServiceIDOptions) (api.None, error) {
return api.None{}, s.start(opts.ID)
}

// Stop asks a service to stop the way Ctrl+C at its console does; see stop.
func (s *Service) Stop(_ context.Context, opts api.ServiceIDOptions) (api.None, error) {
return api.None{}, s.stop(opts.ID)
}

// ForceStop kills a service without waiting for it to agree.
func (s *Service) ForceStop(_ context.Context, opts api.ServiceIDOptions) (api.None, error) {
return api.None{}, s.forceStop(opts.ID)
}

// Restart stops a service and starts it again once it exited.
func (s *Service) Restart(_ context.Context, opts api.ServiceIDOptions) (api.None, error) {
return api.None{}, s.restart(opts.ID)
}

// StartAll starts every service of a project that is not running.
func (s *Service) StartAll(_ context.Context, opts api.ProjectIDOptions) (api.None, error) {
return api.None{}, s.startAll(opts.ID)
}

// StopAll stops every service of a project that is running.
func (s *Service) StopAll(_ context.Context, opts api.ProjectIDOptions) (api.None, error) {
return api.None{}, s.stopAll(opts.ID)
}

// Output is what a service printed last, from the moment its last run exited:
// nil while it runs, its console being live on /ws then, or before it ever ran.
func (s *Service) Output(_ context.Context, opts api.ServiceIDOptions) ([]byte, error) {
return s.output(opts.ID), nil
}
Loading
Loading